diff --git a/PRE_RELEASE_CHANGELOG.md b/PRE_RELEASE_CHANGELOG.md index 755af7ae..1fcc16b4 100644 --- a/PRE_RELEASE_CHANGELOG.md +++ b/PRE_RELEASE_CHANGELOG.md @@ -10,6 +10,7 @@ - **AxiosHeaders:** Silently skip empty response header names emitted by some React Native Android responses instead of throwing. (**#6959**, **#10875**) - **Config Security:** Ignore inherited `params` and `paramsSerializer` values when resolving request config, preventing prototype-pollution gadgets from changing serialized URLs. (**#10922**) +- **Fetch Adapter - Auth:** Support HTTP Basic credentials embedded in request URLs, including UTF-8 credentials, while stripping credentials before constructing the fetch `Request` and preserving `config.auth` precedence. (**#10896**) - **Types:** Add the missing readonly `name: 'CanceledError'` declaration to CommonJS `CanceledError` typings to match the ESM declarations. (**#10922**) - **Types:** Correct the CommonJS `isCancel` type guard to narrow cancellation errors to `CanceledError`, matching the ESM declaration. (**#10952**) - **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**) @@ -23,3 +24,4 @@ - Update `README.md` request config docs for `transitional.advertiseZstdAcceptEncoding` and zstd decompression support. - Update `docs/pages/advanced/request-config.md` for `transitional.advertiseZstdAcceptEncoding` and zstd decompression support. - Update decompression-bomb security guidance in `README.md` and `docs/pages/misc/security.md` to mention zstd. +- Update `README.md` and `docs/pages/advanced/request-config.md` to document URL-embedded Basic auth fallback and `config.auth` precedence across adapters. diff --git a/lib/adapters/fetch.js b/lib/adapters/fetch.js index b9015a9b..e25bbfcf 100644 --- a/lib/adapters/fetch.js +++ b/lib/adapters/fetch.js @@ -19,6 +19,35 @@ const DEFAULT_CHUNK_SIZE = 64 * 1024; const { isFunction } = utils; +/** + * Encode a UTF-8 string to a Latin-1 byte string for use with btoa(). + * This is a modern replacement for the deprecated unescape(encodeURIComponent(str)) pattern. + * + * @param {string} str The string to encode + * + * @returns {string} UTF-8 bytes as a Latin-1 string + */ +const encodeUTF8 = (str) => + encodeURIComponent(str).replace(/%([0-9A-F]{2})/gi, (_, hex) => + String.fromCharCode(parseInt(hex, 16)) + ); + +// Node's WHATWG URL parser returns `username` and `password` percent-encoded. +// Decode before composing the `auth` option so credentials such as +// `my%40email.com:pass` are sent as `my@email.com:pass`. Falls back to the +// original value for malformed input so a bad encoding never throws. +const decodeURIComponentSafe = (value) => { + if (!utils.isString(value)) { + return value; + } + + try { + return decodeURIComponent(value); + } catch (error) { + return value; + } +}; + const test = (fn, ...args) => { try { return !!fn(...args); @@ -27,6 +56,15 @@ const test = (fn, ...args) => { } }; +const maybeWithAuthCredentials = (url) => { + const protocolIndex = url.indexOf('://'); + let urlToCheck = url; + if (protocolIndex !== -1) { + urlToCheck = urlToCheck.slice(protocolIndex + 3); + } + return urlToCheck.includes('@') || urlToCheck.includes(':'); +}; + const factory = (env) => { const globalObject = utils.global !== undefined && utils.global !== null @@ -174,6 +212,7 @@ const factory = (env) => { const hasMaxContentLength = utils.isNumber(maxContentLength) && maxContentLength > -1; const hasMaxBodyLength = utils.isNumber(maxBodyLength) && maxBodyLength > -1; + const own = (key) => (utils.hasOwnProp(config, key) ? config[key] : undefined); let _fetch = envFetch || fetch; @@ -196,6 +235,46 @@ const factory = (env) => { let requestContentLength; try { + // HTTP basic authentication + let auth = undefined; + const configAuth = own('auth'); + + if (configAuth) { + const username = configAuth.username || ''; + const password = configAuth.password || ''; + auth = { + username, + password + }; + } + + if (maybeWithAuthCredentials(url)) { + const parsedURL = new URL(url, platform.origin); + + if (!auth && (parsedURL.username || parsedURL.password)) { + const urlUsername = decodeURIComponentSafe(parsedURL.username); + const urlPassword = decodeURIComponentSafe(parsedURL.password); + auth = { + username: urlUsername, + password: urlPassword + }; + } + + if (parsedURL.username || parsedURL.password) { + parsedURL.username = ''; + parsedURL.password = ''; + url = parsedURL.href; + } + } + + if (auth) { + headers.delete('authorization'); + headers.set( + 'Authorization', + 'Basic ' + btoa(encodeUTF8((auth.username || '') + ':' + (auth.password || ''))) + ); + } + // Enforce maxContentLength for data: URLs up-front so we never materialize // an oversized payload. The HTTP adapter applies the same check (see http.js // "if (protocol === 'data:')" branch). diff --git a/lib/adapters/http.js b/lib/adapters/http.js index f37ba974..8f4a19ea 100755 --- a/lib/adapters/http.js +++ b/lib/adapters/http.js @@ -752,7 +752,7 @@ export default isHttpAdapterSupported && auth = username + ':' + password; } - if (!auth && parsed.username) { + if (!auth && (parsed.username || parsed.password)) { const urlUsername = decodeURIComponentSafe(parsed.username); const urlPassword = decodeURIComponentSafe(parsed.password); auth = urlUsername + ':' + urlPassword; diff --git a/tests/unit/adapters/fetch.test.js b/tests/unit/adapters/fetch.test.js index 0afdd6a3..142d24d2 100644 --- a/tests/unit/adapters/fetch.test.js +++ b/tests/unit/adapters/fetch.test.js @@ -465,7 +465,7 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => try { const user = 'foo'; const headers = { Authorization: 'Bearer 1234' }; - const res = await axios.get(`http://${user}@localhost:${server.address().port}/`, { + const res = await fetchAxios.get(`http://${user}@localhost:${server.address().port}/`, { headers, }); @@ -476,6 +476,121 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => } }); + it('should decode basic auth credentials from the request URL', async () => { + const server = await startHTTPServer( + (req, res) => { + res.end(req.headers.authorization); + }, + { port: SERVER_PORT } + ); + + try { + const response = await fetchAxios.get( + `http://my%40email.com:pa%24ss@localhost:${server.address().port}/` + ); + const base64 = Buffer.from('my@email.com:pa$ss', 'utf8').toString('base64'); + assert.strictEqual(response.data, `Basic ${base64}`); + } finally { + await stopHTTPServer(server); + } + }); + + it('should UTF-8 encode basic auth credentials from the request URL', async () => { + const server = await startHTTPServer( + (req, res) => { + res.end(req.headers.authorization); + }, + { port: SERVER_PORT } + ); + + try { + const response = await fetchAxios.get( + `http://%E7%94%A8%E6%88%B7:pa%C3%9F@localhost:${server.address().port}/` + ); + const base64 = Buffer.from('\u7528\u6237:pa\u00df', 'utf8').toString('base64'); + assert.strictEqual(response.data, `Basic ${base64}`); + } finally { + await stopHTTPServer(server); + } + }); + + it('keeps malformed URL credentials percent-encoding and does not throw', async () => { + const server = await startHTTPServer( + (req, res) => { + res.end(req.headers.authorization); + }, + { port: SERVER_PORT } + ); + + try { + const response = await fetchAxios.get(`http://user%:foo%zz@localhost:${server.address().port}/`); + const base64 = Buffer.from('user%:foo%zz', 'utf8').toString('base64'); + assert.strictEqual(response.data, `Basic ${base64}`); + } finally { + await stopHTTPServer(server); + } + }); + + it('should support password-only basic auth credentials from the request URL', async () => { + const server = await startHTTPServer( + (req, res) => { + res.end(req.headers.authorization); + }, + { port: SERVER_PORT } + ); + + try { + const response = await fetchAxios.get(`http://:secret@localhost:${server.address().port}/`); + const base64 = Buffer.from(':secret', 'utf8').toString('base64'); + assert.strictEqual(response.data, `Basic ${base64}`); + } finally { + await stopHTTPServer(server); + } + }); + + it('should prefer config auth over basic auth credentials from the request URL', async () => { + const server = await startHTTPServer( + (req, res) => { + res.end(req.headers.authorization); + }, + { port: SERVER_PORT } + ); + + try { + const auth = { username: 'config-user', password: 'config-pass' }; + const response = await fetchAxios.get( + `http://url-user:url-pass@localhost:${server.address().port}/`, + { auth } + ); + const base64 = Buffer.from('config-user:config-pass', 'utf8').toString('base64'); + assert.strictEqual(response.data, `Basic ${base64}`); + } finally { + await stopHTTPServer(server); + } + }); + + it('should support basic auth with a header', async () => { + const server = await startHTTPServer( + (req, res) => { + res.end(req.headers.authorization); + }, + { port: SERVER_PORT } + ); + + try { + const auth = { username: 'foo', password: 'bar' }; + const headers = { AuThOrIzAtIoN: 'Bearer 1234' }; // wonky casing to ensure caseless comparison + const response = await fetchAxios.get(`http://localhost:${server.address().port}/`, { + auth, + headers, + }); + const base64 = Buffer.from('foo:bar', 'utf8').toString('base64'); + assert.strictEqual(response.data, `Basic ${base64}`); + } finally { + await stopHTTPServer(server); + } + }); + it('should support stream.Readable as a payload', async () => { const server = await startHTTPServer(async (req, res) => res.end('OK'), { port: SERVER_PORT }); diff --git a/tests/unit/adapters/http.test.js b/tests/unit/adapters/http.test.js index 7799d521..8e103c09 100644 --- a/tests/unit/adapters/http.test.js +++ b/tests/unit/adapters/http.test.js @@ -1143,6 +1143,23 @@ describe('supports http with nodejs', () => { } }); + it('should support password-only basic auth credentials from the request URL', async () => { + const server = await startHTTPServer( + (req, res) => { + res.end(req.headers.authorization); + }, + { port: SERVER_PORT } + ); + + try { + const response = await axios.get(`http://:secret@localhost:${server.address().port}/`); + const base64 = Buffer.from(':secret', 'utf8').toString('base64'); + assert.strictEqual(response.data, `Basic ${base64}`); + } finally { + await stopHTTPServer(server); + } + }); + it('should support basic auth with a header', async () => { const server = await startHTTPServer( (req, res) => {