Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions lib/web/fetch/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1644,12 +1644,25 @@ 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.
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
Expand Down
6 changes: 4 additions & 2 deletions lib/web/fetch/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions test/fetch/401-statuscode-no-infinite-loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
})
Loading