Setting the file. One moment.
Egress Proxy · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page function refusal
— line 283
This file
Number 45.74
Position 74 of 182
Type JavaScript
Size 21 KB
Lines 418 lib/ egress-proxy.js
JavaScript · 418 lines · 21 KB
14 // request, and never put in an error, a log line or a return value.
15
16 const http = require ( 'node:http' );
17 const https = require ( 'node:https' );
18 const net = require ( 'node:net' );
19 const tls = require ( 'node:tls' );
20 const zlib = require ( 'node:zlib' );
21
22 // The hop to the proxy itself must go direct. A Node started with --use-env-proxy (or
23 // NODE_USE_ENV_PROXY=1) routes the global agent through the proxy, which would send our CONNECT to
24 // the proxy through the proxy. An explicit agent is never proxied.
25 // No Wix or telemetry answer is near this; a runaway body is cut off instead of held in memory.
26 const MAX_BODY_BYTES = 64 * 1024 * 1024 ;
27
28 const DIRECT_AGENTS = { 'http:' : new http. Agent (), 'https:' : new https. Agent () };
29
30 function envValue ( env , ... names ) {
31 for ( const name of names) {
32 const value = env[name];
33 if ( typeof value === 'string' && value. trim () !== '' ) return value. trim ();
34 }
35 return null ;
36 }
37
38 // `new URL(...).hostname` keeps the square brackets on IPv6 literals.
39 function bareHost ( hostname ) {
40 return String (hostname). toLowerCase (). replace ( / ^ \[ | \] $ / g , '' );
41 }
42
43 // Every spelling of "this machine": the test mocks and local servers must never go to a proxy.
44 // `new URL` rewrites `::ffff:127.0.0.1` to `::ffff:7f00:1`, so the hex form is matched too.
45 function isLocalHost ( hostname ) {
46 const host = bareHost (hostname);
47 return host === 'localhost'
48 || host === '0.0.0.0'
49 || / ^ 127 \. \d {1,3} \. \d {1,3} \. \d {1,3}$ / . test (host)
50 || host === '::1'
51 || / ^ ::ffff:127 \. \d {1,3} \. \d {1,3} \. \d {1,3}$ / . test (host)
52 || / ^ ::ffff:7f [0-9a-f] {2} : [0-9a-f] {1,4}$ / . test (host);
53 }
54
55 function ipv4ToInt ( address ) {
56 return address. split ( '.' ). reduce (( total , part ) => (total * 256 ) + Number (part), 0 );
57 }
58
59 function inIpv4Range ( address , base , bits ) {
60 if (bits < 0 || bits > 32 ) return false ;
61 const size = 2 ** ( 32 - bits);
62 return Math. floor ( ipv4ToInt (address) / size) === Math. floor ( ipv4ToInt (base) / size);
63 }
64
65 // NO_PROXY: comma or space separated. `*` excludes everything. An entry matches the host exactly
66 // or as a domain suffix (`example.com`, `.example.com` and `*.example.com` all match
67 // `a.example.com`). An optional `:port` must equal the target port. An IPv4 range (`10.0.0.0/8`)
68 // matches the addresses inside it; IPv6 ranges are not matched.
69 function excludedByNoProxy ( target , env ) {
70 const list = envValue (env, 'NO_PROXY' , 'no_proxy' );
71 if ( ! list) return false ;
72 const host = bareHost (target.hostname). replace ( / \. $ / , '' );
73 const port = target.port || (target.protocol === 'https:' ? '443' : '80' );
74 return list. split ( / [\s,] + / ). filter (Boolean). some (( raw ) => {
75 const entry = raw. toLowerCase ();
76 if (entry === '*' ) return true ;
77 // An IPv4 range (10.0.0.0/8) excludes the IPv4 addresses inside it.
78 const cidr = entry. match ( / ^ ( \d {1,3} (?: \. \d {1,3} ) {3} ) \/ ( \d {1,2} ) $ / );
79 if (cidr) return net. isIPv4 (host) && inIpv4Range (host, cidr[ 1 ], Number (cidr[ 2 ]));
80 // `[::1]:8080` has a port; a bare IPv6 literal (`fe80::1`) has colons but no port.
81 const bracket = entry. match ( / ^ \[ ( [ ^ \] ] + ) \] (?::( \d + )) ?$ / );
82 const parts = bracket ? [entry, bracket[ 1 ], bracket[ 2 ]] : (net. isIPv6 (entry) ? [entry, entry, undefined ] : entry. match ( / ^ ( . *? )(?::( \d + )) ?$ / ));
83 let entryHost = parts[ 1 ]. replace ( / ^ \*\. ? / , '' ). replace ( / ^ \. / , '' ). replace ( / ^ \[ | \] $ / g , '' ). replace ( / \. $ / , '' );
84 // Compare IPv6 literals in the form `new URL` gives the target (compressed, lower case).
85 if (net. isIPv6 (entryHost)) { try { entryHost = bareHost ( new URL ( `http://[${ entryHost }]` ).hostname); } catch { /* keep as written */ } }
86 if ( ! entryHost) return false ;
87 if (parts[ 2 ] && parts[ 2 ] !== port) return false ;
88 return host === entryHost || host. endsWith ( `.${ entryHost }` );
89 });
90 }
91
92 // Node proxies `fetch` and `http(s).request` itself when started with NODE_USE_ENV_PROXY=1, but only
93 // from Node 22.21 and 24.0 (measured: 22.13 ignores it, 22.23 and 24 honor it). On an older Node the
94 // variable does nothing, so it must not stop us from using the tunnel.
95 function nodeProxiesFromEnv ( version = process.versions.node) {
96 const [ major , minor ] = String (version). split ( '.' ). map (Number);
97 return major >= 24 || (major === 22 && minor >= 21 );
98 }
99
100 // The proxy URL to use for `target`, or null when the request should go direct: Node is already
101 // proxying `fetch` itself, the target is local, no proxy variable applies, NO_PROXY excludes the
102 // host, or the proxy is not an http(s) proxy (a socks proxy is not supported; the call then goes
103 // direct, as before this file existed).
104 function proxyFor ( target , env = process.env, nodeVersion = process.versions.node) {
105 if (env. NODE_USE_ENV_PROXY === '1' && nodeProxiesFromEnv (nodeVersion)) return null ;
106 if ( isLocalHost (target.hostname)) return null ;
107 const raw = target.protocol === 'https:'
108 ? envValue (env, 'HTTPS_PROXY' , 'https_proxy' )
109 : envValue (env, 'HTTP_PROXY' , 'http_proxy' );
110 if ( ! raw) return null ;
111 if ( excludedByNoProxy (target, env)) return null ;
112 try {
113 const proxy = new URL (raw. includes ( '://' ) ? raw : `http://${ raw }` );
114 return proxy.protocol === 'http:' || proxy.protocol === 'https:' ? proxy : null ;
115 } catch {
116 return null ;
117 }
118 }
119
120 function proxyAuthHeaders ( proxy ) {
121 if ( ! proxy.username && ! proxy.password) return {};
122 // A password with a bare `%` is not valid percent-encoding; use it as written rather than fail.
123 const decode = ( value ) => { try { return decodeURIComponent (value); } catch { return value; } };
124 const credentials = `${ decode ( proxy . username ) }:${ decode ( proxy . password ) }` ;
125 return { 'proxy-authorization' : `Basic ${ Buffer . from ( credentials ). toString ( 'base64' ) }` };
126 }
127
128 // One request to `target` through `proxy`. `body` is a Buffer or null. Resolves with
129 // `{ status, statusText, headers, body }` (headers lower-cased, repeated headers as arrays, body a
130 // Buffer). Rejects with an error that has `code` (a Node error code) or `proxyStatus` (the proxy
131 // refused the tunnel). `signal` aborts everything. Never throws synchronously, and leaves no open
132 // handle after it settles.
133 function requestViaProxy ( target , proxy , { method = 'GET' , headers = {}, body = null , idleTimeoutMs = 0 } = {}, signal ) {
134 return new Promise (( resolve , reject ) => {
135 const cleanups = [];
136 let settled = false ;
137 const finish = ( fn , value ) => {
138 if (settled) return ;
139 settled = true ;
140 for ( const cleanup of cleanups) {
141 try { cleanup (); } catch { /* best effort */ }
142 }
143 fn (value);
144 };
145 const fail = ( error ) => finish (reject, error);
146
147 // An idle limit, not a total one: a large upload or a slow answer that keeps moving is fine; a
148 // connection that moves no bytes for `idleTimeoutMs` is not. `watched` is the socket whose byte
149 // counters show the movement (the proxy connection, then the tunnel).
150 let watched = null ;
151 let lastBytes = 0 ;
152 let idleTimer = null ;
153 const armIdle = () => {
154 if ( ! idleTimeoutMs) return ;
155 idleTimer = setTimeout (() => {
156 const bytes = watched ? watched.bytesRead + watched.bytesWritten : 0 ;
157 if (bytes !== lastBytes) { lastBytes = bytes; armIdle (); return ; }
158 fail (Object. assign ( new Error ( 'no activity before the deadline' ), { code: 'ETIMEDOUT' }));
159 }, idleTimeoutMs);
160 idleTimer. unref ();
161 };
162 cleanups. push (() => clearTimeout (idleTimer));
163 armIdle ();
164
165 const onAbort = () => fail (Object. assign ( new Error ( 'aborted' ), { code: 'ABORT_ERR' }));
166 if (signal) {
167 if (signal.aborted) { onAbort (); return ; }
168 signal. addEventListener ( 'abort' , onAbort, { once: true });
169 cleanups. push (() => signal. removeEventListener ( 'abort' , onAbort));
170 }
171
172 const requestHeaders = { ... headers, connection: 'close' };
173 if (body) requestHeaders[ 'content-length' ] = body. length ;
174 const readResponse = ( res ) => {
175 const chunks = [];
176 let received = 0 ;
177 res. on ( 'data' , ( chunk ) => {
178 received += chunk. length ;
179 if (received > MAX_BODY_BYTES ) { fail (Object. assign ( new Error ( 'answer too large' ), { code: 'ERR_BODY_TOO_LARGE' })); return ; }
180 chunks. push (chunk);
181 });
182 res. on ( 'end' , () => finish (resolve, {
183 status: res.statusCode,
184 statusText: res.statusMessage || '' ,
185 headers: res.headers,
186 body: Buffer. concat (chunks),
187 }));
188 res. on ( 'error' , fail);
189 res. on ( 'close' , () => fail (Object. assign ( new Error ( 'closed' ), { code: 'ECONNRESET' })));
190 };
191 const proxyHost = bareHost (proxy.hostname);
192 const proxyPort = Number (proxy.port || (proxy.protocol === 'https:' ? 443 : 80 ));
193 const proxyTransport = proxy.protocol === 'https:' ? https : http;
194
195 try {
196 if (target.protocol !== 'https:' ) {
197 // Plain http target: an absolute-URI request to the proxy.
198 const request = proxyTransport. request ({
199 host: proxyHost,
200 port: proxyPort,
201 method,
202 agent: DIRECT_AGENTS [proxy.protocol],
203 path: `${ target . origin }${ target . pathname }${ target . search }` ,
204 headers: { ... requestHeaders, host: target.host, ... proxyAuthHeaders (proxy) },
205 }, readResponse);
206 cleanups. push (() => request. destroy ());
207 request. on ( 'error' , fail);
208 request. on ( 'socket' , ( socket ) => { watched = socket; });
209 request. end (body);
210 return ;
211 }
212
213 const targetHost = bareHost (target.hostname);
214 const targetPort = Number (target.port || 443 );
215 // A CONNECT authority writes an IPv6 literal in square brackets.
216 const authority = `${ net . isIPv6 ( targetHost ) ? `[${ targetHost }]` : targetHost }:${ targetPort }` ;
217 const connectRequest = proxyTransport. request ({
218 host: proxyHost,
219 port: proxyPort,
220 method: 'CONNECT' ,
221 agent: DIRECT_AGENTS [proxy.protocol],
222 path: authority,
223 headers: { host: authority, ... proxyAuthHeaders (proxy) },
224 });
225 cleanups. push (() => connectRequest. destroy ());
226 connectRequest. on ( 'error' , fail);
227 connectRequest. on ( 'socket' , ( socket ) => { watched = socket; });
228 connectRequest. on ( 'connect' , ( res , socket ) => {
229 watched = socket;
230 cleanups. push (() => socket. destroy ());
231 socket. on ( 'error' , fail);
232 if (res.statusCode !== 200 ) {
233 fail (Object. assign ( new Error ( 'proxy refused the tunnel' ), { proxyStatus: res.statusCode }));
234 return ;
235 }
236 // This runs from an event, outside the try/catch around the setup: a synchronous throw from
237 // https.request or tls.connect (a bad header or path) must reject the call, not crash.
238 try {
239 // `host` makes Node verify the certificate against the target (an IP literal included),
240 // not against the proxy's name; `servername` is for SNI and is not allowed for an IP.
241 const tlsOptions = { socket, host: targetHost };
242 if ( ! net. isIP (targetHost)) tlsOptions.servername = targetHost;
243 // No `agent` key on purpose: with an agent (even `agent: false`, which builds a fresh
244 // one) Node ignores `createConnection` and dials the host directly, bypassing the tunnel.
245 const request = https. request ({
246 host: targetHost,
247 port: targetPort,
248 method,
249 path: `${ target . pathname }${ target . search }` ,
250 // The Host header as fetch writes it: the port only when it is not the default.
251 headers: { host: target.host, ... requestHeaders },
252 createConnection : () => tls. connect (tlsOptions),
253 }, readResponse);
254 cleanups. push (() => request. destroy ());
255 request. on ( 'error' , fail);
256 request. end (body);
257 } catch (error) {
258 fail (error);
259 }
260 });
261 connectRequest. end ();
262 } catch (error) {
263 fail (error);
264 }
265 });
266 }
267
268 const NULL_BODY_STATUS = new Set ([ 101 , 204 , 205 , 304 ]);
269 const REDIRECT_STATUS = new Set ([ 301 , 302 , 303 , 307 , 308 ]);
270 const MAX_REDIRECTS = 20 ;
271
272 // Node's own fetch gives up after about five minutes of silence from the server. The tunnel path
273 // has no such limit unless it is given one, and several callers pass no signal, so a proxy or
274 // server that goes quiet would hang a run forever. It is an idle limit (no bytes moved), as in
275 // Node, not a cap on the whole transfer.
276 const EGRESS_TIMEOUT_MS = 300000 ;
277
278 // Captured at load. A test (or an instrumentation library) that replaces `globalThis.fetch` expects
279 // to see the call; if it is replaced we hand the call to the replacement instead of opening our
280 // own connection, so a stub never turns into real traffic behind a proxy.
281 const nativeFetch = globalThis.fetch;
282
283 function refusal ( message , code ) {
284 return Object. assign ( new TypeError (message), { code });
285 }
286
287 // A drop-in for `fetch` that follows the environment's proxy. Same arguments; resolves to a
288 // standard Response. With no proxy it IS `globalThis.fetch`, read at call time. What it does not do:
289 // accept a body that is not a string, Buffer,
290 // typed array or ArrayBuffer, or take a Request as its first argument. Those are refused before
291 // anything is sent (so is a body on GET or HEAD, as in fetch). A call that moves no bytes for
292 // `timeoutMs` fails like fetch's own timeout. It sends no `Accept-Encoding`, because it does not decode.
293 function createEgressFetch ({ timeoutMs = EGRESS_TIMEOUT_MS } = {}) {
294 return async function egressFetch ( input , init = {}) {
295 if (globalThis.fetch !== nativeFetch) return globalThis. fetch (input, init);
296 const isUrlInput = typeof input === 'string' || input instanceof URL ;
297 const target = isUrlInput ? new URL (input) : null ;
298 const proxy = target ? proxyFor (target) : null ;
299 if (target && ! proxy) return globalThis. fetch (input, init);
300 if ( ! target) {
301 // A Request (or anything else) can only be handled by fetch itself; refuse when a proxy
302 // would apply rather than silently going direct.
303 let candidate = null ;
304 try { candidate = new URL (input && input.url); } catch { /* not a request */ }
305 if ( ! candidate || ! proxyFor (candidate)) return globalThis. fetch (input, init);
306 throw refusal ( 'egressFetch accepts a URL or a string as its first argument' , 'EGRESS_UNSUPPORTED_INPUT' );
307 }
308
309 let body = null ;
310 if (init.body !== undefined && init.body !== null ) {
311 if ( typeof init.body === 'string' ) body = Buffer. from (init.body);
312 else if (Buffer. isBuffer (init.body)) body = init.body;
313 else if (ArrayBuffer. isView (init.body)) body = Buffer. from (init.body.buffer, init.body.byteOffset, init.body.byteLength);
314 else if (init.body instanceof ArrayBuffer ) body = Buffer. from (init.body);
315 else throw refusal ( 'egressFetch accepts a string, Buffer, typed array or ArrayBuffer body' , 'EGRESS_UNSUPPORTED_BODY' );
316 }
317 let method = String (init.method || 'GET' ). toUpperCase ();
318 if (body && (method === 'GET' || method === 'HEAD' )) throw refusal ( 'Request with GET/HEAD method cannot have body.' , 'EGRESS_UNSUPPORTED_BODY' );
319 if (target.username || target.password) throw refusal ( 'Request cannot be constructed from a URL that includes credentials' , 'EGRESS_UNSUPPORTED_INPUT' );
320 let headers = {};
321 for ( const [ name , value ] of new Headers (init.headers || {})) headers[name] = value;
322 // The answer is not decoded here, so never ask for a compressed one. The other defaults are the
323 // ones Node's fetch sends, so a call looks the same on the wire with or without a proxy.
324 delete headers[ 'accept-encoding' ];
325 headers = { accept: '*/*' , 'accept-language' : '*' , 'user-agent' : 'node' , ... headers };
326
327 // Redirects follow fetch: `follow` (default) up to 20 hops, `manual` returns the 3xx, `error`
328 // rejects. A 303, and a 301 or 302 after POST, becomes a GET without the body; 307 and 308 keep
329 // the method and body; a hop to another origin drops credentials.
330 const redirectMode = init.redirect || 'follow' ;
331 let current = target;
332 for ( let hops = 0 ; ; hops += 1 ) {
333 const currentProxy = hops === 0 ? proxy : proxyFor (current);
334 if ( ! currentProxy) {
335 // A hop to a host that goes direct (NO_PROXY, a local address) is finished by the real fetch.
336 return globalThis. fetch (current, { ... init, method, headers, body: body || undefined });
337 }
338 let response;
339 try {
340 response = await requestViaProxy (current, currentProxy, { method, headers, body, idleTimeoutMs: timeoutMs }, init.signal);
341 } catch (error) {
342 if (error && error.code === 'ABORT_ERR' ) {
343 throw (init.signal && init.signal.reason) || new DOMException ( 'This operation was aborted' , 'AbortError' );
344 }
345 const cause = error && error.proxyStatus
346 ? Object. assign ( new Error ( 'proxy refused the tunnel' ), { code: `PROXY_CONNECT_${ error . proxyStatus }` , proxyStatus: error.proxyStatus })
347 : error;
348 throw new TypeError ( 'fetch failed' , { cause });
349 }
350 const location = response.headers.location;
351 if ( REDIRECT_STATUS . has (response.status) && location && redirectMode !== 'manual' ) {
352 if (redirectMode === 'error' ) {
353 throw new TypeError ( 'fetch failed' , { cause: Object. assign ( new Error ( 'unexpected redirect' ), { code: 'ERR_UNEXPECTED_REDIRECT' }) });
354 }
355 if (hops >= MAX_REDIRECTS ) {
356 throw new TypeError ( 'fetch failed' , { cause: Object. assign ( new Error ( 'too many redirects' ), { code: 'ERR_TOO_MANY_REDIRECTS' }) });
357 }
358 let next;
359 try { next = new URL (location, current); } catch { next = null ; }
360 if ( ! next || (next.protocol !== 'http:' && next.protocol !== 'https:' )) {
361 throw new TypeError ( 'fetch failed' , { cause: Object. assign ( new Error ( 'bad redirect target' ), { code: 'ERR_INVALID_REDIRECT' }) });
362 }
363 const toGet = (response.status === 303 && method !== 'GET' && method !== 'HEAD' )
364 || ((response.status === 301 || response.status === 302 ) && method === 'POST' );
365 if (toGet) {
366 method = 'GET' ;
367 body = null ;
368 headers = { ... headers };
369 delete headers[ 'content-type' ];
370 delete headers[ 'content-length' ];
371 }
372 if (next.origin !== current.origin) {
373 headers = { ... headers };
374 for ( const name of [ 'authorization' , 'cookie' , 'proxy-authorization' ]) delete headers[name];
375 }
376 current = next;
377 continue ;
378 }
379 const responseHeaders = new Headers ();
380 for ( const [ name , value ] of Object. entries (response.headers)) {
381 for ( const item of []. concat (value)) responseHeaders. append (name, item);
382 }
383 // No Accept-Encoding is sent, but a proxy or server can still compress; decode it so
384 // `.json()` and `.text()` read the real body, as they do with fetch.
385 let answer = NULL_BODY_STATUS . has (response.status) ? null : response.body;
386 const encoding = String (responseHeaders. get ( 'content-encoding' ) || '' ). toLowerCase (). trim ();
387 if (answer && encoding && encoding !== 'identity' ) {
388 try {
389 if (encoding === 'gzip' || encoding === 'x-gzip' ) answer = zlib. gunzipSync (answer);
390 else if (encoding === 'deflate' ) answer = zlib. inflateSync (answer);
391 else if (encoding === 'br' ) answer = zlib. brotliDecompressSync (answer);
392 else throw new Error ( 'unsupported content-encoding' );
393 } catch (error) {
394 throw new TypeError ( 'fetch failed' , { cause: Object. assign ( new Error ( 'cannot decode the answer' ), { code: 'ERR_CONTENT_DECODING' }) });
395 }
396 responseHeaders. delete ( 'content-encoding' );
397 responseHeaders. delete ( 'content-length' );
398 }
399 const result = new Response (answer, {
400 status: response.status,
401 statusText: response.statusText,
402 headers: responseHeaders,
403 });
404 // `url` and `redirected` describe the final hop, as they do for fetch.
405 Object. defineProperty (result, 'url' , { value: current.href });
406 Object. defineProperty (result, 'redirected' , { value: hops > 0 });
407 return result;
408 }
409 };
410 }
411
412 const egressFetch = createEgressFetch ();
413
414 // True when something replaced the global fetch after this module loaded (a test stub). Callers
415 // that choose between `fetch` and the tunnel themselves use it to stay on the replacement.
416 const isFetchReplaced = () => globalThis.fetch !== nativeFetch;
417
418 module . exports = { proxyFor, requestViaProxy, egressFetch, createEgressFetch, isFetchReplaced, isLocalHost, excludedByNoProxy, nodeProxiesFromEnv };