From d5c76ecda6256d645c9895da0360ee4e4a8c79d2 Mon Sep 17 00:00:00 2001 From: Daniel Martins Date: Fri, 6 Mar 2026 16:57:46 +0200 Subject: [PATCH 1/7] fix: Prevent Node subprocess from hanging during snyk test --- src/lib/request/request.ts | 74 +++++++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/src/lib/request/request.ts b/src/lib/request/request.ts index a71cdb2182..9d59db8ffe 100644 --- a/src/lib/request/request.ts +++ b/src/lib/request/request.ts @@ -140,25 +140,73 @@ function setupRequest(payload: Payload) { return { method, url, data, options }; } +const REDIRECT_CODES = [301, 302, 303, 307, 308]; +const MAX_REDIRECTS = 5; + export async function makeRequest( payload: Payload, ): Promise<{ res: needle.NeedleResponse; body: any }> { const { method, url, data, options } = setupRequest(payload); + // Disable needle's internal redirect following. When needle follows a + // redirect through a CONNECT proxy, the intermediate socket is never + // exposed to the callback and lingers, keeping the process alive. + // We follow redirects manually below so each hop gets its own agent + // and socket cleanup. + options.follow_max = 0; return new Promise((resolve, reject) => { - needle.request(method, url, data, options, (err, res, respBody) => { - if (res?.headers?.[headerSnykAuthFailed] === 'true') { - return reject(new MissingApiTokenError()); - } - // respBody potentially very large, do not output it in debug - debug('response (%s)', (res || {}).statusCode); - if (err) { - debug('response err: %s', err); - return reject(err); - } - - resolve({ res, body: respBody }); - }); + let redirectsLeft = MAX_REDIRECTS; + + const sendRequest = ( + reqMethod: string, + reqUrl: string, + reqData: any, + reqOptions: needle.NeedleOptions, + ) => { + needle.request( + reqMethod as needle.NeedleHttpVerbs, + reqUrl, + reqData, + reqOptions, + (err, res, respBody) => { + // Destroy the socket so the CONNECT tunnel doesn't keep the process alive. + res?.socket?.destroy(); + + if (res?.headers?.[headerSnykAuthFailed] === 'true') { + return reject(new MissingApiTokenError()); + } + debug('response (%s)', (res || {}).statusCode); + if (err) { + debug('response err: %s', err); + return reject(err); + } + + if ( + res.statusCode && + REDIRECT_CODES.includes(res.statusCode) && + res.headers?.location && + redirectsLeft > 0 + ) { + redirectsLeft--; + const redirectUrl = new URL(res.headers.location, reqUrl).toString(); + debug('following redirect to %s', redirectUrl); + const parsedRedirect = parse(redirectUrl); + const newAgent = + parsedRedirect.protocol === 'http:' + ? new http.Agent({ keepAlive: false }) + : new https.Agent({ keepAlive: false }); + return sendRequest('get', redirectUrl, null, { + ...reqOptions, + agent: newAgent, + }); + } + + resolve({ res, body: respBody }); + }, + ); + }; + + sendRequest(method, url, data, options); }); } From 5d70328787cbb8250424d146c6a725e5a5f10187 Mon Sep 17 00:00:00 2001 From: Daniel Martins Date: Mon, 9 Mar 2026 08:58:11 +0200 Subject: [PATCH 2/7] fix: Run prettier to fix lint issues --- src/lib/request/request.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/request/request.ts b/src/lib/request/request.ts index 9d59db8ffe..ecc6ec9781 100644 --- a/src/lib/request/request.ts +++ b/src/lib/request/request.ts @@ -188,7 +188,10 @@ export async function makeRequest( redirectsLeft > 0 ) { redirectsLeft--; - const redirectUrl = new URL(res.headers.location, reqUrl).toString(); + const redirectUrl = new URL( + res.headers.location, + reqUrl, + ).toString(); debug('following redirect to %s', redirectUrl); const parsedRedirect = parse(redirectUrl); const newAgent = From 38af1ce90fb630a3c877a1df70dbb96b9d73e6fb Mon Sep 17 00:00:00 2001 From: Daniel Martins Date: Mon, 9 Mar 2026 15:54:38 +0200 Subject: [PATCH 3/7] fix: Update request tests to expect follow_max=0 --- test/tap/request.test.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/test/tap/request.test.ts b/test/tap/request.test.ts index 41e8731819..b34080add2 100644 --- a/test/tap/request.test.ts +++ b/test/tap/request.test.ts @@ -47,7 +47,7 @@ test('request calls needle as expected and returns status code and body', (t) => 'content-encoding': undefined, // should not set when no data 'content-length': undefined, // should not be set when no data }), - follow_max: 5, // default + follow_max: 0, // needle's redirect following is disabled; we handle redirects manually timeout: 300000, // default json: undefined, // default agent: sinon.match.instanceOf(http.Agent), @@ -85,7 +85,7 @@ test('request to localhost calls needle as expected', (t) => { 'content-encoding': undefined, // should not set when no data 'content-length': undefined, // should not be set when no data }), - follow_max: 5, // default + follow_max: 0, // needle's redirect following is disabled; we handle redirects manually timeout: 300000, // default json: undefined, // default agent: sinon.match.instanceOf(http.Agent), @@ -124,7 +124,7 @@ test('request with timeout calls needle as expected', (t) => { 'content-encoding': undefined, // should not set when no data 'content-length': undefined, // should not be set when no data }), - follow_max: 5, // default + follow_max: 0, // needle's redirect following is disabled; we handle redirects manually timeout: 100000, // provided json: undefined, // default agent: sinon.match.instanceOf(http.Agent), @@ -166,7 +166,7 @@ test('request with query string calls needle as expected', (t) => { 'content-encoding': undefined, // should not set when no data 'content-length': undefined, // should not be set when no data }), - follow_max: 5, // default + follow_max: 0, // needle's redirect following is disabled; we handle redirects manually timeout: 300000, // default json: undefined, // default agent: sinon.match.instanceOf(http.Agent), @@ -205,7 +205,7 @@ test('request with json calls needle as expected', (t) => { 'content-encoding': undefined, // should not set when no data 'content-length': undefined, // should not be set when no data }), - follow_max: 5, // default + follow_max: 0, // needle's redirect following is disabled; we handle redirects manually timeout: 300000, // default json: false, // provided agent: sinon.match.instanceOf(http.Agent), @@ -247,7 +247,7 @@ test('request with custom header calls needle as expected', (t) => { 'content-encoding': undefined, // should not set when no data 'content-length': undefined, // should not be set when no data }), - follow_max: 5, // default + follow_max: 0, // needle's redirect following is disabled; we handle redirects manually timeout: 300000, // default json: undefined, // default agent: sinon.match.instanceOf(http.Agent), @@ -286,7 +286,7 @@ test('request with https proxy calls needle as expected', (t) => { 'content-encoding': undefined, // should not set when no data 'content-length': undefined, // should not be set when no data }), - follow_max: 5, // default + follow_max: 0, // needle's redirect following is disabled; we handle redirects manually timeout: 300000, // default json: undefined, // default agent: sinon.match.truthy, @@ -335,7 +335,7 @@ test('request with http proxy calls needle as expected', (t) => { 'content-encoding': undefined, // should not set when no data 'content-length': undefined, // should not be set when no data }), - follow_max: 5, // default + follow_max: 0, // needle's redirect following is disabled; we handle redirects manually timeout: 300000, // default json: undefined, // default agent: sinon.match.truthy, @@ -375,7 +375,7 @@ test('request with no proxy calls needle as expected', (t) => { 'content-encoding': undefined, // should not set when no data 'content-length': undefined, // should not be set when no data }), - follow_max: 5, // default + follow_max: 0, // needle's redirect following is disabled; we handle redirects manually timeout: 300000, // default json: undefined, // default agent: sinon.match.instanceOf(http.Agent), @@ -414,7 +414,7 @@ test('request with insecure calls needle as expected', (t) => { 'content-encoding': undefined, // should not set when no data 'content-length': undefined, // should not be set when no data }), - follow_max: 5, // default + follow_max: 0, // needle's redirect following is disabled; we handle redirects manually timeout: 300000, // default json: undefined, // default agent: sinon.match.instanceOf(http.Agent), @@ -471,7 +471,7 @@ test('request calls needle as expected and will not update HTTP to HTTPS if envv 'content-encoding': undefined, // should not set when no data 'content-length': undefined, // should not be set when no data }), - follow_max: 5, // default + follow_max: 0, // needle's redirect following is disabled; we handle redirects manually timeout: 300000, // default json: undefined, // default agent: sinon.match.instanceOf(http.Agent), From 2014c5c8ff53bc374c91588639518c489cd5fbd0 Mon Sep 17 00:00:00 2001 From: Daniel Martins Date: Mon, 9 Mar 2026 16:15:35 +0200 Subject: [PATCH 4/7] fix: Preserve HTTP method and body on 307/308 redirects --- src/lib/request/request.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/lib/request/request.ts b/src/lib/request/request.ts index ecc6ec9781..94a0f182e2 100644 --- a/src/lib/request/request.ts +++ b/src/lib/request/request.ts @@ -141,6 +141,8 @@ function setupRequest(payload: Payload) { } const REDIRECT_CODES = [301, 302, 303, 307, 308]; +// 307/308 require preserving the original method and body per RFC 9110. +const METHOD_PRESERVING_REDIRECTS = [307, 308]; const MAX_REDIRECTS = 5; export async function makeRequest( @@ -198,10 +200,15 @@ export async function makeRequest( parsedRedirect.protocol === 'http:' ? new http.Agent({ keepAlive: false }) : new https.Agent({ keepAlive: false }); - return sendRequest('get', redirectUrl, null, { - ...reqOptions, - agent: newAgent, - }); + const preserveMethod = + res.statusCode !== undefined && + METHOD_PRESERVING_REDIRECTS.includes(res.statusCode); + return sendRequest( + preserveMethod ? reqMethod : 'get', + redirectUrl, + preserveMethod ? reqData : null, + { ...reqOptions, agent: newAgent }, + ); } resolve({ res, body: respBody }); From aef5d2b95dfbd9b424f7cd39cdd622fedb1f503e Mon Sep 17 00:00:00 2001 From: Daniel Martins Date: Mon, 9 Mar 2026 16:36:48 +0200 Subject: [PATCH 5/7] fix: Strip body headers on GET redirects to avoid server hangs --- src/lib/request/request.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/request/request.ts b/src/lib/request/request.ts index 94a0f182e2..00eb4c0de5 100644 --- a/src/lib/request/request.ts +++ b/src/lib/request/request.ts @@ -203,11 +203,16 @@ export async function makeRequest( const preserveMethod = res.statusCode !== undefined && METHOD_PRESERVING_REDIRECTS.includes(res.statusCode); + const redirectOptions = { ...reqOptions, agent: newAgent }; + if (!preserveMethod) { + delete redirectOptions.headers!['content-length']; + delete redirectOptions.headers!['content-encoding']; + } return sendRequest( preserveMethod ? reqMethod : 'get', redirectUrl, preserveMethod ? reqData : null, - { ...reqOptions, agent: newAgent }, + redirectOptions, ); } From 1d170b35fdb14503325c0272d5d1828bbd299de0 Mon Sep 17 00:00:00 2001 From: Daniel Martins Date: Mon, 9 Mar 2026 16:52:49 +0200 Subject: [PATCH 6/7] fix: Strip auth headers, guard malformed Location --- src/lib/request/request.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/lib/request/request.ts b/src/lib/request/request.ts index 00eb4c0de5..c56bfba697 100644 --- a/src/lib/request/request.ts +++ b/src/lib/request/request.ts @@ -190,12 +190,22 @@ export async function makeRequest( redirectsLeft > 0 ) { redirectsLeft--; - const redirectUrl = new URL( - res.headers.location, - reqUrl, - ).toString(); + + let redirectUrl: string; + try { + redirectUrl = new URL( + res.headers.location, + reqUrl, + ).toString(); + } catch (e) { + return reject( + new Error(`Invalid redirect Location: ${res.headers.location}`), + ); + } + debug('following redirect to %s', redirectUrl); const parsedRedirect = parse(redirectUrl); + const parsedOriginal = parse(reqUrl); const newAgent = parsedRedirect.protocol === 'http:' ? new http.Agent({ keepAlive: false }) @@ -208,6 +218,11 @@ export async function makeRequest( delete redirectOptions.headers!['content-length']; delete redirectOptions.headers!['content-encoding']; } + // Strip auth headers on cross-origin redirects to avoid leaking credentials. + if (parsedRedirect.host !== parsedOriginal.host) { + delete redirectOptions.headers!['authorization']; + delete redirectOptions.headers!['session-token']; + } return sendRequest( preserveMethod ? reqMethod : 'get', redirectUrl, From 39fc99a68e01f17cc9c05d6fc85a7a9a575e0db3 Mon Sep 17 00:00:00 2001 From: Daniel Martins Date: Mon, 9 Mar 2026 17:00:24 +0200 Subject: [PATCH 7/7] fix: Deep copy headers, strip all sensitive headers --- src/lib/request/request.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/lib/request/request.ts b/src/lib/request/request.ts index c56bfba697..306e7c473f 100644 --- a/src/lib/request/request.ts +++ b/src/lib/request/request.ts @@ -213,16 +213,28 @@ export async function makeRequest( const preserveMethod = res.statusCode !== undefined && METHOD_PRESERVING_REDIRECTS.includes(res.statusCode); - const redirectOptions = { ...reqOptions, agent: newAgent }; + const redirectHeaders = { ...reqOptions.headers }; if (!preserveMethod) { - delete redirectOptions.headers!['content-length']; - delete redirectOptions.headers!['content-encoding']; + delete redirectHeaders['content-length']; + delete redirectHeaders['content-encoding']; } - // Strip auth headers on cross-origin redirects to avoid leaking credentials. if (parsedRedirect.host !== parsedOriginal.host) { - delete redirectOptions.headers!['authorization']; - delete redirectOptions.headers!['session-token']; + const sensitiveHeaders = [ + 'authorization', + 'session-token', + 'cookie', + 'x-api-key', + 'x-snyk-token', + ]; + for (const h of sensitiveHeaders) { + delete redirectHeaders[h]; + } } + const redirectOptions = { + ...reqOptions, + agent: newAgent, + headers: redirectHeaders, + }; return sendRequest( preserveMethod ? reqMethod : 'get', redirectUrl,