Skip to content

Commit 38ba1b3

Browse files
niksyjasonsaayman
andauthored
fix(fetch): support basic auth from URL (#10896)
Co-authored-by: Jay <jasonsaayman@gmail.com>
1 parent 32e2515 commit 38ba1b3

5 files changed

Lines changed: 215 additions & 2 deletions

File tree

PRE_RELEASE_CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
- **AxiosHeaders:** Silently skip empty response header names emitted by some React Native Android responses instead of throwing. (**#6959**, **#10875**)
1212
- **Config Security:** Ignore inherited `params` and `paramsSerializer` values when resolving request config, preventing prototype-pollution gadgets from changing serialized URLs. (**#10922**)
13+
- **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**)
1314
- **Types:** Add the missing readonly `name: 'CanceledError'` declaration to CommonJS `CanceledError` typings to match the ESM declarations. (**#10922**)
1415
- **Types:** Correct the CommonJS `isCancel` type guard to narrow cancellation errors to `CanceledError<T>`, matching the ESM declaration. (**#10952**)
1516
- **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 @@
2324
- Update `README.md` request config docs for `transitional.advertiseZstdAcceptEncoding` and zstd decompression support.
2425
- Update `docs/pages/advanced/request-config.md` for `transitional.advertiseZstdAcceptEncoding` and zstd decompression support.
2526
- Update decompression-bomb security guidance in `README.md` and `docs/pages/misc/security.md` to mention zstd.
27+
- Update `README.md` and `docs/pages/advanced/request-config.md` to document URL-embedded Basic auth fallback and `config.auth` precedence across adapters.

lib/adapters/fetch.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,35 @@ const DEFAULT_CHUNK_SIZE = 64 * 1024;
1919

2020
const { isFunction } = utils;
2121

22+
/**
23+
* Encode a UTF-8 string to a Latin-1 byte string for use with btoa().
24+
* This is a modern replacement for the deprecated unescape(encodeURIComponent(str)) pattern.
25+
*
26+
* @param {string} str The string to encode
27+
*
28+
* @returns {string} UTF-8 bytes as a Latin-1 string
29+
*/
30+
const encodeUTF8 = (str) =>
31+
encodeURIComponent(str).replace(/%([0-9A-F]{2})/gi, (_, hex) =>
32+
String.fromCharCode(parseInt(hex, 16))
33+
);
34+
35+
// Node's WHATWG URL parser returns `username` and `password` percent-encoded.
36+
// Decode before composing the `auth` option so credentials such as
37+
// `my%40email.com:pass` are sent as `my@email.com:pass`. Falls back to the
38+
// original value for malformed input so a bad encoding never throws.
39+
const decodeURIComponentSafe = (value) => {
40+
if (!utils.isString(value)) {
41+
return value;
42+
}
43+
44+
try {
45+
return decodeURIComponent(value);
46+
} catch (error) {
47+
return value;
48+
}
49+
};
50+
2251
const test = (fn, ...args) => {
2352
try {
2453
return !!fn(...args);
@@ -27,6 +56,15 @@ const test = (fn, ...args) => {
2756
}
2857
};
2958

59+
const maybeWithAuthCredentials = (url) => {
60+
const protocolIndex = url.indexOf('://');
61+
let urlToCheck = url;
62+
if (protocolIndex !== -1) {
63+
urlToCheck = urlToCheck.slice(protocolIndex + 3);
64+
}
65+
return urlToCheck.includes('@') || urlToCheck.includes(':');
66+
};
67+
3068
const factory = (env) => {
3169
const globalObject =
3270
utils.global !== undefined && utils.global !== null
@@ -174,6 +212,7 @@ const factory = (env) => {
174212

175213
const hasMaxContentLength = utils.isNumber(maxContentLength) && maxContentLength > -1;
176214
const hasMaxBodyLength = utils.isNumber(maxBodyLength) && maxBodyLength > -1;
215+
const own = (key) => (utils.hasOwnProp(config, key) ? config[key] : undefined);
177216

178217
let _fetch = envFetch || fetch;
179218

@@ -196,6 +235,46 @@ const factory = (env) => {
196235
let requestContentLength;
197236

198237
try {
238+
// HTTP basic authentication
239+
let auth = undefined;
240+
const configAuth = own('auth');
241+
242+
if (configAuth) {
243+
const username = configAuth.username || '';
244+
const password = configAuth.password || '';
245+
auth = {
246+
username,
247+
password
248+
};
249+
}
250+
251+
if (maybeWithAuthCredentials(url)) {
252+
const parsedURL = new URL(url, platform.origin);
253+
254+
if (!auth && (parsedURL.username || parsedURL.password)) {
255+
const urlUsername = decodeURIComponentSafe(parsedURL.username);
256+
const urlPassword = decodeURIComponentSafe(parsedURL.password);
257+
auth = {
258+
username: urlUsername,
259+
password: urlPassword
260+
};
261+
}
262+
263+
if (parsedURL.username || parsedURL.password) {
264+
parsedURL.username = '';
265+
parsedURL.password = '';
266+
url = parsedURL.href;
267+
}
268+
}
269+
270+
if (auth) {
271+
headers.delete('authorization');
272+
headers.set(
273+
'Authorization',
274+
'Basic ' + btoa(encodeUTF8((auth.username || '') + ':' + (auth.password || '')))
275+
);
276+
}
277+
199278
// Enforce maxContentLength for data: URLs up-front so we never materialize
200279
// an oversized payload. The HTTP adapter applies the same check (see http.js
201280
// "if (protocol === 'data:')" branch).

lib/adapters/http.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -752,7 +752,7 @@ export default isHttpAdapterSupported &&
752752
auth = username + ':' + password;
753753
}
754754

755-
if (!auth && parsed.username) {
755+
if (!auth && (parsed.username || parsed.password)) {
756756
const urlUsername = decodeURIComponentSafe(parsed.username);
757757
const urlPassword = decodeURIComponentSafe(parsed.password);
758758
auth = urlUsername + ':' + urlPassword;

tests/unit/adapters/fetch.test.js

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
465465
try {
466466
const user = 'foo';
467467
const headers = { Authorization: 'Bearer 1234' };
468-
const res = await axios.get(`http://${user}@localhost:${server.address().port}/`, {
468+
const res = await fetchAxios.get(`http://${user}@localhost:${server.address().port}/`, {
469469
headers,
470470
});
471471

@@ -476,6 +476,121 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
476476
}
477477
});
478478

479+
it('should decode basic auth credentials from the request URL', async () => {
480+
const server = await startHTTPServer(
481+
(req, res) => {
482+
res.end(req.headers.authorization);
483+
},
484+
{ port: SERVER_PORT }
485+
);
486+
487+
try {
488+
const response = await fetchAxios.get(
489+
`http://my%40email.com:pa%24ss@localhost:${server.address().port}/`
490+
);
491+
const base64 = Buffer.from('my@email.com:pa$ss', 'utf8').toString('base64');
492+
assert.strictEqual(response.data, `Basic ${base64}`);
493+
} finally {
494+
await stopHTTPServer(server);
495+
}
496+
});
497+
498+
it('should UTF-8 encode basic auth credentials from the request URL', async () => {
499+
const server = await startHTTPServer(
500+
(req, res) => {
501+
res.end(req.headers.authorization);
502+
},
503+
{ port: SERVER_PORT }
504+
);
505+
506+
try {
507+
const response = await fetchAxios.get(
508+
`http://%E7%94%A8%E6%88%B7:pa%C3%9F@localhost:${server.address().port}/`
509+
);
510+
const base64 = Buffer.from('\u7528\u6237:pa\u00df', 'utf8').toString('base64');
511+
assert.strictEqual(response.data, `Basic ${base64}`);
512+
} finally {
513+
await stopHTTPServer(server);
514+
}
515+
});
516+
517+
it('keeps malformed URL credentials percent-encoding and does not throw', async () => {
518+
const server = await startHTTPServer(
519+
(req, res) => {
520+
res.end(req.headers.authorization);
521+
},
522+
{ port: SERVER_PORT }
523+
);
524+
525+
try {
526+
const response = await fetchAxios.get(`http://user%:foo%zz@localhost:${server.address().port}/`);
527+
const base64 = Buffer.from('user%:foo%zz', 'utf8').toString('base64');
528+
assert.strictEqual(response.data, `Basic ${base64}`);
529+
} finally {
530+
await stopHTTPServer(server);
531+
}
532+
});
533+
534+
it('should support password-only basic auth credentials from the request URL', async () => {
535+
const server = await startHTTPServer(
536+
(req, res) => {
537+
res.end(req.headers.authorization);
538+
},
539+
{ port: SERVER_PORT }
540+
);
541+
542+
try {
543+
const response = await fetchAxios.get(`http://:secret@localhost:${server.address().port}/`);
544+
const base64 = Buffer.from(':secret', 'utf8').toString('base64');
545+
assert.strictEqual(response.data, `Basic ${base64}`);
546+
} finally {
547+
await stopHTTPServer(server);
548+
}
549+
});
550+
551+
it('should prefer config auth over basic auth credentials from the request URL', async () => {
552+
const server = await startHTTPServer(
553+
(req, res) => {
554+
res.end(req.headers.authorization);
555+
},
556+
{ port: SERVER_PORT }
557+
);
558+
559+
try {
560+
const auth = { username: 'config-user', password: 'config-pass' };
561+
const response = await fetchAxios.get(
562+
`http://url-user:url-pass@localhost:${server.address().port}/`,
563+
{ auth }
564+
);
565+
const base64 = Buffer.from('config-user:config-pass', 'utf8').toString('base64');
566+
assert.strictEqual(response.data, `Basic ${base64}`);
567+
} finally {
568+
await stopHTTPServer(server);
569+
}
570+
});
571+
572+
it('should support basic auth with a header', async () => {
573+
const server = await startHTTPServer(
574+
(req, res) => {
575+
res.end(req.headers.authorization);
576+
},
577+
{ port: SERVER_PORT }
578+
);
579+
580+
try {
581+
const auth = { username: 'foo', password: 'bar' };
582+
const headers = { AuThOrIzAtIoN: 'Bearer 1234' }; // wonky casing to ensure caseless comparison
583+
const response = await fetchAxios.get(`http://localhost:${server.address().port}/`, {
584+
auth,
585+
headers,
586+
});
587+
const base64 = Buffer.from('foo:bar', 'utf8').toString('base64');
588+
assert.strictEqual(response.data, `Basic ${base64}`);
589+
} finally {
590+
await stopHTTPServer(server);
591+
}
592+
});
593+
479594
it('should support stream.Readable as a payload', async () => {
480595
const server = await startHTTPServer(async (req, res) => res.end('OK'), { port: SERVER_PORT });
481596

tests/unit/adapters/http.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,6 +1143,23 @@ describe('supports http with nodejs', () => {
11431143
}
11441144
});
11451145

1146+
it('should support password-only basic auth credentials from the request URL', async () => {
1147+
const server = await startHTTPServer(
1148+
(req, res) => {
1149+
res.end(req.headers.authorization);
1150+
},
1151+
{ port: SERVER_PORT }
1152+
);
1153+
1154+
try {
1155+
const response = await axios.get(`http://:secret@localhost:${server.address().port}/`);
1156+
const base64 = Buffer.from(':secret', 'utf8').toString('base64');
1157+
assert.strictEqual(response.data, `Basic ${base64}`);
1158+
} finally {
1159+
await stopHTTPServer(server);
1160+
}
1161+
});
1162+
11461163
it('should support basic auth with a header', async () => {
11471164
const server = await startHTTPServer(
11481165
(req, res) => {

0 commit comments

Comments
 (0)