2
0
mirror of https://github.com/tenrok/axios.git synced 2026-06-17 19:21:29 +03:00

fix(fetch): support basic auth from URL (#10896)

Co-authored-by: Jay <jasonsaayman@gmail.com>
This commit is contained in:
Ivan Nikolić
2026-06-01 18:12:55 +02:00
committed by GitHub
parent 32e2515f1e
commit 38ba1b3d2b
5 changed files with 215 additions and 2 deletions
+2
View File
@@ -10,6 +10,7 @@
- **AxiosHeaders:** Silently skip empty response header names emitted by some React Native Android responses instead of throwing. (**#6959**, **#10875**) - **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**) - **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:** 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<T>`, matching the ESM declaration. (**#10952**) - **Types:** Correct the CommonJS `isCancel` type guard to narrow cancellation errors to `CanceledError<T>`, 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**) - **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 `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 `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 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.
+79
View File
@@ -19,6 +19,35 @@ const DEFAULT_CHUNK_SIZE = 64 * 1024;
const { isFunction } = utils; 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) => { const test = (fn, ...args) => {
try { try {
return !!fn(...args); 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 factory = (env) => {
const globalObject = const globalObject =
utils.global !== undefined && utils.global !== null utils.global !== undefined && utils.global !== null
@@ -174,6 +212,7 @@ const factory = (env) => {
const hasMaxContentLength = utils.isNumber(maxContentLength) && maxContentLength > -1; const hasMaxContentLength = utils.isNumber(maxContentLength) && maxContentLength > -1;
const hasMaxBodyLength = utils.isNumber(maxBodyLength) && maxBodyLength > -1; const hasMaxBodyLength = utils.isNumber(maxBodyLength) && maxBodyLength > -1;
const own = (key) => (utils.hasOwnProp(config, key) ? config[key] : undefined);
let _fetch = envFetch || fetch; let _fetch = envFetch || fetch;
@@ -196,6 +235,46 @@ const factory = (env) => {
let requestContentLength; let requestContentLength;
try { 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 // Enforce maxContentLength for data: URLs up-front so we never materialize
// an oversized payload. The HTTP adapter applies the same check (see http.js // an oversized payload. The HTTP adapter applies the same check (see http.js
// "if (protocol === 'data:')" branch). // "if (protocol === 'data:')" branch).
+1 -1
View File
@@ -752,7 +752,7 @@ export default isHttpAdapterSupported &&
auth = username + ':' + password; auth = username + ':' + password;
} }
if (!auth && parsed.username) { if (!auth && (parsed.username || parsed.password)) {
const urlUsername = decodeURIComponentSafe(parsed.username); const urlUsername = decodeURIComponentSafe(parsed.username);
const urlPassword = decodeURIComponentSafe(parsed.password); const urlPassword = decodeURIComponentSafe(parsed.password);
auth = urlUsername + ':' + urlPassword; auth = urlUsername + ':' + urlPassword;
+116 -1
View File
@@ -465,7 +465,7 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
try { try {
const user = 'foo'; const user = 'foo';
const headers = { Authorization: 'Bearer 1234' }; 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, 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 () => { it('should support stream.Readable as a payload', async () => {
const server = await startHTTPServer(async (req, res) => res.end('OK'), { port: SERVER_PORT }); const server = await startHTTPServer(async (req, res) => res.end('OK'), { port: SERVER_PORT });
+17
View File
@@ -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 () => { it('should support basic auth with a header', async () => {
const server = await startHTTPServer( const server = await startHTTPServer(
(req, res) => { (req, res) => {