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