From 58d8a125bf8339b9079caf9359c82ac136da95b0 Mon Sep 17 00:00:00 2001 From: Devendra Reddy Pennabadi Date: Wed, 27 May 2026 00:19:23 +0530 Subject: [PATCH] fix(http): preserve basic auth on same-origin redirects (#6929) (#10929) * fix(http): preserve basic auth on same-origin redirects (#6929) * docs(http): address redirect auth review nits --------- Co-authored-by: Jason Saayman --- PRE_RELEASE_CHANGELOG.md | 1 + lib/adapters/http.js | 24 ++++++++- tests/unit/adapters/http.test.js | 92 ++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/PRE_RELEASE_CHANGELOG.md b/PRE_RELEASE_CHANGELOG.md index 837d32b8..b41d2094 100644 --- a/PRE_RELEASE_CHANGELOG.md +++ b/PRE_RELEASE_CHANGELOG.md @@ -9,6 +9,7 @@ ## Bug Fixes - **AxiosHeaders:** Silently skip empty response header names emitted by some React Native Android responses instead of throwing. (**#6959**, **#10875**) +- **HTTP Adapter - Auth on Redirect:** HTTP Basic credentials supplied via `config.auth` are now restored on same-origin redirects, fixing a regression caused by `follow-redirects` >= 1.15.8 that broke `POST` requests answered with a 303 Location. Cross-origin redirects continue to drop credentials, preserving the existing T-R2 mitigation in `THREATMODEL.md`. (**#6929**) - **HTTP Adapter - Socket Path:** Ignore inherited `socketPath` and `allowedSocketPaths` config values when building Node.js requests, preventing prototype-pollution SSRF via Unix sockets. (**#10901**) - **React Native FormData:** Clear the default `Content-Type` header for React Native `FormData` requests so Android can build multipart bodies with the correct boundary. (**#10898**) - **Request Data:** Preserve enumerable symbol keys when merging plain request data before `transformRequest`. (**#6392**) diff --git a/lib/adapters/http.js b/lib/adapters/http.js index f8f3d7d4..6aaee563 100755 --- a/lib/adapters/http.js +++ b/lib/adapters/http.js @@ -146,8 +146,8 @@ const flushOnFinish = (stream, [throttled, flush]) => { const http2Sessions = new Http2Sessions(); /** - * If the proxy or config beforeRedirects functions are defined, call them with the options - * object. + * If the proxy, auth, or config beforeRedirects functions are defined, call them + * with the options object. * * @param {Object} options - The options object that was passed to the request. * @@ -157,6 +157,9 @@ function dispatchBeforeRedirect(options, responseDetails, requestDetails) { if (options.beforeRedirects.proxy) { options.beforeRedirects.proxy(options); } + if (options.beforeRedirects.auth) { + options.beforeRedirects.auth(options); + } if (options.beforeRedirects.config) { options.beforeRedirects.config(options, responseDetails, requestDetails); } @@ -860,6 +863,23 @@ export default isHttpAdapterSupported && if (configBeforeRedirect) { options.beforeRedirects.config = configBeforeRedirect; } + if (auth) { + // Restore HTTP Basic credentials on same-origin redirects only. + // follow-redirects >= 1.15.8 strips Authorization on every redirect (see #6929); + // cross-origin stripping is the documented mitigation for T-R2 in THREATMODEL.md + // and is preserved by deliberately not restoring on origin change. + const requestOrigin = parsed.origin; + const authToRestore = auth; + options.beforeRedirects.auth = function beforeRedirectAuth(redirectOptions) { + try { + if (new URL(redirectOptions.href).origin === requestOrigin) { + redirectOptions.auth = authToRestore; + } + } catch (e) { + // ignore malformed URL: leaving auth stripped is fail-safe + } + }; + } transport = isHttpsRequest ? httpsFollow : httpFollow; } } diff --git a/tests/unit/adapters/http.test.js b/tests/unit/adapters/http.test.js index b5146a73..0a9741a6 100644 --- a/tests/unit/adapters/http.test.js +++ b/tests/unit/adapters/http.test.js @@ -1165,6 +1165,98 @@ describe('supports http with nodejs', () => { } }); + it('should preserve basic auth across same-origin 303 POST -> GET redirect', async () => { + const server = await startHTTPServer( + (req, res) => { + if (req.url === '/login') { + res.setHeader('Location', '/profile'); + res.statusCode = 303; + res.end(); + return; + } + res.end(req.headers.authorization || ''); + }, + { port: SERVER_PORT } + ); + + try { + const auth = { username: 'foo', password: 'bar' }; + const response = await axios.post( + `http://localhost:${server.address().port}/login`, + { hello: 'world' }, + { auth, maxRedirects: 1 } + ); + const base64 = Buffer.from('foo:bar', 'utf8').toString('base64'); + assert.strictEqual(response.data, `Basic ${base64}`); + assert.strictEqual(response.request.path, '/profile'); + } finally { + await stopHTTPServer(server); + } + }); + + it('should strip basic auth on cross-origin redirect', async () => { + const targetServer = await startHTTPServer( + (req, res) => { + res.end(req.headers.authorization || 'no-auth'); + }, + { port: ALTERNATE_SERVER_PORT } + ); + const redirectServer = await startHTTPServer( + (req, res) => { + res.setHeader('Location', `http://127.0.0.1:${targetServer.address().port}/`); + res.statusCode = 302; + res.end(); + }, + { port: SERVER_PORT } + ); + + try { + const auth = { username: 'foo', password: 'bar' }; + const response = await axios.get(`http://localhost:${redirectServer.address().port}/start`, { + auth, + maxRedirects: 1, + }); + assert.strictEqual(response.data, 'no-auth'); + } finally { + await stopHTTPServer(redirectServer); + await stopHTTPServer(targetServer); + } + }); + + it('should preserve basic auth across multi-hop same-origin redirects', async () => { + const server = await startHTTPServer( + (req, res) => { + if (req.url === '/a') { + res.setHeader('Location', '/b'); + res.statusCode = 302; + res.end(); + return; + } + if (req.url === '/b') { + res.setHeader('Location', '/c'); + res.statusCode = 302; + res.end(); + return; + } + res.end(req.headers.authorization || ''); + }, + { port: SERVER_PORT } + ); + + try { + const auth = { username: 'foo', password: 'bar' }; + const response = await axios.get(`http://localhost:${server.address().port}/a`, { + auth, + maxRedirects: 5, + }); + const base64 = Buffer.from('foo:bar', 'utf8').toString('base64'); + assert.strictEqual(response.data, `Basic ${base64}`); + assert.strictEqual(response.request.path, '/c'); + } finally { + await stopHTTPServer(server); + } + }); + it('should provides a default User-Agent header', async () => { const server = await startHTTPServer( (req, res) => {