Setting the file. One moment.
Document URL Guard · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page Bundled file Discovered Set Status
lib/ document-url-guard.js
JavaScript · 234 lines · 13 KB
15 // `classifyDocumentUrl` is a LEXICAL check on the URL as written: it does not resolve DNS and it
16 // does not follow redirects, so a public hostname that resolves to a private address, or one that
17 // redirects to it, passes it. It must therefore never be the only thing between an untrusted URL
18 // and a server-side fetch. `fetchPublicDocument` is the fetch that is safe to run on our side:
19 // it resolves the host, refuses any private answer, connects to the ADDRESS IT VALIDATED (so the
20 // name cannot resolve differently between check and connect -- no DNS rebinding), walks redirects
21 // hop by hop applying the same checks, and caps the response size. The bytes are then uploaded to
22 // Wix Media, so Wix never fetches an untrusted URL at all.
23
24 const dns = require ( 'node:dns' );
25 const net = require ( 'node:net' );
26
27 // Only a GLOBAL UNICAST address may be fetched. This is an allowlist, not a list of known-private
28 // ranges: a deny list misses whatever it forgot (multicast, broadcast, benchmarking, documentation,
29 // newer IPv6 scopes). The ranges below are the IANA IPv4/IPv6 Special-Purpose Address Registries'
30 // entries that are not globally reachable, plus multicast and reserved space. IPv6 must also lie in
31 // 2000::/3, the only block IANA allocates for global unicast. The same rules live in
32 // wix-harmony-editor's copy/public-address.mjs (a separate published skill that cannot import this
33 // file); tests/target-wix/public-address-parity-test.js keeps the two identical.
34 const NON_GLOBAL = new net. BlockList ();
35 for ( const [ address , prefix ] of [
36 [ '0.0.0.0' , 8 ], [ '10.0.0.0' , 8 ], [ '100.64.0.0' , 10 ], [ '127.0.0.0' , 8 ], [ '169.254.0.0' , 16 ],
37 [ '172.16.0.0' , 12 ], [ '192.0.0.0' , 24 ], [ '192.0.2.0' , 24 ], [ '192.88.99.0' , 24 ], [ '192.168.0.0' , 16 ],
38 [ '198.18.0.0' , 15 ], [ '198.51.100.0' , 24 ], [ '203.0.113.0' , 24 ], [ '224.0.0.0' , 4 ], [ '240.0.0.0' , 4 ],
39 ]) NON_GLOBAL . addSubnet (address, prefix, 'ipv4' );
40 // Inside 2000::/3: IETF protocol assignments (incl. Teredo), documentation, 6to4 (it can wrap any
41 // IPv4 address, private ones included), and the second documentation block.
42 for ( const [ address , prefix ] of [[ '2001::' , 23 ], [ '2001:db8::' , 32 ], [ '2002::' , 16 ], [ '3fff::' , 20 ]]) NON_GLOBAL . addSubnet (address, prefix, 'ipv6' );
43
44 function ipv6Hextets ( address ) {
45 let text = address;
46 const dotted = text. match ( / ^ ( . * :)( \d {1,3} (?: \. \d {1,3} ) {3} ) $ / );
47 if (dotted) {
48 const octets = dotted[ 2 ]. split ( '.' ). map (Number);
49 text = `${ dotted [ 1 ] }${ (( octets [ 0 ] << 8 ) | octets [ 1 ]). toString ( 16 ) }:${ (( octets [ 2 ] << 8 ) | octets [ 3 ]). toString ( 16 ) }` ;
50 }
51 const [ head , tail ] = text. includes ( '::' ) ? text. split ( '::' ) : [text, null ];
52 const left = head ? head. split ( ':' ) : [];
53 const right = tail ? tail. split ( ':' ) : [];
54 const fill = tail === null ? [] : new Array ( 8 - left. length - right. length ). fill ( '0' );
55 return [ ... left, ... fill, ... right]. map (( part ) => parseInt (part, 16 ));
56 }
57
58 // True only for a literal IP address that is globally routable unicast. Anything else -- a
59 // hostname, an unparseable string, a private/special-purpose address -- is false.
60 function isGlobalUnicastAddress ( value ) {
61 const address = String (value || '' ). toLowerCase (). replace ( / ^ \[ | \] $ / g , '' );
62 const family = net. isIP (address);
63 if (family === 4 ) return ! NON_GLOBAL . check (address, 'ipv4' );
64 if (family !== 6 ) return false ;
65 const hextets = ipv6Hextets (address);
66 // An IPv4-mapped address (::ffff:a.b.c.d, which Node prints as ::ffff:hhhh:hhhh) is the IPv4
67 // destination in another notation: judge the IPv4 address it carries.
68 if (hextets. slice ( 0 , 5 ). every (( part ) => part === 0 ) && hextets[ 5 ] === 0xffff ) {
69 return isGlobalUnicastAddress ([hextets[ 6 ] >> 8 , hextets[ 6 ] & 0xff , hextets[ 7 ] >> 8 , hextets[ 7 ] & 0xff ]. join ( '.' ));
70 }
71 if ((hextets[ 0 ] & 0xe000 ) !== 0x2000 ) return false ;
72 return ! NON_GLOBAL . check (address, 'ipv6' );
73 }
74
75 // Lexical check on a URL hostname: an IP literal must be global unicast; a name is refused only
76 // when it is local by definition, and is otherwise judged later by the addresses it resolves to.
77 function isPrivateHostname ( hostname ) {
78 // A trailing dot is the same name (`localhost.` resolves like `localhost`) and WHATWG URL keeps it on a name host.
79 const host = String (hostname || '' ). toLowerCase (). replace ( / ^ \[ | \] $ / g , '' ). replace ( / \. +$ / , '' );
80 if ( ! host) return true ;
81 if (net. isIP (host)) return ! isGlobalUnicastAddress (host);
82 return host === 'localhost' || host. endsWith ( '.localhost' ) || host. endsWith ( '.local' );
83 }
84
85 // Signal D. Every discovered document is classified, not only the sampled ones -- the strategy
86 // depends on where the bytes are, and one WordPress-hosted document in an otherwise PSP-hosted
87 // store still dies with the source site.
88 function classifyDocumentUrl ( rawUrl , { sourceSiteHost = null } = {}) {
89 const text = String (rawUrl || '' ). trim ();
90 if (text. length === 0 ) return { safe: false , location: null , reason: 'empty-url' };
91
92 let url;
93 try {
94 url = new URL (text);
95 } catch (error) {
96 return { safe: false , location: null , reason: 'unparseable-url' };
97 }
98
99 if (url.protocol !== 'https:' ) return { safe: false , location: null , reason: 'not-https' };
100 if (url.username || url.password) return { safe: false , location: null , reason: 'embedded-credentials' };
101 if (url.port && url.port !== '443' ) return { safe: false , location: null , reason: 'non-standard-port' };
102 if ( isPrivateHostname (url.hostname)) return { safe: false , location: null , reason: 'private-or-loopback-host' };
103
104 const host = url.hostname. toLowerCase ();
105 const sameSite = sourceSiteHost
106 && (host === String (sourceSiteHost). toLowerCase () || host. endsWith ( `.${ String ( sourceSiteHost ). toLowerCase () }` ));
107
108 // A same-origin uploads path is the WordPress filesystem: those bytes are gone when the site
109 // goes. Anything else is somebody else's archive, which survives the migration but not the
110 // merchant leaving that provider.
111 const location = sameSite ? 'wordpress' : 'third-party' ;
112 return { safe: true , location, reason: null , host };
113 }
114
115 const MAX_REDIRECTS = 3 ;
116 const FETCH_TIMEOUT_MS = 20000 ;
117 // The bytes go to Media's Generate File Upload URL, which is non-resumable; Wix sends files over
118 // 10 MB to the resumable endpoint, so a document is capped below that.
119 const DEFAULT_MAX_BYTES = 10 * 1000 * 1000 ;
120
121 // Default transport: one GET over HTTPS, pinned to an address the caller already validated. TLS
122 // still verifies the certificate against the hostname (`servername`), so pinning the address does
123 // not weaken it. The body is read up to `maxBytes` and the connection is dropped past that.
124 function pinnedHttpsGet ( url , { address , family , timeoutMs , maxBytes }) {
125 const https = require ( 'node:https' );
126 const target = new URL (url);
127 return new Promise (( resolve , reject ) => {
128 let settled = false ;
129 const done = ( fn , value ) => { if ( ! settled) { settled = true ; fn (value); } };
130 const request = https. request ({
131 protocol: 'https:' ,
132 hostname: target.hostname,
133 port: 443 ,
134 path: `${ target . pathname }${ target . search }` ,
135 method: 'GET' ,
136 servername: target.hostname,
137 headers: { Accept: '*/*' , 'Accept-Encoding' : 'identity' , 'User-Agent' : 'wix-replatform-document-fetch' },
138 lookup : ( hostname , options , callback ) => (options && options.all
139 ? callback ( null , [{ address, family }])
140 : callback ( null , address, family)),
141 timeout: timeoutMs,
142 }, ( response ) => {
143 const status = response.statusCode || 0 ;
144 const headers = { get : ( name ) => { const value = response.headers[ String (name). toLowerCase ()]; return Array. isArray (value) ? value[ 0 ] : (value === undefined ? null : value); } };
145 if (status >= 300 && status < 400 ) { response. resume (); done (resolve, { status, headers, body: Buffer. alloc ( 0 ) }); return ; }
146 const declared = Number (response.headers[ 'content-length' ]);
147 if (Number. isFinite (declared) && declared > maxBytes) { response. destroy (); done (reject, Object. assign ( new Error ( 'response-too-large' ), { code: 'TOO_LARGE' })); return ; }
148 const chunks = [];
149 let total = 0 ;
150 response. on ( 'data' , ( chunk ) => {
151 total += chunk. length ;
152 if (total > maxBytes) { response. destroy (); done (reject, Object. assign ( new Error ( 'response-too-large' ), { code: 'TOO_LARGE' })); return ; }
153 chunks. push (chunk);
154 });
155 response. on ( 'end' , () => done (resolve, { status, headers, body: Buffer. concat (chunks) }));
156 response. on ( 'error' , ( error ) => done (reject, error));
157 });
158 request. on ( 'timeout' , () => { request. destroy (); done (reject, Object. assign ( new Error ( 'timeout' ), { code: 'TIMEOUT' })); });
159 request. on ( 'error' , ( error ) => done (reject, error));
160 request. end ();
161 });
162 }
163
164 // Fetches the document's bytes on our side, never handing the URL to anything else. Returns
165 // { ok: true, bytes, contentType } or { ok: false, unsafe, reason }. `unsafe` separates a URL we
166 // refuse on principle (internal address, redirect to one, bad scheme) from a document that is
167 // merely unavailable (404, timeout, too large). `lookup` and `transport` are injectable.
168 async function fetchPublicDocument ( rawUrl , {
169 lookup = dns.promises.lookup,
170 transport = pinnedHttpsGet,
171 maxRedirects = MAX_REDIRECTS ,
172 timeoutMs = FETCH_TIMEOUT_MS ,
173 maxBytes = DEFAULT_MAX_BYTES ,
174 } = {}) {
175 let current = String (rawUrl || '' ). trim ();
176 for ( let hop = 0 ; hop <= maxRedirects; hop += 1 ) {
177 const classification = classifyDocumentUrl (current);
178 if ( ! classification.safe) return { ok: false , unsafe: true , reason: hop === 0 ? classification.reason : `redirect-${ classification . reason }` };
179 let addresses;
180 try {
181 addresses = await lookup ( new URL (current).hostname, { all: true });
182 } catch (error) {
183 return { ok: false , unsafe: false , reason: 'host-not-resolvable' };
184 }
185 const list = (Array. isArray (addresses) ? addresses : [addresses]). filter (Boolean);
186 if ( ! list. length || list. some (( entry ) => ! isGlobalUnicastAddress (entry && entry.address))) {
187 return { ok: false , unsafe: true , reason: 'resolves-to-private-address' };
188 }
189 const pinned = list[ 0 ];
190 let response;
191 try {
192 response = await transport (current, { address: pinned.address, family: pinned.family || ( String (pinned.address). includes ( ':' ) ? 6 : 4 ), timeoutMs, maxBytes });
193 } catch (error) {
194 return { ok: false , unsafe: false , reason: error && error.code === 'TOO_LARGE' ? 'response-too-large' : 'unreachable' };
195 }
196 if (response.status >= 300 && response.status < 400 ) {
197 const location = response.headers && response.headers.get && response.headers. get ( 'location' );
198 if ( ! location) return { ok: false , unsafe: true , reason: 'redirect-without-location' };
199 try {
200 current = new URL (location, current). toString ();
201 } catch (error) {
202 return { ok: false , unsafe: true , reason: 'redirect-unparseable-url' };
203 }
204 continue ;
205 }
206 if (response.status < 200 || response.status >= 300 ) return { ok: false , unsafe: false , reason: `http-${ response . status }` };
207 const body = Buffer. isBuffer (response.body) ? response.body : Buffer. from (response.body || '' );
208 if (body. length > maxBytes) return { ok: false , unsafe: false , reason: 'response-too-large' };
209 if (body. length === 0 ) return { ok: false , unsafe: false , reason: 'empty-document' };
210 const contentType = response.headers && response.headers.get ? response.headers. get ( 'content-type' ) : null ;
211 return { ok: true , bytes: body, contentType: contentType ? String (contentType). split ( ';' )[ 0 ]. trim (). toLowerCase () : null };
212 }
213 return { ok: false , unsafe: true , reason: 'too-many-redirects' };
214 }
215
216 // Never log or persist a raw document URL: the query string can carry a bearer token and the
217 // filename can carry the customer's name and the invoice number.
218 function redactUrlForLog ( rawUrl ) {
219 try {
220 const url = new URL ( String (rawUrl));
221 const segments = url.pathname. split ( '/' ). filter (Boolean). length ;
222 return `${ url . protocol }//${ url . hostname }/<${ segments } path segment(s) redacted>` ;
223 } catch (error) {
224 return '<unparseable url redacted>' ;
225 }
226 }
227
228 module . exports = {
229 isGlobalUnicastAddress,
230 isPrivateHostname,
231 classifyDocumentUrl,
232 fetchPublicDocument,
233 redactUrlForLog,
234 };