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:
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user