From 7c1b59ad3cdca92f95cba6f1301ccab3458946ae Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 29 Mar 2026 10:23:58 +0000 Subject: [PATCH 1/2] fix: avoid 401 failures for stream-backed request bodies - Properly implement isTraversableNavigable() to return false in Node.js - Return 401 response directly for stream-backed bodies instead of throwing network error - Aligns with Fetch spec discussion in whatwg/fetch#1132 - Add regression tests for PUT with ReadableStream and POST with JSON body --- lib/web/fetch/index.js | 8 +++- lib/web/fetch/util.js | 6 ++- test/fetch/401-statuscode-no-infinite-loop.js | 44 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/lib/web/fetch/index.js b/lib/web/fetch/index.js index 8b88904e9d5..a3b905925ce 100644 --- a/lib/web/fetch/index.js +++ b/lib/web/fetch/index.js @@ -1649,7 +1649,13 @@ async function httpNetworkOrCacheFetch ( if (request.body != null) { // 1. If request’s body’s source is null, then return a network error. if (request.body.source == null) { - return makeNetworkError('expected non-null body source') + // Note: In Node.js, this code path should not be reached because + // isTraversableNavigable() returns false for non-navigable contexts. + // However, we handle it gracefully by returning the response instead of + // a network error, as we won't actually retry the request. + // This aligns with the Fetch spec discussion in whatwg/fetch#1132, + // which allows implementations flexibility when credentials can't be obtained. + return response } // 2. Set request’s body to the body of the result of safely extracting diff --git a/lib/web/fetch/util.js b/lib/web/fetch/util.js index fe63cb3a9b0..b77be74f5d4 100644 --- a/lib/web/fetch/util.js +++ b/lib/web/fetch/util.js @@ -1447,8 +1447,10 @@ function includesCredentials (url) { * @param {object|string} navigable */ function isTraversableNavigable (navigable) { - // TODO - return true + // Returns true only if we have an actual traversable navigable object + // that can prompt the user for credentials. In Node.js, this will always + // be false since there's no Window object or navigable. + return navigable != null && navigable !== 'client' && navigable !== 'no-traversable' } class EnvironmentSettingsObjectBase { diff --git a/test/fetch/401-statuscode-no-infinite-loop.js b/test/fetch/401-statuscode-no-infinite-loop.js index 96611718036..2a959e1e77b 100644 --- a/test/fetch/401-statuscode-no-infinite-loop.js +++ b/test/fetch/401-statuscode-no-infinite-loop.js @@ -20,3 +20,47 @@ test('Receiving a 401 status code should not cause infinite retry loop', async ( const response = await fetch(`http://localhost:${server.address().port}`) assert.strictEqual(response.status, 401) }) + +test('Receiving a 401 status code should not fail for stream-backed request bodies', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.statusCode = 401 + res.end('Unauthorized') + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const response = await fetch(`http://localhost:${server.address().port}`, { + method: 'PUT', + duplex: 'half', + body: new ReadableStream({ + start (controller) { + controller.enqueue(Buffer.from('hello world')) + controller.close() + } + }) + }) + + assert.strictEqual(response.status, 401) +}) + +test('Receiving a 401 status code should work for POST with JSON body', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.statusCode = 401 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: 'unauthorized' })) + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const response = await fetch(`http://localhost:${server.address().port}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: 'test' }) + }) + + assert.strictEqual(response.status, 401) + const body = await response.json() + assert.deepStrictEqual(body, { error: 'unauthorized' }) +}) From 47cc74ab9ae5101278bd0aa29a3dad6bfd2d8d0d Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 31 Mar 2026 13:32:58 +0000 Subject: [PATCH 2/2] fix: preserve 401 auth retries for URL credentials --- lib/web/fetch/index.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/web/fetch/index.js b/lib/web/fetch/index.js index a3b905925ce..31466eb3216 100644 --- a/lib/web/fetch/index.js +++ b/lib/web/fetch/index.js @@ -1644,7 +1644,14 @@ async function httpNetworkOrCacheFetch ( // 14. If response’s status is 401, httpRequest’s response tainting is not "cors", // includeCredentials is true, and request’s traversable for user prompts is // a traversable navigable: - if (response.status === 401 && httpRequest.responseTainting !== 'cors' && includeCredentials && isTraversableNavigable(request.traversableForUserPrompts)) { + // + // In Node.js there is no traversable navigable to prompt the user, but we + // still need to handle URL-embedded credentials so authentication retries + // for WebSocket handshakes continue to work. + if (response.status === 401 && httpRequest.responseTainting !== 'cors' && includeCredentials && ( + request.useURLCredentials !== undefined || + isTraversableNavigable(request.traversableForUserPrompts) + )) { // 2. If request’s body is non-null, then: if (request.body != null) { // 1. If request’s body’s source is null, then return a network error.