Setting the file. One moment.
URL Safety · Wix Headless Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page 15.10
Workflow
scripts/lib/ url-safety.mjs
JavaScript · 167 lines · 7 KB
9
10 function ipv4Parts ( address ) {
11 const parts = address. split ( "." ). map (Number);
12 return parts. length === 4 && parts. every (( n ) => Number. isInteger (n) && n >= 0 && n <= 255 ) ? parts : null ;
13 }
14
15 export function isPrivateAddress ( address ) {
16 const value = String (address || "" ). toLowerCase (). replace ( / ^ \[ | \] $ / g , "" );
17 const mapped = / ^ ::ffff:( \d + \. \d + \. \d + \. \d + ) $ / . exec (value);
18 if (mapped) return isPrivateAddress (mapped[ 1 ]);
19 // WHATWG URL normalizes an IPv4-mapped address to hex groups (`::ffff:7f00:1`).
20 const mappedHex = / ^ ::ffff:( [0-9a-f] {1,4} ):( [0-9a-f] {1,4} ) $ / . exec (value);
21 if (mappedHex) {
22 const high = parseInt (mappedHex[ 1 ], 16 );
23 const low = parseInt (mappedHex[ 2 ], 16 );
24 return isPrivateAddress ( `${ high >> 8 }.${ high & 255 }.${ low >> 8 }.${ low & 255 }` );
25 }
26 if (net. isIPv4 (value)) {
27 const [ a , b ] = ipv4Parts (value);
28 return (
29 a === 0 || a === 10 || a === 127 ||
30 (a === 100 && b >= 64 && b <= 127 ) ||
31 (a === 169 && b === 254 ) ||
32 (a === 172 && b >= 16 && b <= 31 ) ||
33 (a === 192 && b === 168 ) ||
34 (a === 192 && b === 0 ) ||
35 (a === 198 && (b === 18 || b === 19 )) ||
36 a >= 224
37 );
38 }
39 if (net. isIPv6 (value)) {
40 return value === "::" || value === "::1" || / ^ f [cd] / . test (value) || / ^ fe [89ab] / . test (value) || / ^ ff/ . test (value);
41 }
42 return false ;
43 }
44
45 // The clone under test (post-build-gap) is served from a local dev server or a Wix preview, which the
46 // guards above would refuse. It registers its own origin once; nothing else is ever trusted.
47 const TRUSTED_ORIGINS_ENV = "RP_TRUSTED_ORIGINS" ;
48 const trustedOrigins = new Set ();
49 // A child process (the design extractor) cannot share this set, so the parent passes it down.
50 for ( const value of String (process.env[ TRUSTED_ORIGINS_ENV ] || "" ). split ( "," ). filter (Boolean)) {
51 try { trustedOrigins. add ( new URL (value).origin); } catch { /* ignore a malformed entry */ }
52 }
53
54 export function trustedOriginsEnv () {
55 return { [ TRUSTED_ORIGINS_ENV ]: [ ... trustedOrigins]. join ( "," ) };
56 }
57
58 export function trustOrigin ( value ) {
59 trustedOrigins. add ( new URL ( String (value)).origin);
60 }
61
62 export function isTrustedOrigin ( value ) {
63 try {
64 return trustedOrigins. has ( new URL ( String (value)).origin);
65 } catch {
66 return false ;
67 }
68 }
69
70 /** Throws unless `value` is an http(s) URL whose host resolves only to public addresses. */
71 export async function assertPublicHttpUrl ( value , { lookup = dns.lookup } = {}) {
72 if ( isTrustedOrigin (value)) return value instanceof URL ? value : new URL ( String (value));
73 let url;
74 try {
75 url = value instanceof URL ? value : new URL ( String (value));
76 } catch {
77 throw new Error ( `Refusing to browse an invalid URL: ${ String ( value ). slice ( 0 , 200 ) }` );
78 }
79 if (url.protocol !== "http:" && url.protocol !== "https:" ) {
80 throw new Error ( `Refusing to browse a non-http(s) URL (${ url . protocol })` );
81 }
82 const host = url.hostname. toLowerCase (). replace ( / ^ \[ | \] $ / g , "" ). replace ( / \. $ / , "" );
83 if ( ! host || host === "localhost" || BLOCKED_HOST_SUFFIXES . some (( suffix ) => host. endsWith (suffix))) {
84 throw new Error ( `Refusing to browse an internal host: ${ host }` );
85 }
86 if (net. isIP (host)) {
87 if ( isPrivateAddress (host)) throw new Error ( `Refusing to browse a private address: ${ host }` );
88 return url;
89 }
90 let addresses;
91 try {
92 addresses = await lookup (host, { all: true });
93 } catch (error) {
94 throw new Error ( `Could not resolve ${ host }: ${ error . message }` );
95 }
96 const blocked = addresses. find (( entry ) => isPrivateAddress (entry.address));
97 if (blocked) throw new Error ( `Refusing to browse ${ host }: it resolves to a private address (${ blocked . address })` );
98 return url;
99 }
100
101 /**
102 * fetch() that checks every hop: the first URL and each redirect target must be a public http(s) host
103 * (the platform follows redirects silently, so a public page could otherwise bounce to cloud metadata).
104 * The resolve-then-connect gap (DNS rebinding) remains; the browser-side guard and a network-isolated
105 * sandbox are the backstop.
106 */
107 export async function fetchPublic ( value , init = {}, { lookup , maxRedirects = 5 , fetchImpl = fetch } = {}) {
108 let current = String (value);
109 for ( let hop = 0 ; hop <= maxRedirects; hop += 1 ) {
110 await assertPublicHttpUrl (current, { lookup });
111 const response = await fetchImpl (current, { ... init, redirect: "manual" });
112 if (response.status >= 300 && response.status < 400 && response.headers. get ( "location" )) {
113 current = new URL (response.headers. get ( "location" ), current). toString ();
114 continue ;
115 }
116 return response;
117 }
118 throw new Error ( `Too many redirects fetching ${ String ( value ). slice ( 0 , 200 ) }` );
119 }
120
121 /**
122 * Aborts every request the page makes (navigations, subresources) to a non-public destination, and every
123 * redirect to one. Playwright does not route the hops that follow a `route.continue()`, so each request is
124 * fetched here without following redirects: a 3xx is checked against its `Location` before it is handed
125 * back to the browser, which then requests the next hop through this same handler. The registered clone
126 * origin is exempt as a destination, but its redirects are still checked. Verdicts are cached per origin so
127 * a page with many assets resolves each host once.
128 */
129 export async function guardPageRequests ( page , { lookup } = {}) {
130 const verdicts = new Map ();
131 const isAllowed = ( url ) => {
132 let origin;
133 try {
134 origin = new URL (url).origin;
135 } catch {
136 return Promise . resolve ( false );
137 }
138 if ( ! verdicts. has (origin)) {
139 verdicts. set (origin, assertPublicHttpUrl (url, { lookup }). then (() => true , () => false ));
140 }
141 return verdicts. get (origin);
142 };
143 await page. route ( "**/*" , async ( route ) => {
144 const request = route. request ();
145 const url = request. url ();
146 const scheme = url. slice ( 0 , url. indexOf ( ":" ) + 1 );
147 if (scheme === "data:" || scheme === "blob:" || scheme === "about:" ) return route. continue ();
148 if ( ! ( await isAllowed (url))) return route. abort ( "blockedbyclient" );
149 let response;
150 try {
151 response = await route. fetch ({ maxRedirects: 0 });
152 } catch {
153 return route. abort ( "failed" );
154 }
155 const location = response. headers ().location;
156 if (response. status () >= 300 && response. status () < 400 && location) {
157 let target;
158 try {
159 target = new URL (location, url). toString ();
160 } catch {
161 return route. abort ( "blockedbyclient" );
162 }
163 if ( ! ( await isAllowed (target))) return route. abort ( "blockedbyclient" );
164 }
165 return route. fulfill ({ response });
166 });
167 }