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

fix: malformed http urls (#11000)

This commit is contained in:
Jay
2026-06-11 08:36:48 +02:00
committed by GitHub
parent b40ce498ab
commit 32fc489632
29 changed files with 1640 additions and 131 deletions
+1
View File
@@ -8,6 +8,7 @@
## Bug Fixes ## Bug Fixes
- **URL Validation:** Reject malformed `http:` and `https:` URLs that omit `//` before adapter URL normalization, returning `ERR_INVALID_URL` instead of silently normalizing invalid input. (**#10900**, closes **#7315**)
- **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**)
- **Config Merge:** Added `transitional.validateStatusUndefinedResolves` (default `true`) so applications can opt into treating explicit `validateStatus: undefined` like an omitted option by setting it to `false`. `validateStatus: null` still accepts every response status. (**#10899**, closes **#6688**) - **Config Merge:** Added `transitional.validateStatusUndefinedResolves` (default `true`) so applications can opt into treating explicit `validateStatus: undefined` like an omitted option by setting it to `false`. `validateStatus: null` still accepts every response status. (**#10899**, closes **#6688**)
+10
View File
@@ -20,6 +20,16 @@ Do not store raw diffs or line-number-only instructions here; prefer stable sect
## Unreleased ## Unreleased
### malformed HTTP URL rejection
- **Change:** Note that malformed `http:` and `https:` URLs missing `//` are rejected before adapter normalization.
- **Source:** `PRE_RELEASE_CHANGELOG.md` Bug Fixes, #10900, closes #7315.
- **Status:** Skipped.
- **Docs targets:** None beyond release notes.
- **Required content:** No API documentation update is needed because this changes handling for invalid URL input without adding or changing request config, types, or public APIs. The release note should mention that axios now throws `AxiosError` with `ERR_INVALID_URL` for malformed HTTP(S) URLs such as `https:example.com` or `http:/example.com` instead of allowing platform URL normalization.
- **Examples:** None.
- **Notes:** Treat as a bug/security-hardening release note, not a request-config documentation change.
### sensitiveHeaders request config ### sensitiveHeaders request config
- **Change:** Document the Node.js `sensitiveHeaders` request config option for stripping custom secret headers from cross-origin redirects. - **Change:** Document the Node.js `sensitiveHeaders` request config option for stripping custom secret headers from cross-origin redirects.
+113 -37
View File
@@ -234,14 +234,28 @@ const factory = (env) => {
let requestContentLength; let requestContentLength;
// AxiosError we raise while the request body is being streamed. Captured
// by identity so the catch block can surface it directly, regardless of
// how the runtime wraps the resulting fetch rejection (undici exposes it
// as `err.cause`; some browsers drop the original error entirely).
let pendingBodyError = null;
const maxBodyLengthError = () =>
new AxiosError(
'Request body larger than maxBodyLength limit',
AxiosError.ERR_BAD_REQUEST,
config,
request
);
try { try {
// HTTP basic authentication // HTTP basic authentication
let auth = undefined; let auth = undefined;
const configAuth = own('auth'); const configAuth = own('auth');
if (configAuth) { if (configAuth) {
const username = configAuth.username || ''; const username = utils.getSafeProp(configAuth, 'username') || '';
const password = configAuth.password || ''; const password = utils.getSafeProp(configAuth, 'password') || '';
auth = { auth = {
username, username,
password password
@@ -290,53 +304,96 @@ const factory = (env) => {
} }
} }
// Enforce maxBodyLength against the outbound request body before dispatch. // Enforce maxBodyLength against known-size bodies before dispatch using
// Mirrors http.js behavior (ERR_BAD_REQUEST / 'Request body larger than // the body's *actual* size — never a caller-declared Content-Length,
// maxBodyLength limit'). Skip when the body length cannot be determined // which could under-report to slip an oversized body past the check.
// (e.g. a live ReadableStream supplied by the caller). // Unknown-size streams return undefined here and are counted per-chunk
// below as fetch consumes them.
if (hasMaxBodyLength && method !== 'get' && method !== 'head') { if (hasMaxBodyLength && method !== 'get' && method !== 'head') {
const outboundLength = await resolveBodyLength(headers, data); const outboundLength = await getBodyLength(data);
if ( if (typeof outboundLength === 'number' && isFinite(outboundLength)) {
typeof outboundLength === 'number' && requestContentLength = outboundLength;
isFinite(outboundLength) && if (outboundLength > maxBodyLength) {
outboundLength > maxBodyLength throw maxBodyLengthError();
) { }
throw new AxiosError(
'Request body larger than maxBodyLength limit',
AxiosError.ERR_BAD_REQUEST,
config,
request
);
} }
} }
// A streamed body under maxBodyLength must be counted as fetch consumes
// it; its size is never trusted from a caller-declared Content-Length.
const mustEnforceStreamBody =
hasMaxBodyLength && (utils.isReadableStream(data) || utils.isStream(data));
const trackRequestStream = (stream, onProgress, flush) =>
trackStream(
stream,
DEFAULT_CHUNK_SIZE,
(loadedBytes) => {
if (hasMaxBodyLength && loadedBytes > maxBodyLength) {
throw (pendingBodyError = maxBodyLengthError());
}
onProgress && onProgress(loadedBytes);
},
flush
);
if ( if (
onUploadProgress &&
supportsRequestStream && supportsRequestStream &&
method !== 'get' && method !== 'get' &&
method !== 'head' && method !== 'head' &&
(requestContentLength = await resolveBodyLength(headers, data)) !== 0 (onUploadProgress || mustEnforceStreamBody)
) { ) {
let _request = new Request(url, { requestContentLength =
method: 'POST', requestContentLength == null ? await resolveBodyLength(headers, data) : requestContentLength;
body: data,
duplex: 'half',
});
let contentTypeHeader; // A declared length of 0 is only trusted to skip the wrap when we are
// not enforcing a stream limit (which must not rely on that header).
if (requestContentLength !== 0 || mustEnforceStreamBody) {
let _request = new Request(url, {
method: 'POST',
body: data,
duplex: 'half',
});
if (utils.isFormData(data) && (contentTypeHeader = _request.headers.get('content-type'))) { let contentTypeHeader;
headers.setContentType(contentTypeHeader);
}
if (_request.body) { if (utils.isFormData(data) && (contentTypeHeader = _request.headers.get('content-type'))) {
const [onProgress, flush] = progressEventDecorator( headers.setContentType(contentTypeHeader);
requestContentLength, }
progressEventReducer(asyncDecorator(onUploadProgress))
);
data = trackStream(_request.body, DEFAULT_CHUNK_SIZE, onProgress, flush); if (_request.body) {
const [onProgress, flush] =
(onUploadProgress &&
progressEventDecorator(
requestContentLength,
progressEventReducer(asyncDecorator(onUploadProgress))
)) ||
[];
data = trackRequestStream(_request.body, onProgress, flush);
}
} }
} else if (
mustEnforceStreamBody &&
!isRequestSupported &&
isReadableStreamSupported &&
method !== 'get' &&
method !== 'head'
) {
data = trackRequestStream(data);
} else if (
mustEnforceStreamBody &&
isRequestSupported &&
!supportsRequestStream &&
method !== 'get' &&
method !== 'head'
) {
throw new AxiosError(
'Stream request bodies are not supported by the current fetch implementation',
AxiosError.ERR_NOT_SUPPORT,
config,
request
);
} }
if (!utils.isString(withCredentials)) { if (!utils.isString(withCredentials)) {
@@ -379,10 +436,12 @@ const factory = (env) => {
? _fetch(request, fetchOptions) ? _fetch(request, fetchOptions)
: _fetch(url, resolvedOptions)); : _fetch(url, resolvedOptions));
const responseHeaders = AxiosHeaders.from(response.headers);
// Cheap pre-check: if the server honestly declares a content-length that // Cheap pre-check: if the server honestly declares a content-length that
// already exceeds the cap, reject before we start streaming. // already exceeds the cap, reject before we start streaming.
if (hasMaxContentLength) { if (hasMaxContentLength) {
const declaredLength = utils.toFiniteNumber(response.headers.get('content-length')); const declaredLength = utils.toFiniteNumber(responseHeaders.getContentLength());
if (declaredLength != null && declaredLength > maxContentLength) { if (declaredLength != null && declaredLength > maxContentLength) {
throw new AxiosError( throw new AxiosError(
'maxContentLength size of ' + maxContentLength + ' exceeded', 'maxContentLength size of ' + maxContentLength + ' exceeded',
@@ -407,7 +466,7 @@ const factory = (env) => {
options[prop] = response[prop]; options[prop] = response[prop];
}); });
const responseContentLength = utils.toFiniteNumber(response.headers.get('content-length')); const responseContentLength = utils.toFiniteNumber(responseHeaders.getContentLength());
const [onProgress, flush] = const [onProgress, flush] =
(onDownloadProgress && (onDownloadProgress &&
@@ -502,6 +561,23 @@ const factory = (env) => {
throw canceledError; throw canceledError;
} }
// Surface a maxBodyLength violation we raised while the request body was
// being streamed. Matching by identity (rather than reading
// `err.cause.isAxiosError`) keeps the error deterministic across runtimes
// and avoids both prototype-pollution reads and mis-attributing a foreign
// AxiosError that merely happened to land in `err.cause`.
if (pendingBodyError) {
request && !pendingBodyError.request && (pendingBodyError.request = request);
throw pendingBodyError;
}
// Re-throw AxiosErrors we raised synchronously (data: URL / content-length
// pre-checks, response size enforcement) without re-wrapping them.
if (err instanceof AxiosError) {
request && !err.request && (err.request = request);
throw err;
}
if (err && err.name === 'TypeError' && /Load failed|fetch/i.test(err.message)) { if (err && err.name === 'TypeError' && /Load failed|fetch/i.test(err.message)) {
throw Object.assign( throw Object.assign(
new AxiosError( new AxiosError(
+61 -41
View File
@@ -318,7 +318,7 @@ function setProxy(options, configProxy, location, isRedirect, configHttpsAgent)
} }
const tunnelingAgent = getTunnelingAgent(agentOptions, configHttpsAgent); const tunnelingAgent = getTunnelingAgent(agentOptions, configHttpsAgent);
// Set both: `options.agent` is consumed by the native https.request path // Set both: `options.agent` is consumed by the native https.request path
// (config.maxRedirects === 0); `options.agents.https` is consumed by // (maxRedirects === 0); `options.agents.https` is consumed by
// follow-redirects, which ignores `options.agent` when `options.agents` // follow-redirects, which ignores `options.agent` when `options.agents`
// is present. // is present.
options.agent = tunnelingAgent; options.agent = tunnelingAgent;
@@ -462,7 +462,12 @@ const http2Transport = {
export default isHttpAdapterSupported && export default isHttpAdapterSupported &&
function httpAdapter(config) { function httpAdapter(config) {
return wrapAsync(async function dispatchHttpRequest(resolve, reject, onDone) { return wrapAsync(async function dispatchHttpRequest(resolve, reject, onDone) {
const own = (key) => (utils.hasOwnProp(config, key) ? config[key] : undefined); // Read config pollution-safely: own properties and members inherited from
// a non-Object.prototype source (e.g. an Object.create(defaults) template)
// are honored, but values injected onto a polluted Object.prototype are
// ignored. All behavior-affecting reads in this adapter go through own()
// so the protection boundary stays consistent.
const own = (key) => utils.getSafeProp(config, key);
const transitional = own('transitional') || transitionalDefaults; const transitional = own('transitional') || transitionalDefaults;
let data = own('data'); let data = own('data');
let lookup = own('lookup'); let lookup = own('lookup');
@@ -472,7 +477,13 @@ export default isHttpAdapterSupported &&
let http2Options = own('http2Options'); let http2Options = own('http2Options');
const responseType = own('responseType'); const responseType = own('responseType');
const responseEncoding = own('responseEncoding'); const responseEncoding = own('responseEncoding');
const method = config.method.toUpperCase(); const httpAgent = own('httpAgent');
const httpsAgent = own('httpsAgent');
const method = own('method').toUpperCase();
const maxRedirects = own('maxRedirects');
const maxBodyLength = own('maxBodyLength');
const maxContentLength = own('maxContentLength');
const decompress = own('decompress');
let isDone; let isDone;
let rejected = false; let rejected = false;
let req; let req;
@@ -529,11 +540,13 @@ export default isHttpAdapterSupported &&
} }
function createTimeoutError() { function createTimeoutError() {
let timeoutErrorMessage = config.timeout const configTimeout = own('timeout');
? 'timeout of ' + config.timeout + 'ms exceeded' let timeoutErrorMessage = configTimeout
? 'timeout of ' + configTimeout + 'ms exceeded'
: 'timeout exceeded'; : 'timeout exceeded';
if (config.timeoutErrorMessage) { const configTimeoutErrorMessage = own('timeoutErrorMessage');
timeoutErrorMessage = config.timeoutErrorMessage; if (configTimeoutErrorMessage) {
timeoutErrorMessage = configTimeoutErrorMessage;
} }
return new AxiosError( return new AxiosError(
timeoutErrorMessage, timeoutErrorMessage,
@@ -589,21 +602,21 @@ export default isHttpAdapterSupported &&
}); });
// Parse url // Parse url
const fullPath = buildFullPath(config.baseURL, config.url, config.allowAbsoluteUrls); const fullPath = buildFullPath(own('baseURL'), own('url'), own('allowAbsoluteUrls'), config);
const parsed = new URL(fullPath, platform.hasBrowserEnv ? platform.origin : undefined); const parsed = new URL(fullPath, platform.hasBrowserEnv ? platform.origin : undefined);
const protocol = parsed.protocol || supportedProtocols[0]; const protocol = parsed.protocol || supportedProtocols[0];
if (protocol === 'data:') { if (protocol === 'data:') {
// Apply the same semantics as HTTP: only enforce if a finite, non-negative cap is set. // Apply the same semantics as HTTP: only enforce if a finite, non-negative cap is set.
if (config.maxContentLength > -1) { if (maxContentLength > -1) {
// Use the exact string passed to fromDataURI (config.url); fall back to fullPath if needed. // Use the exact string passed to fromDataURI (the configured url); fall back to fullPath if needed.
const dataUrl = String(config.url || fullPath || ''); const dataUrl = String(own('url') || fullPath || '');
const estimated = estimateDataURLDecodedBytes(dataUrl); const estimated = estimateDataURLDecodedBytes(dataUrl);
if (estimated > config.maxContentLength) { if (estimated > maxContentLength) {
return reject( return reject(
new AxiosError( new AxiosError(
'maxContentLength size of ' + config.maxContentLength + ' exceeded', 'maxContentLength size of ' + maxContentLength + ' exceeded',
AxiosError.ERR_BAD_RESPONSE, AxiosError.ERR_BAD_RESPONSE,
config config
) )
@@ -623,7 +636,7 @@ export default isHttpAdapterSupported &&
} }
try { try {
convertedData = fromDataURI(config.url, responseType === 'blob', { convertedData = fromDataURI(own('url'), responseType === 'blob', {
Blob: config.env && config.env.Blob, Blob: config.env && config.env.Blob,
}); });
} catch (err) { } catch (err) {
@@ -723,7 +736,7 @@ export default isHttpAdapterSupported &&
// Add Content-Length header if data exists // Add Content-Length header if data exists
headers.setContentLength(data.length, false); headers.setContentLength(data.length, false);
if (config.maxBodyLength > -1 && data.length > config.maxBodyLength) { if (maxBodyLength > -1 && data.length > maxBodyLength) {
return reject( return reject(
new AxiosError( new AxiosError(
'Request body larger than maxBodyLength limit', 'Request body larger than maxBodyLength limit',
@@ -775,8 +788,8 @@ export default isHttpAdapterSupported &&
let auth = undefined; let auth = undefined;
const configAuth = own('auth'); const configAuth = own('auth');
if (configAuth) { if (configAuth) {
const username = configAuth.username || ''; const username = utils.getSafeProp(configAuth, 'username') || '';
const password = configAuth.password || ''; const password = utils.getSafeProp(configAuth, 'password') || '';
auth = username + ':' + password; auth = username + ':' + password;
} }
@@ -793,13 +806,13 @@ export default isHttpAdapterSupported &&
try { try {
path = buildURL( path = buildURL(
parsed.pathname + parsed.search, parsed.pathname + parsed.search,
config.params, own('params'),
config.paramsSerializer own('paramsSerializer')
).replace(/^\?/, ''); ).replace(/^\?/, '');
} catch (err) { } catch (err) {
const customErr = new Error(err.message); const customErr = new Error(err.message);
customErr.config = config; customErr.config = config;
customErr.url = config.url; customErr.url = own('url');
customErr.exists = true; customErr.exists = true;
return reject(customErr); return reject(customErr);
} }
@@ -817,7 +830,7 @@ export default isHttpAdapterSupported &&
path, path,
method: method, method: method,
headers: toByteStringHeaderObject(headers), headers: toByteStringHeaderObject(headers),
agents: { http: config.httpAgent, https: config.httpsAgent }, agents: { http: httpAgent, https: httpsAgent },
auth, auth,
protocol, protocol,
family, family,
@@ -867,19 +880,24 @@ export default isHttpAdapterSupported &&
options.port = parsed.port; options.port = parsed.port;
setProxy( setProxy(
options, options,
config.proxy, own('proxy'),
protocol + '//' + parsed.hostname + (parsed.port ? ':' + parsed.port : '') + options.path, protocol + '//' + parsed.hostname + (parsed.port ? ':' + parsed.port : '') + options.path,
false, false,
config.httpsAgent httpsAgent
); );
} }
let transport; let transport;
let isNativeTransport = false; let isNativeTransport = false;
// True only for the follow-redirects transport, which applies
// options.maxBodyLength itself. Every other transport (http2, native
// http/https, a user-supplied custom transport) needs the explicit
// byte-counting pipeline below to enforce maxBodyLength on streamed uploads.
let transportEnforcesMaxBodyLength = false;
const isHttpsRequest = isHttps.test(options.protocol); const isHttpsRequest = isHttps.test(options.protocol);
// Don't clobber a CONNECT-tunneling agent installed by setProxy() for an // Don't clobber a CONNECT-tunneling agent installed by setProxy() for an
// HTTPS target. // HTTPS target.
if (options.agent == null) { if (options.agent == null) {
options.agent = isHttpsRequest ? config.httpsAgent : config.httpAgent; options.agent = isHttpsRequest ? httpsAgent : httpAgent;
} }
if (isHttp2) { if (isHttp2) {
@@ -888,13 +906,14 @@ export default isHttpAdapterSupported &&
const configTransport = own('transport'); const configTransport = own('transport');
if (configTransport) { if (configTransport) {
transport = configTransport; transport = configTransport;
} else if (config.maxRedirects === 0) { } else if (maxRedirects === 0) {
transport = isHttpsRequest ? https : http; transport = isHttpsRequest ? https : http;
isNativeTransport = true; isNativeTransport = true;
} else { } else {
transportEnforcesMaxBodyLength = true;
options.sensitiveHeaders = []; options.sensitiveHeaders = [];
if (config.maxRedirects) { if (maxRedirects) {
options.maxRedirects = config.maxRedirects; options.maxRedirects = maxRedirects;
} }
const configBeforeRedirect = own('beforeRedirect'); const configBeforeRedirect = own('beforeRedirect');
if (configBeforeRedirect) { if (configBeforeRedirect) {
@@ -960,8 +979,8 @@ export default isHttpAdapterSupported &&
} }
} }
if (config.maxBodyLength > -1) { if (maxBodyLength > -1) {
options.maxBodyLength = config.maxBodyLength; options.maxBodyLength = maxBodyLength;
} else { } else {
// follow-redirects does not skip comparison, so it should always succeed for axios -1 unlimited // follow-redirects does not skip comparison, so it should always succeed for axios -1 unlimited
options.maxBodyLength = Infinity; options.maxBodyLength = Infinity;
@@ -1009,7 +1028,7 @@ export default isHttpAdapterSupported &&
const lastRequest = res.req || req; const lastRequest = res.req || req;
// if decompress disabled we should not decompress // if decompress disabled we should not decompress
if (config.decompress !== false && res.headers['content-encoding']) { if (decompress !== false && res.headers['content-encoding']) {
// if no content, but headers still say that it is encoded, // if no content, but headers still say that it is encoded,
// remove the header not confuse downstream operations // remove the header not confuse downstream operations
if (method === 'HEAD' || res.statusCode === 204) { if (method === 'HEAD' || res.statusCode === 204) {
@@ -1065,8 +1084,8 @@ export default isHttpAdapterSupported &&
if (responseType === 'stream') { if (responseType === 'stream') {
// Enforce maxContentLength on streamed responses; previously this // Enforce maxContentLength on streamed responses; previously this
// was applied only to buffered responses. // was applied only to buffered responses.
if (config.maxContentLength > -1) { if (maxContentLength > -1) {
const limit = config.maxContentLength; const limit = maxContentLength;
const source = responseStream; const source = responseStream;
async function* enforceMaxContentLength() { async function* enforceMaxContentLength() {
let totalResponseBytes = 0; let totalResponseBytes = 0;
@@ -1098,13 +1117,13 @@ export default isHttpAdapterSupported &&
totalResponseBytes += chunk.length; totalResponseBytes += chunk.length;
// make sure the content length is not over the maxContentLength if specified // make sure the content length is not over the maxContentLength if specified
if (config.maxContentLength > -1 && totalResponseBytes > config.maxContentLength) { if (maxContentLength > -1 && totalResponseBytes > maxContentLength) {
// stream.destroy() emit aborted event before calling reject() on Node.js v16 // stream.destroy() emit aborted event before calling reject() on Node.js v16
rejected = true; rejected = true;
responseStream.destroy(); responseStream.destroy();
abort( abort(
new AxiosError( new AxiosError(
'maxContentLength size of ' + config.maxContentLength + ' exceeded', 'maxContentLength size of ' + maxContentLength + ' exceeded',
AxiosError.ERR_BAD_RESPONSE, AxiosError.ERR_BAD_RESPONSE,
config, config,
lastRequest lastRequest
@@ -1219,9 +1238,9 @@ export default isHttpAdapterSupported &&
}); });
// Handle request timeout // Handle request timeout
if (config.timeout) { if (own('timeout')) {
// This is forcing a int timeout to avoid problems if the `req` interface doesn't handle other types. // This is forcing a int timeout to avoid problems if the `req` interface doesn't handle other types.
const timeout = parseInt(config.timeout, 10); const timeout = parseInt(own('timeout'), 10);
if (Number.isNaN(timeout)) { if (Number.isNaN(timeout)) {
abort( abort(
@@ -1279,12 +1298,13 @@ export default isHttpAdapterSupported &&
} }
}); });
// Enforce maxBodyLength for streamed uploads on the native http/https // Enforce maxBodyLength for streamed uploads on every transport that
// transport (maxRedirects === 0); follow-redirects enforces it on the // does not apply options.maxBodyLength itself (native http/https, http2,
// other path. // and user-supplied custom transports). The follow-redirects transport
// enforces it on the redirected HTTP/1 path.
let uploadStream = data; let uploadStream = data;
if (config.maxBodyLength > -1 && config.maxRedirects === 0) { if (maxBodyLength > -1 && !transportEnforcesMaxBodyLength) {
const limit = config.maxBodyLength; const limit = maxBodyLength;
let bytesSent = 0; let bytesSent = 0;
uploadStream = stream.pipeline( uploadStream = stream.pipeline(
[ [
+2 -2
View File
@@ -234,7 +234,7 @@ class Axios {
getUri(config) { getUri(config) {
config = mergeConfig(this.defaults, config); config = mergeConfig(this.defaults, config);
const fullPath = buildFullPath(config.baseURL, config.url, config.allowAbsoluteUrls); const fullPath = buildFullPath(config.baseURL, config.url, config.allowAbsoluteUrls, config);
return buildURL(fullPath, config.params, config.paramsSerializer); return buildURL(fullPath, config.params, config.paramsSerializer);
} }
} }
@@ -247,7 +247,7 @@ utils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData
mergeConfig(config || {}, { mergeConfig(config || {}, {
method, method,
url, url,
data: (config || {}).data, data: config && utils.hasOwnProp(config, 'data') ? config.data : undefined,
}) })
); );
}; };
+10 -7
View File
@@ -111,8 +111,8 @@ class AxiosHeaders {
setHeaders(header, valueOrRewrite); setHeaders(header, valueOrRewrite);
} else if (utils.isString(header) && (header = header.trim()) && !isValidHeaderName(header)) { } else if (utils.isString(header) && (header = header.trim()) && !isValidHeaderName(header)) {
setHeaders(parseHeaders(header), valueOrRewrite); setHeaders(parseHeaders(header), valueOrRewrite);
} else if (utils.isObject(header) && utils.isIterable(header)) { } else if (utils.isObject(header) && utils.isSafeIterable(header)) {
let obj = {}, let obj = Object.create(null),
dest, dest,
key; key;
for (const entry of header) { for (const entry of header) {
@@ -120,11 +120,14 @@ class AxiosHeaders {
throw new TypeError('Object iterator must return a key-value pair'); throw new TypeError('Object iterator must return a key-value pair');
} }
obj[(key = entry[0])] = (dest = obj[key]) key = entry[0];
? utils.isArray(dest)
? [...dest, entry[1]] if (utils.hasOwnProp(obj, key)) {
: [dest, entry[1]] dest = obj[key];
: entry[1]; obj[key] = utils.isArray(dest) ? [...dest, entry[1]] : [dest, entry[1]];
} else {
obj[key] = entry[1];
}
} }
setHeaders(obj, valueOrRewrite); setHeaders(obj, valueOrRewrite);
+29 -1
View File
@@ -1,8 +1,34 @@
'use strict'; 'use strict';
import AxiosError from './AxiosError.js';
import isAbsoluteURL from '../helpers/isAbsoluteURL.js'; import isAbsoluteURL from '../helpers/isAbsoluteURL.js';
import combineURLs from '../helpers/combineURLs.js'; import combineURLs from '../helpers/combineURLs.js';
const malformedHttpProtocol = /^https?:(?!\/\/)/i;
const httpProtocolControlCharacters = /[\t\n\r]/g;
function stripLeadingC0ControlOrSpace(url) {
let i = 0;
while (i < url.length && url.charCodeAt(i) <= 0x20) {
i++;
}
return url.slice(i);
}
function normalizeURLForProtocolCheck(url) {
return stripLeadingC0ControlOrSpace(url).replace(httpProtocolControlCharacters, '');
}
function assertValidHttpProtocolURL(url, config) {
if (typeof url === 'string' && malformedHttpProtocol.test(normalizeURLForProtocolCheck(url))) {
throw new AxiosError(
'Invalid URL: missing "//" after protocol',
AxiosError.ERR_INVALID_URL,
config
);
}
}
/** /**
* Creates a new URL by combining the baseURL with the requestedURL, * Creates a new URL by combining the baseURL with the requestedURL,
* only when the requestedURL is not already an absolute URL. * only when the requestedURL is not already an absolute URL.
@@ -13,9 +39,11 @@ import combineURLs from '../helpers/combineURLs.js';
* *
* @returns {string} The combined full path * @returns {string} The combined full path
*/ */
export default function buildFullPath(baseURL, requestedURL, allowAbsoluteUrls) { export default function buildFullPath(baseURL, requestedURL, allowAbsoluteUrls, config) {
assertValidHttpProtocolURL(requestedURL, config);
let isRelativeUrl = !isAbsoluteURL(requestedURL); let isRelativeUrl = !isAbsoluteURL(requestedURL);
if (baseURL && (isRelativeUrl || allowAbsoluteUrls === false)) { if (baseURL && (isRelativeUrl || allowAbsoluteUrls === false)) {
assertValidHttpProtocolURL(baseURL, config);
return combineURLs(baseURL, requestedURL); return combineURLs(baseURL, requestedURL);
} }
return requestedURL; return requestedURL;
+5 -3
View File
@@ -33,15 +33,17 @@ export default function buildURL(url, params, options) {
return url; return url;
} }
const _encode = (options && options.encode) || encode;
const _options = utils.isFunction(options) const _options = utils.isFunction(options)
? { ? {
serialize: options, serialize: options,
} }
: options; : options;
const serializeFn = _options && _options.serialize; // Read serializer options pollution-safely: own properties and methods on a
// class/template prototype are honored, but values injected onto a polluted
// Object.prototype are ignored.
const _encode = utils.getSafeProp(_options, 'encode') || encode;
const serializeFn = utils.getSafeProp(_options, 'serialize');
let serializedParams; let serializedParams;
+16 -11
View File
@@ -2,11 +2,19 @@
* Estimate decoded byte length of a data:// URL *without* allocating large buffers. * Estimate decoded byte length of a data:// URL *without* allocating large buffers.
* - For base64: compute exact decoded size using length and padding; * - For base64: compute exact decoded size using length and padding;
* handle %XX at the character-count level (no string allocation). * handle %XX at the character-count level (no string allocation).
* - For non-base64: use UTF-8 byteLength of the encoded body as a safe upper bound. * - For non-base64: compute the exact percent-decoded UTF-8 byte length.
* *
* @param {string} url * @param {string} url
* @returns {number} * @returns {number}
*/ */
const isHexDigit = (charCode) =>
(charCode >= 48 && charCode <= 57) ||
(charCode >= 65 && charCode <= 70) ||
(charCode >= 97 && charCode <= 102);
const isPercentEncodedByte = (str, i, len) =>
i + 2 < len && isHexDigit(str.charCodeAt(i + 1)) && isHexDigit(str.charCodeAt(i + 2));
export default function estimateDataURLDecodedBytes(url) { export default function estimateDataURLDecodedBytes(url) {
if (!url || typeof url !== 'string') return 0; if (!url || typeof url !== 'string') return 0;
if (!url.startsWith('data:')) return 0; if (!url.startsWith('data:')) return 0;
@@ -26,9 +34,7 @@ export default function estimateDataURLDecodedBytes(url) {
if (body.charCodeAt(i) === 37 /* '%' */ && i + 2 < len) { if (body.charCodeAt(i) === 37 /* '%' */ && i + 2 < len) {
const a = body.charCodeAt(i + 1); const a = body.charCodeAt(i + 1);
const b = body.charCodeAt(i + 2); const b = body.charCodeAt(i + 2);
const isHex = const isHex = isHexDigit(a) && isHexDigit(b);
((a >= 48 && a <= 57) || (a >= 65 && a <= 70) || (a >= 97 && a <= 102)) &&
((b >= 48 && b <= 57) || (b >= 65 && b <= 70) || (b >= 97 && b <= 102));
if (isHex) { if (isHex) {
effectiveLen -= 2; effectiveLen -= 2;
@@ -69,18 +75,17 @@ export default function estimateDataURLDecodedBytes(url) {
return bytes > 0 ? bytes : 0; return bytes > 0 ? bytes : 0;
} }
if (typeof Buffer !== 'undefined' && typeof Buffer.byteLength === 'function') {
return Buffer.byteLength(body, 'utf8');
}
// Compute UTF-8 byte length directly from UTF-16 code units without allocating // Compute UTF-8 byte length directly from UTF-16 code units without allocating
// a byte buffer (TextEncoder.encode would defeat the DoS guard on large bodies). // a byte buffer (TextEncoder.encode would defeat the DoS guard on large bodies).
// Using body.length here would undercount non-ASCII (e.g. '€' is 1 code unit // Valid %XX triplets count as one decoded byte; this matches the bytes that
// but 3 UTF-8 bytes). // decodeURIComponent(body) would produce before Buffer re-encodes the string.
let bytes = 0; let bytes = 0;
for (let i = 0, len = body.length; i < len; i++) { for (let i = 0, len = body.length; i < len; i++) {
const c = body.charCodeAt(i); const c = body.charCodeAt(i);
if (c < 0x80) { if (c === 37 /* '%' */ && isPercentEncodedByte(body, i, len)) {
bytes += 1;
i += 2;
} else if (c < 0x80) {
bytes += 1; bytes += 1;
} else if (c < 0x800) { } else if (c < 0x800) {
bytes += 2; bytes += 2;
+25 -3
View File
@@ -1,6 +1,19 @@
'use strict'; 'use strict';
import utils from '../utils.js'; import utils from '../utils.js';
import AxiosError from '../core/AxiosError.js';
import { DEFAULT_FORM_DATA_MAX_DEPTH } from './toFormData.js';
const MAX_DEPTH = DEFAULT_FORM_DATA_MAX_DEPTH;
function throwIfDepthExceeded(index) {
if (index > MAX_DEPTH) {
throw new AxiosError(
'FormData field is too deeply nested (' + index + ' levels). Max depth: ' + MAX_DEPTH,
AxiosError.ERR_FORM_DATA_DEPTH_EXCEEDED
);
}
}
/** /**
* It takes a string like `foo[x][y][z]` and returns an array like `['foo', 'x', 'y', 'z'] * It takes a string like `foo[x][y][z]` and returns an array like `['foo', 'x', 'y', 'z']
@@ -14,9 +27,16 @@ function parsePropPath(name) {
// foo.x.y.z // foo.x.y.z
// foo-x-y-z // foo-x-y-z
// foo x y z // foo x y z
return utils.matchAll(/\w+|\[(\w*)]/g, name).map((match) => { const path = [];
return match[0] === '[]' ? '' : match[1] || match[0]; const pattern = /\w+|\[(\w*)]/g;
}); let match;
while ((match = pattern.exec(name)) !== null) {
throwIfDepthExceeded(path.length);
path.push(match[0] === '[]' ? '' : match[1] || match[0]);
}
return path;
} }
/** /**
@@ -48,6 +68,8 @@ function arrayToObject(arr) {
*/ */
function formDataToJSON(formData) { function formDataToJSON(formData) {
function buildPath(path, value, target, index) { function buildPath(path, value, target, index) {
throwIfDepthExceeded(index);
let name = path[index++]; let name = path[index++];
if (name === '__proto__') return true; if (name === '__proto__') return true;
+5 -3
View File
@@ -55,17 +55,19 @@ function resolveConfig(config) {
newConfig.headers = headers = AxiosHeaders.from(headers); newConfig.headers = headers = AxiosHeaders.from(headers);
newConfig.url = buildURL( newConfig.url = buildURL(
buildFullPath(baseURL, url, allowAbsoluteUrls), buildFullPath(baseURL, url, allowAbsoluteUrls, newConfig),
own('params'), own('params'),
own('paramsSerializer') own('paramsSerializer')
); );
// HTTP basic authentication // HTTP basic authentication
if (auth) { if (auth) {
const username = utils.getSafeProp(auth, 'username') || '';
const password = utils.getSafeProp(auth, 'password') || '';
headers.set( headers.set(
'Authorization', 'Authorization',
'Basic ' + 'Basic ' + btoa(username + ':' + (password ? encodeUTF8(password) : ''))
btoa((auth.username || '') + ':' + (auth.password ? encodeUTF8(auth.password) : ''))
); );
} }
+33 -1
View File
@@ -1,4 +1,4 @@
const LOOPBACK_HOSTNAMES = new Set(['localhost']); const LOOPBACK_HOSTNAMES = new Set(['localhost', '0.0.0.0']);
const isIPv4Loopback = (host) => { const isIPv4Loopback = (host) => {
const parts = host.split('.'); const parts = host.split('.');
@@ -7,6 +7,37 @@ const isIPv4Loopback = (host) => {
return parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255); return parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255);
}; };
const isIPv6ZeroGroup = (group) => /^0{1,4}$/.test(group);
// The unspecified address (IPv4 0.0.0.0 / IPv6 ::) resolves to the local host
// for outbound connections, so treat it as loopback-equivalent for NO_PROXY
// matching. 0.0.0.0 is covered by LOOPBACK_HOSTNAMES; this handles compressed
// and full IPv6 all-zero forms so both families bypass symmetrically.
const isIPv6Unspecified = (host) => {
if (host === '::') return true;
const compressionIndex = host.indexOf('::');
if (compressionIndex !== -1) {
if (compressionIndex !== host.lastIndexOf('::')) return false;
const left = host.slice(0, compressionIndex);
const right = host.slice(compressionIndex + 2);
const leftGroups = left ? left.split(':') : [];
const rightGroups = right ? right.split(':') : [];
const explicitGroups = leftGroups.length + rightGroups.length;
return (
explicitGroups < 8 &&
leftGroups.every(isIPv6ZeroGroup) &&
rightGroups.every(isIPv6ZeroGroup)
);
}
const groups = host.split(':');
return groups.length === 8 && groups.every(isIPv6ZeroGroup);
};
const isIPv6Loopback = (host) => { const isIPv6Loopback = (host) => {
// Collapse all-zero groups: any form of ::1 / 0:0:...:0:1 // Collapse all-zero groups: any form of ::1 / 0:0:...:0:1
// First, strip any leading "::" by normalising with Set lookup of common forms, // First, strip any leading "::" by normalising with Set lookup of common forms,
@@ -42,6 +73,7 @@ const isLoopback = (host) => {
if (!host) return false; if (!host) return false;
if (LOOPBACK_HOSTNAMES.has(host)) return true; if (LOOPBACK_HOSTNAMES.has(host)) return true;
if (isIPv4Loopback(host)) return true; if (isIPv4Loopback(host)) return true;
if (isIPv6Unspecified(host)) return true;
return isIPv6Loopback(host); return isIPv6Loopback(host);
}; };
+40 -10
View File
@@ -5,6 +5,10 @@ import AxiosError from '../core/AxiosError.js';
// temporary hotfix to avoid circular references until AxiosURLSearchParams is refactored // temporary hotfix to avoid circular references until AxiosURLSearchParams is refactored
import PlatformFormData from '../platform/node/classes/FormData.js'; import PlatformFormData from '../platform/node/classes/FormData.js';
// Default nesting limit shared with the inverse transform (formDataToJSON) so
// the FormData <-> JSON round-trip stays symmetric.
export const DEFAULT_FORM_DATA_MAX_DEPTH = 100;
/** /**
* Determines if the given thing is a array or js object. * Determines if the given thing is a array or js object.
* *
@@ -115,8 +119,9 @@ function toFormData(obj, formData, options) {
const dots = options.dots; const dots = options.dots;
const indexes = options.indexes; const indexes = options.indexes;
const _Blob = options.Blob || (typeof Blob !== 'undefined' && Blob); const _Blob = options.Blob || (typeof Blob !== 'undefined' && Blob);
const maxDepth = options.maxDepth === undefined ? 100 : options.maxDepth; const maxDepth = options.maxDepth === undefined ? DEFAULT_FORM_DATA_MAX_DEPTH : options.maxDepth;
const useBlob = _Blob && utils.isSpecCompliantForm(formData); const useBlob = _Blob && utils.isSpecCompliantForm(formData);
const stack = [];
if (!utils.isFunction(visitor)) { if (!utils.isFunction(visitor)) {
throw new TypeError('visitor must be a function'); throw new TypeError('visitor must be a function');
@@ -144,6 +149,38 @@ function toFormData(obj, formData, options) {
return value; return value;
} }
function throwIfMaxDepthExceeded(depth) {
if (depth > maxDepth) {
throw new AxiosError(
'Object is too deeply nested (' + depth + ' levels). Max depth: ' + maxDepth,
AxiosError.ERR_FORM_DATA_DEPTH_EXCEEDED
);
}
}
function stringifyWithDepthLimit(value, depth) {
if (maxDepth === Infinity) {
return JSON.stringify(value);
}
const ancestors = [];
return JSON.stringify(value, function limitDepth(_key, currentValue) {
if (!utils.isObject(currentValue)) {
return currentValue;
}
while (ancestors.length && ancestors[ancestors.length - 1] !== this) {
ancestors.pop();
}
ancestors.push(currentValue);
throwIfMaxDepthExceeded(depth + ancestors.length - 1);
return currentValue;
});
}
/** /**
* Default visitor. * Default visitor.
* *
@@ -167,7 +204,7 @@ function toFormData(obj, formData, options) {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
key = metaTokens ? key : key.slice(0, -2); key = metaTokens ? key : key.slice(0, -2);
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
value = JSON.stringify(value); value = stringifyWithDepthLimit(value, 1);
} else if ( } else if (
(utils.isArray(value) && isFlatArray(value)) || (utils.isArray(value) && isFlatArray(value)) ||
((utils.isFileList(value) || utils.endsWith(key, '[]')) && (arr = utils.toArray(value))) ((utils.isFileList(value) || utils.endsWith(key, '[]')) && (arr = utils.toArray(value)))
@@ -200,8 +237,6 @@ function toFormData(obj, formData, options) {
return false; return false;
} }
const stack = [];
const exposedHelpers = Object.assign(predicates, { const exposedHelpers = Object.assign(predicates, {
defaultVisitor, defaultVisitor,
convertValue, convertValue,
@@ -211,12 +246,7 @@ function toFormData(obj, formData, options) {
function build(value, path, depth = 0) { function build(value, path, depth = 0) {
if (utils.isUndefined(value)) return; if (utils.isUndefined(value)) return;
if (depth > maxDepth) { throwIfMaxDepthExceeded(depth);
throw new AxiosError(
'Object is too deeply nested (' + depth + ' levels). Max depth: ' + maxDepth,
AxiosError.ERR_FORM_DATA_DEPTH_EXCEEDED
);
}
if (stack.indexOf(value) !== -1) { if (stack.indexOf(value) !== -1) {
throw new Error('Circular reference detected in ' + path.join('.')); throw new Error('Circular reference detected in ' + path.join('.'));
+75 -11
View File
@@ -8,6 +8,57 @@ const { toString } = Object.prototype;
const { getPrototypeOf } = Object; const { getPrototypeOf } = Object;
const { iterator, toStringTag } = Symbol; const { iterator, toStringTag } = Symbol;
/* Creating a function that will check if an object has a property. */
const hasOwnProperty = (
({ hasOwnProperty }) =>
(obj, prop) =>
hasOwnProperty.call(obj, prop)
)(Object.prototype);
/**
* Walk the prototype chain (excluding the shared Object.prototype) looking for
* an own `prop`. This distinguishes genuine own/inherited members — including
* class accessors and template prototypes — from members injected via
* Object.prototype pollution (e.g. `Object.prototype.username = '...'`), which
* live on Object.prototype itself and are therefore never matched.
*
* @param {*} thing The value whose chain to inspect
* @param {string|symbol} prop The property key to look for
*
* @returns {boolean} True when `prop` is owned below Object.prototype
*/
const hasOwnInPrototypeChain = (thing, prop) => {
let obj = thing;
const seen = [];
while (obj != null && obj !== Object.prototype) {
if (seen.indexOf(obj) !== -1) {
return false;
}
seen.push(obj);
if (hasOwnProperty(obj, prop)) {
return true;
}
obj = getPrototypeOf(obj);
}
return false;
};
/**
* Read `obj[prop]` only when it is safe from Object.prototype pollution. Own
* properties and members inherited from a non-Object.prototype source (a class
* instance or template object) are honored; a value reachable only through a
* polluted Object.prototype is ignored and `undefined` is returned.
*
* @param {*} obj The source object
* @param {string|symbol} prop The property key to read
*
* @returns {*} The resolved value, or undefined when unsafe/absent
*/
const getSafeProp = (obj, prop) =>
obj != null && hasOwnInPrototypeChain(obj, prop) ? obj[prop] : undefined;
const kindOf = ((cache) => (thing) => { const kindOf = ((cache) => (thing) => {
const str = toString.call(thing); const str = toString.call(thing);
return cache[str] || (cache[str] = str.slice(8, -1).toLowerCase()); return cache[str] || (cache[str] = str.slice(8, -1).toLowerCase());
@@ -133,7 +184,7 @@ const isBoolean = (thing) => thing === true || thing === false;
* @returns {boolean} True if value is a plain Object, otherwise false * @returns {boolean} True if value is a plain Object, otherwise false
*/ */
const isPlainObject = (val) => { const isPlainObject = (val) => {
if (kindOf(val) !== 'object') { if (!isObject(val)) {
return false; return false;
} }
@@ -141,9 +192,12 @@ const isPlainObject = (val) => {
return ( return (
(prototype === null || (prototype === null ||
prototype === Object.prototype || prototype === Object.prototype ||
Object.getPrototypeOf(prototype) === null) && getPrototypeOf(prototype) === null) &&
!(toStringTag in val) && // Treat any genuine (non-Object.prototype-polluted) Symbol.toStringTag or
!(iterator in val) // Symbol.iterator as evidence the value is a tagged/iterable type rather
// than a plain object, while ignoring keys injected onto Object.prototype.
!hasOwnInPrototypeChain(val, toStringTag) &&
!hasOwnInPrototypeChain(val, iterator)
); );
}; };
@@ -670,13 +724,6 @@ const toCamelCase = (str) => {
}); });
}; };
/* Creating a function that will check if an object has a property. */
const hasOwnProperty = (
({ hasOwnProperty }) =>
(obj, prop) =>
hasOwnProperty.call(obj, prop)
)(Object.prototype);
const { propertyIsEnumerable } = Object.prototype; const { propertyIsEnumerable } = Object.prototype;
/** /**
@@ -890,6 +937,20 @@ const asap =
const isIterable = (thing) => thing != null && isFunction(thing[iterator]); const isIterable = (thing) => thing != null && isFunction(thing[iterator]);
/**
* Determine if a value is iterable via an iterator that is NOT sourced solely
* from a polluted Object.prototype. Use this instead of `isIterable` whenever
* the iterable comes from untrusted input (e.g. user-supplied header sources),
* so `Object.prototype[Symbol.iterator] = ...` cannot turn an ordinary object
* into an attacker-controlled entries iterator.
*
* @param {*} thing The value to test
*
* @returns {boolean} True if value has a non-polluted iterator
*/
const isSafeIterable = (thing) =>
thing != null && hasOwnInPrototypeChain(thing, iterator) && isIterable(thing);
export default { export default {
isArray, isArray,
isArrayBuffer, isArrayBuffer,
@@ -934,6 +995,8 @@ export default {
isHTMLForm, isHTMLForm,
hasOwnProperty, hasOwnProperty,
hasOwnProp: hasOwnProperty, // an alias to avoid ESLint no-prototype-builtins detection hasOwnProp: hasOwnProperty, // an alias to avoid ESLint no-prototype-builtins detection
hasOwnInPrototypeChain,
getSafeProp,
reduceDescriptors, reduceDescriptors,
freezeMethods, freezeMethods,
toObjectSet, toObjectSet,
@@ -950,4 +1013,5 @@ export default {
setImmediate: _setImmediate, setImmediate: _setImmediate,
asap, asap,
isIterable, isIterable,
isSafeIterable,
}; };
+24
View File
@@ -125,6 +125,30 @@ describe('basicAuth (vitest browser)', () => {
await flushSuccess(request, promise); await flushSuccess(request, promise);
}); });
it('should ignore inherited nested auth fields', async () => {
Object.defineProperty(Object.prototype, 'username', {
value: 'inherited-user',
configurable: true,
});
Object.defineProperty(Object.prototype, 'password', {
value: 'inherited-pass',
configurable: true,
});
try {
const { request, promise } = startRequest('/foo', {
auth: {},
});
expect(request.requestHeaders.Authorization).toBe('Basic Og==');
await flushSuccess(request, promise);
} finally {
delete Object.prototype.username;
delete Object.prototype.password;
}
});
it('should fail to encode HTTP Basic auth credentials with non-Latin1 characters in username', async () => { it('should fail to encode HTTP Basic auth credentials with non-Latin1 characters in username', async () => {
await expect(axios('/foo', { await expect(axios('/foo', {
auth: { auth: {
+22
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import axios from '../../index.js'; import axios from '../../index.js';
import AxiosError from '../../lib/core/AxiosError.js';
class MockXMLHttpRequest { class MockXMLHttpRequest {
constructor() { constructor() {
@@ -222,6 +223,27 @@ describe('requests (vitest browser)', () => {
expect(reason.request).toBeInstanceOf(MockXMLHttpRequest); expect(reason.request).toBeInstanceOf(MockXMLHttpRequest);
}); });
it('rejects malformed HTTP URLs before opening an XHR request', async () => {
const openSpy = vi.spyOn(MockXMLHttpRequest.prototype, 'open');
const reason = await axios
.get('\u0000https:example.com/users', {
adapter: 'xhr',
headers: {
'X-Test': 'yes',
},
})
.catch((error) => error);
expect(reason).toBeInstanceOf(AxiosError);
expect(reason.code).toBe(AxiosError.ERR_INVALID_URL);
expect(reason.message).toBe('Invalid URL: missing "//" after protocol');
expect(reason.config.url).toBe('\u0000https:example.com/users');
expect(reason.config.headers.get('X-Test')).toBe('yes');
expect(openSpy).not.toHaveBeenCalled();
expect(requests).toHaveLength(0);
});
it('should reject on abort', async () => { it('should reject on abort', async () => {
const { request, promise } = startRequest('/foo'); const { request, promise } = startRequest('/foo');
+327
View File
@@ -9,6 +9,7 @@ import {
makeEchoStream, makeEchoStream,
} from '../../setup/server.js'; } from '../../setup/server.js';
import axios from '../../../index.js'; import axios from '../../../index.js';
import AxiosError from '../../../lib/core/AxiosError.js';
import utils from '../../../lib/utils.js'; import utils from '../../../lib/utils.js';
import { getFetch } from '../../../lib/adapters/fetch.js'; import { getFetch } from '../../../lib/adapters/fetch.js';
import stream from 'stream'; import stream from 'stream';
@@ -51,6 +52,28 @@ const createBrokenDOMExceptionLikeError = () =>
); );
describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => { describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => {
it('rejects malformed HTTP URLs before fetch normalization and preserves config', async () => {
for (const url of ['\u0000https:example.com/users', 'h\nttp:example.com/users']) {
await assert.rejects(
() =>
axios.get(url, {
adapter: 'fetch',
headers: {
'X-Test': 'yes',
},
}),
(error) => {
assert.ok(error instanceof AxiosError);
assert.strictEqual(error.code, AxiosError.ERR_INVALID_URL);
assert.strictEqual(error.message, 'Invalid URL: missing "//" after protocol');
assert.strictEqual(error.config.url, url);
assert.strictEqual(error.config.headers.get('X-Test'), 'yes');
return true;
}
);
}
});
it('should sanitize request headers containing CRLF characters', async () => { it('should sanitize request headers containing CRLF characters', async () => {
const server = await startHTTPServer( const server = await startHTTPServer(
(req, res) => { (req, res) => {
@@ -81,6 +104,40 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
} }
}); });
it('should not use inherited Symbol.iterator for request headers', async () => {
const server = await startHTTPServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
authorization: req.headers.authorization,
xApp: req.headers['x-app'],
xInjected: req.headers['x-injected'] ?? null,
})
);
});
try {
Object.prototype[Symbol.iterator] = function* () {
yield ['X-Injected', 'yes'];
yield ['Authorization', 'Bearer CHANGED'];
};
const { data } = await fetchAxios.get(`http://localhost:${server.address().port}/`, {
headers: {
Authorization: 'Bearer VALID_USER_TOKEN',
'X-App': 'safe',
},
});
assert.strictEqual(data.authorization, 'Bearer VALID_USER_TOKEN');
assert.strictEqual(data.xApp, 'safe');
assert.strictEqual(data.xInjected, null);
} finally {
delete Object.prototype[Symbol.iterator];
await stopHTTPServer(server);
}
});
it('should allow request interceptors to encode Unicode header values before fetch sends them', async () => { it('should allow request interceptors to encode Unicode header values before fetch sends them', async () => {
const server = await startHTTPServer( const server = await startHTTPServer(
(req, res) => { (req, res) => {
@@ -591,6 +648,33 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
} }
}); });
it('should ignore inherited nested auth fields', async () => {
const server = await startHTTPServer((req, res) => res.end(req.headers.authorization), {
port: SERVER_PORT,
});
Object.defineProperty(Object.prototype, 'username', {
value: 'inherited-user',
configurable: true,
});
Object.defineProperty(Object.prototype, 'password', {
value: 'inherited-pass',
configurable: true,
});
try {
const response = await fetchAxios.get(`http://localhost:${server.address().port}/`, {
auth: {},
});
assert.strictEqual(response.data, 'Basic Og==');
} finally {
delete Object.prototype.username;
delete Object.prototype.password;
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 });
@@ -1132,6 +1216,23 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
}); });
describe('size limits', () => { describe('size limits', () => {
const makeUploadStream = (totalBytes, chunkSize = 512) => {
let remaining = totalBytes;
return new ReadableStream({
pull(controller) {
if (remaining <= 0) {
controller.close();
return;
}
const size = Math.min(chunkSize, remaining);
remaining -= size;
controller.enqueue(new Uint8Array(size));
},
});
};
it('should reject an outbound body that exceeds maxBodyLength with ERR_BAD_REQUEST', async () => { it('should reject an outbound body that exceeds maxBodyLength with ERR_BAD_REQUEST', async () => {
const server = await startHTTPServer( const server = await startHTTPServer(
(req, res) => { (req, res) => {
@@ -1156,6 +1257,164 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
} }
}); });
it('should reject a streamed outbound body that exceeds maxBodyLength during upload', async () => {
let bytesReceived = 0;
const server = await startHTTPServer(
(req, res) => {
req.on('data', (chunk) => {
bytesReceived += chunk.length;
});
req.on('error', () => {});
req.on('end', () => {
res.end('ok');
});
},
{ port: SERVER_PORT }
);
try {
await assert.rejects(
fetchAxios.post(`${LOCAL_SERVER_URL}/`, makeUploadStream(2048), {
maxBodyLength: 1024,
headers: { 'Content-Type': 'application/octet-stream' },
}),
(err) => {
assert.strictEqual(err.code, 'ERR_BAD_REQUEST');
assert.strictEqual(err.message, 'Request body larger than maxBodyLength limit');
return true;
}
);
assert.ok(
bytesReceived <= 1024,
`server should not receive more than maxBodyLength; got ${bytesReceived}`
);
} finally {
await stopHTTPServer(server);
}
});
it('should enforce maxBodyLength on a stream even when a smaller Content-Length is declared', async () => {
let bytesReceived = 0;
const server = await startHTTPServer(
(req, res) => {
req.on('data', (chunk) => {
bytesReceived += chunk.length;
});
req.on('error', () => {});
req.on('end', () => {
res.end('ok');
});
},
{ port: SERVER_PORT }
);
try {
await assert.rejects(
// A caller-declared Content-Length that under-reports the real body
// must not let an oversized stream slip past the limit.
fetchAxios.post(`${LOCAL_SERVER_URL}/`, makeUploadStream(8192), {
maxBodyLength: 1024,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': '500',
},
}),
(err) => {
assert.strictEqual(err.code, 'ERR_BAD_REQUEST');
assert.strictEqual(err.message, 'Request body larger than maxBodyLength limit');
return true;
}
);
assert.ok(
bytesReceived <= 1024,
`server should not receive more than maxBodyLength; got ${bytesReceived}`
);
} finally {
await stopHTTPServer(server);
}
});
it('should enforce maxBodyLength with custom fetch when Request is unavailable', async () => {
let bytesRead = 0;
await assert.rejects(
fetchAxios.post('/', makeUploadStream(2048), {
maxBodyLength: 1024,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': '1',
},
env: {
Request: null,
async fetch(_url, options) {
for await (const chunk of options.body) {
bytesRead += chunk.byteLength;
}
return {
headers: {},
status: 200,
statusText: 'OK',
text: async () => 'ok',
};
},
},
}),
(err) => {
assert.strictEqual(err.code, 'ERR_BAD_REQUEST');
assert.strictEqual(err.message, 'Request body larger than maxBodyLength limit');
return true;
}
);
assert.ok(bytesRead <= 1024, `custom fetch read too many bytes; got ${bytesRead}`);
});
it('should not force ReadableStream bodies when Request does not support request streams', async () => {
let fetchCalled = false;
class NoStreamRequest {
constructor(_url, init) {
if (init && utils.isReadableStream(init.body)) {
throw new TypeError('ReadableStream request bodies are unsupported');
}
}
}
await assert.rejects(
fetchAxios.post('/', stream.Readable.from([Buffer.alloc(2048)]), {
maxBodyLength: 1024,
headers: {
'Content-Type': 'application/octet-stream',
},
env: {
Request: NoStreamRequest,
Response: null,
async fetch() {
fetchCalled = true;
return {
headers: {},
status: 200,
statusText: 'OK',
text: async () => 'ok',
};
},
},
}),
(err) => {
assert.strictEqual(err.code, 'ERR_NOT_SUPPORT');
assert.strictEqual(
err.message,
'Stream request bodies are not supported by the current fetch implementation'
);
return true;
}
);
assert.strictEqual(fetchCalled, false, 'fetch must not receive a forced ReadableStream body');
});
it('should reject a response whose Content-Length exceeds maxContentLength with ERR_BAD_RESPONSE', async () => { it('should reject a response whose Content-Length exceeds maxContentLength with ERR_BAD_RESPONSE', async () => {
const payload = 'A'.repeat(8 * 1024); const payload = 'A'.repeat(8 * 1024);
const server = await startHTTPServer( const server = await startHTTPServer(
@@ -1182,6 +1441,33 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
} }
}); });
it('should handle plain object response headers while enforcing maxContentLength', async () => {
const { data, headers } = await fetchAxios.get('/', {
maxContentLength: 10,
env: {
async fetch() {
return {
status: 200,
statusText: 'OK',
headers: {
'content-length': '4',
foo: 'bar',
},
body: new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([116, 101, 115, 116]));
controller.close();
},
}),
};
},
},
});
assert.strictEqual(data, 'test');
assert.strictEqual(headers.get('foo'), 'bar');
});
it('should reject a chunked response that exceeds maxContentLength during streaming', async () => { it('should reject a chunked response that exceeds maxContentLength during streaming', async () => {
const server = await startHTTPServer( const server = await startHTTPServer(
(req, res) => { (req, res) => {
@@ -1246,6 +1532,15 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
}); });
}); });
it('should allow a percent-encoded data: URL within decoded maxContentLength', async () => {
const bareAxios = axios.create({ adapter: 'fetch' });
const { data } = await bareAxios.get('data:text/plain,%E2%82%AC', {
maxContentLength: 4,
});
assert.strictEqual(data, '\u20ac');
});
it('should allow a response at or below maxContentLength', async () => { it('should allow a response at or below maxContentLength', async () => {
const payload = 'ok'; const payload = 'ok';
const server = await startHTTPServer( const server = await startHTTPServer(
@@ -1265,6 +1560,38 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
} }
}); });
it('should allow a streamed outbound body at or below maxBodyLength', async () => {
const payloadLength = 1024;
let bytesReceived = 0;
const server = await startHTTPServer(
(req, res) => {
req.on('data', (chunk) => {
bytesReceived += chunk.length;
});
req.on('end', () => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ received: bytesReceived }));
});
},
{ port: SERVER_PORT }
);
try {
const { data } = await fetchAxios.post(
`${LOCAL_SERVER_URL}/`,
makeUploadStream(payloadLength),
{
maxBodyLength: 1024,
headers: { 'Content-Type': 'application/octet-stream' },
}
);
assert.strictEqual(data.received, payloadLength);
} finally {
await stopHTTPServer(server);
}
});
it('should allow a body at or below maxBodyLength', async () => { it('should allow a body at or below maxBodyLength', async () => {
const payload = 'hello'; const payload = 'hello';
let received; let received;
+278 -1
View File
@@ -10,7 +10,7 @@ import {
} from '../../setup/server.js'; } from '../../setup/server.js';
import axios from '../../../index.js'; import axios from '../../../index.js';
import AxiosError from '../../../lib/core/AxiosError.js'; import AxiosError from '../../../lib/core/AxiosError.js';
import { __isSameOriginRedirect, __setProxy } from '../../../lib/adapters/http.js'; import httpAdapter, { __isSameOriginRedirect, __setProxy } from '../../../lib/adapters/http.js';
import HttpsProxyAgent from 'https-proxy-agent'; import HttpsProxyAgent from 'https-proxy-agent';
import http from 'http'; import http from 'http';
import https from 'https'; import https from 'https';
@@ -1342,6 +1342,158 @@ describe('supports http with nodejs', () => {
} }
}); });
it('should ignore inherited nested request option fields in http adapter', async () => {
const server = await startHTTPServer(
(req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
authorization: req.headers.authorization,
url: req.url,
})
);
},
{ port: SERVER_PORT }
);
Object.defineProperty(Object.prototype, 'username', {
value: 'inherited-user',
configurable: true,
});
Object.defineProperty(Object.prototype, 'password', {
value: 'inherited-pass',
configurable: true,
});
Object.defineProperty(Object.prototype, 'serialize', {
value() {
return 'inherited=1';
},
configurable: true,
});
try {
const response = await axios.get(`http://localhost:${server.address().port}/demo`, {
auth: {},
params: { value: 'a b' },
paramsSerializer: {},
});
assert.deepStrictEqual(response.data, {
authorization: 'Basic Og==',
url: '/demo?value=a+b',
});
} finally {
delete Object.prototype.username;
delete Object.prototype.password;
delete Object.prototype.serialize;
await stopHTTPServer(server);
}
});
it('should ignore inherited proxy when http adapter receives a plain config', async () => {
const proxyEnvKeys = ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY'];
const originalProxyEnv = Object.create(null);
let proxy;
let target;
let proxyHits = 0;
let targetHits = 0;
for (const key of proxyEnvKeys) {
originalProxyEnv[key] = process.env[key];
delete process.env[key];
}
try {
proxy = await startHTTPServer((req, res) => {
proxyHits += 1;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ via: 'proxy', url: req.url }));
});
target = await startHTTPServer((req, res) => {
targetHits += 1;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ via: 'target', url: req.url }));
});
Object.defineProperty(Object.prototype, 'proxy', {
value: {
protocol: 'http',
host: '127.0.0.1',
port: proxy.address().port,
},
configurable: true,
});
const response = await httpAdapter({
method: 'get',
url: `http://127.0.0.1:${target.address().port}/direct`,
headers: {},
maxRedirects: 0,
maxContentLength: -1,
maxBodyLength: -1,
timeout: 0,
});
const data = JSON.parse(response.data);
assert.strictEqual(proxyHits, 0);
assert.strictEqual(targetHits, 1);
assert.deepStrictEqual(data, { via: 'target', url: '/direct' });
} finally {
delete Object.prototype.proxy;
for (const key of proxyEnvKeys) {
if (originalProxyEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = originalProxyEnv[key];
}
}
await stopHTTPServer(target);
await stopHTTPServer(proxy);
}
});
it('should ignore inherited paramsSerializer when http adapter receives a plain config', async () => {
let server;
let serializerInvoked = false;
Object.defineProperty(Object.prototype, 'paramsSerializer', {
value() {
serializerInvoked = true;
return 'inherited=1';
},
configurable: true,
});
try {
server = await startHTTPServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ url: req.url }));
});
const response = await httpAdapter({
method: 'get',
url: `http://127.0.0.1:${server.address().port}/direct`,
headers: {},
params: { value: 'a b' },
proxy: false,
maxRedirects: 0,
maxContentLength: -1,
maxBodyLength: -1,
timeout: 0,
});
const data = JSON.parse(response.data);
assert.strictEqual(serializerInvoked, false);
assert.deepStrictEqual(data, { url: '/direct?value=a+b' });
} finally {
delete Object.prototype.paramsSerializer;
await stopHTTPServer(server);
}
});
it('should preserve basic auth across same-origin 303 POST -> GET redirect', async () => { it('should preserve basic auth across same-origin 303 POST -> GET redirect', async () => {
const server = await startHTTPServer( const server = await startHTTPServer(
(req, res) => { (req, res) => {
@@ -3708,6 +3860,28 @@ describe('supports http with nodejs', () => {
}); });
}); });
it('rejects malformed HTTP URLs before Node URL normalization and preserves config', async () => {
for (const url of ['\u0000https:example.com/users', 'h\nttp:example.com/users']) {
await assert.rejects(
() =>
axios.get(url, {
adapter: 'http',
headers: {
'X-Test': 'yes',
},
}),
(error) => {
assert.ok(error instanceof AxiosError);
assert.strictEqual(error.code, AxiosError.ERR_INVALID_URL);
assert.strictEqual(error.message, 'Invalid URL: missing "//" after protocol');
assert.strictEqual(error.config.url, url);
assert.strictEqual(error.config.headers.get('X-Test'), 'yes');
return true;
}
);
}
});
it('should supply a user-agent if one is not specified', async () => { it('should supply a user-agent if one is not specified', async () => {
const server = await startHTTPServer( const server = await startHTTPServer(
(req, res) => { (req, res) => {
@@ -3942,6 +4116,60 @@ describe('supports http with nodejs', () => {
const pollutedKeys = ['getHeaders', 'append', 'pipe', 'on', 'once']; const pollutedKeys = ['getHeaders', 'append', 'pipe', 'on', 'once'];
const toStringTagSym = Symbol.toStringTag; const toStringTagSym = Symbol.toStringTag;
it('should not use inherited Symbol.iterator for request or response headers', async () => {
let capturedHeaders;
const stubTransport = {
request(options, handleResponse) {
capturedHeaders = { ...options.headers };
const req = new EventEmitter();
req.write = () => true;
req.setTimeout = () => {};
req.destroy = () => {};
req.end = () => {
const res = new stream.Readable({ read() {} });
res.statusCode = 200;
res.statusMessage = 'OK';
res.headers = { 'x-server': 'real' };
res.rawHeaders = [];
res.req = req;
process.nextTick(() => {
handleResponse(res);
res.push(null);
});
};
return req;
},
};
try {
Object.prototype[Symbol.iterator] = function* () {
yield ['X-Injected', 'yes'];
yield ['Authorization', 'Bearer CHANGED'];
};
const response = await axios.get('http://stub.invalid/', {
headers: {
Authorization: 'Bearer VALID_USER_TOKEN',
'X-App': 'safe',
},
transport: stubTransport,
maxRedirects: 0,
});
assert.ok(capturedHeaders, 'transport was not invoked');
assert.strictEqual(capturedHeaders['X-App'], 'safe');
assert.strictEqual(
capturedHeaders.Authorization || capturedHeaders.authorization,
'Bearer VALID_USER_TOKEN'
);
assert.strictEqual(capturedHeaders['X-Injected'] || capturedHeaders['x-injected'], undefined);
assert.strictEqual(response.headers.get('x-server'), 'real');
assert.strictEqual(response.headers.get('x-injected'), undefined);
} finally {
delete Object.prototype[Symbol.iterator];
}
});
function pollute() { function pollute() {
Object.prototype[toStringTagSym] = 'FormData'; Object.prototype[toStringTagSym] = 'FormData';
Object.prototype.append = () => {}; Object.prototype.append = () => {};
@@ -4971,6 +5199,55 @@ describe('supports http with nodejs', () => {
} }
}); });
it('should enforce maxBodyLength for HTTP/2 streamed uploads', async () => {
let bytesReceived = 0;
const server = await startHTTPServer(
(req, res) => {
req.on('data', (chunk) => {
bytesReceived += chunk.length;
});
req.on('error', () => {});
req.on('end', () => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ received: bytesReceived }));
});
},
{
useHTTP2: true,
port: SERVER_PORT,
}
);
try {
const localServerURL = `https://localhost:${server.address().port}`;
const http2Axios = createHttp2Axios(localServerURL);
const payload = Buffer.alloc(2 * 1024 * 1024, 0x63);
const source = stream.Readable.from([payload]);
await assert.rejects(
http2Axios.post(localServerURL, source, {
maxBodyLength: 1024,
headers: { 'Content-Type': 'application/octet-stream' },
}),
(error) => {
assert.strictEqual(error.message, 'Request body larger than maxBodyLength limit');
assert.strictEqual(error.code, AxiosError.ERR_BAD_REQUEST);
return true;
}
);
assert.ok(
bytesReceived <= 1024 * 4,
`server should not receive full payload; got ${bytesReceived}`
);
} finally {
if (server.closeAllSessions) {
server.closeAllSessions();
}
await stopHTTPServer(server);
}
});
it('should support FormData as a payload', async () => { it('should support FormData as a payload', async () => {
if (typeof FormData !== 'function') { if (typeof FormData !== 'function') {
return; return;
+60
View File
@@ -69,6 +69,66 @@ describe('static api', () => {
assert.strictEqual(typeof axios.getUri, 'function'); assert.strictEqual(typeof axios.getUri, 'function');
}); });
it('should ignore inherited data for bodyless method helpers', async () => {
Object.defineProperty(Object.prototype, 'data', {
value: 'inherited-body',
configurable: true,
});
try {
await Promise.all(
['delete', 'get', 'head', 'options'].map(async (method) => {
let seenData = 'unset';
await axios[method]('/test', {
adapter(config) {
seenData = config.data;
return Promise.resolve({
data: null,
status: 200,
statusText: 'OK',
headers: {},
config,
request: {},
});
},
});
assert.strictEqual(seenData, undefined);
})
);
} finally {
delete Object.prototype.data;
}
});
it('should ignore inherited nested serializer fields in getUri', () => {
let serializeInvoked = false;
Object.defineProperty(Object.prototype, 'serialize', {
value() {
serializeInvoked = true;
return 'inherited=1';
},
configurable: true,
});
try {
assert.strictEqual(
axios.getUri({
url: '/foo',
params: { value: 'a b' },
paramsSerializer: {},
}),
'/foo?value=a+b'
);
assert.strictEqual(serializeInvoked, false);
} finally {
delete Object.prototype.serialize;
}
});
it('should have isAxiosError properties', () => { it('should have isAxiosError properties', () => {
assert.strictEqual(typeof axios.isAxiosError, 'function'); assert.strictEqual(typeof axios.isAxiosError, 'function');
}); });
+98
View File
@@ -84,6 +84,104 @@ describe('AxiosHeaders', () => {
assert.strictEqual(headers.get('x'), '123'); assert.strictEqual(headers.get('x'), '123');
}); });
it('should not merge Object.prototype values into iterable headers', () => {
const descriptor = Object.getOwnPropertyDescriptor(Object.prototype, 'Authorization');
Object.prototype.Authorization = 'polluted';
try {
const headers = new AxiosHeaders(new Map([['Authorization', 'real']]));
assert.strictEqual(headers.get('authorization'), 'real');
} finally {
descriptor
? Object.defineProperty(Object.prototype, 'Authorization', descriptor)
: delete Object.prototype.Authorization;
}
});
it('should support objects with an own iterator as a key-value source object', () => {
const headers = new AxiosHeaders();
headers.set({
*[Symbol.iterator]() {
yield ['x', '123'];
},
});
assert.strictEqual(headers.get('x'), '123');
});
it('should not use inherited Symbol.iterator as a key-value source object', () => {
try {
Object.prototype[Symbol.iterator] = function* () {
yield ['x-app', 'changed'];
yield ['x-injected', 'yes'];
};
const headers = new AxiosHeaders({
'x-app': 'safe',
});
assert.strictEqual(headers.get('x-app'), 'safe');
assert.strictEqual(headers.get('x-injected'), undefined);
} finally {
delete Object.prototype[Symbol.iterator];
}
});
it('should not read polluted Object.prototype Symbol.iterator accessors', () => {
let accessed = false;
try {
Object.defineProperty(Object.prototype, Symbol.iterator, {
configurable: true,
get() {
accessed = true;
throw new Error('polluted iterator accessor');
}
});
const headers = new AxiosHeaders({
'x-app': 'safe',
});
assert.strictEqual(headers.get('x-app'), 'safe');
assert.strictEqual(accessed, false);
} finally {
delete Object.prototype[Symbol.iterator];
}
});
it('should not consume an inherited Symbol.iterator for non-plain header sources', () => {
try {
Object.prototype[Symbol.iterator] = function* () {
yield ['x-injected', 'yes'];
yield ['authorization', 'Bearer CHANGED'];
};
// A class instance and an Object.create(...) object both have a direct
// prototype other than Object.prototype, yet their only iterator comes
// from the polluted Object.prototype — they must not be iterated.
class HeaderBag {
constructor() {
this['authorization'] = 'Bearer VALID';
}
}
const fromClass = new AxiosHeaders(new HeaderBag());
assert.strictEqual(fromClass.get('x-injected'), undefined);
assert.notStrictEqual(fromClass.get('authorization'), 'Bearer CHANGED');
const created = Object.create({ 'x-app': 'safe' });
created['authorization'] = 'Bearer VALID';
const fromCreate = new AxiosHeaders(created);
assert.strictEqual(fromCreate.get('x-injected'), undefined);
assert.notStrictEqual(fromCreate.get('authorization'), 'Bearer CHANGED');
} finally {
delete Object.prototype[Symbol.iterator];
}
});
const runIfNode18OrHigher = nodeMajorVersion >= 18 ? it : it.skip; const runIfNode18OrHigher = nodeMajorVersion >= 18 ? it : it.skip;
runIfNode18OrHigher( runIfNode18OrHigher(
'should support setting multiple header values from an iterable source', 'should support setting multiple header values from an iterable source',
+45
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import buildFullPath from '../../../lib/core/buildFullPath.js'; import buildFullPath from '../../../lib/core/buildFullPath.js';
import AxiosError from '../../../lib/core/AxiosError.js';
describe('core::buildFullPath', () => { describe('core::buildFullPath', () => {
it('combines URLs when the requested URL is relative', () => { it('combines URLs when the requested URL is relative', () => {
@@ -31,4 +32,48 @@ describe('core::buildFullPath', () => {
it('combines URLs when baseURL and requested URL are both relative', () => { it('combines URLs when baseURL and requested URL are both relative', () => {
expect(buildFullPath('/api', '/users')).toBe('/api/users'); expect(buildFullPath('/api', '/users')).toBe('/api/users');
}); });
it('rejects HTTP URLs missing slashes after the protocol', () => {
for (const call of [
() => buildFullPath(undefined, 'https:example.com/users'),
() => buildFullPath(undefined, '\thttps:example.com/users'),
() => buildFullPath(undefined, '\u0000https:example.com/users'),
() => buildFullPath(undefined, 'h\nttp:example.com/users'),
() => buildFullPath(undefined, 'ht\ttp:example.com/users'),
() => buildFullPath(undefined, 'htt\rp:example.com/users'),
() => buildFullPath(undefined, 'http:/example.com/users'),
() => buildFullPath('http:example.com/api', '/users'),
]) {
let error;
try {
call();
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(AxiosError);
expect(error.code).toBe(AxiosError.ERR_INVALID_URL);
expect(error.message).toBe('Invalid URL: missing "//" after protocol');
}
});
it('does not reject an unused malformed baseURL for absolute requests', () => {
expect(buildFullPath('http:example.com/api', 'https://api.example.com/users')).toBe(
'https://api.example.com/users'
);
});
it('rejects a malformed baseURL when absolute requests are forced through baseURL', () => {
let error;
try {
buildFullPath('http:example.com/api', 'https://api.example.com/users', false);
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(AxiosError);
expect(error.code).toBe(AxiosError.ERR_INVALID_URL);
expect(error.message).toBe('Invalid URL: missing "//" after protocol');
});
}); });
+37
View File
@@ -31,6 +31,43 @@ function baseConfig(overrides = {}) {
} }
describe('core::dispatchRequest', () => { describe('core::dispatchRequest', () => {
describe('JSON FormData transform', () => {
it('rejects deeply nested field paths before adapter dispatch', async () => {
const data = new FormData();
let adapterCalled = false;
data.append('foo' + '[bar]'.repeat(101), '123');
const config = baseConfig({
data,
headers: { 'Content-Type': 'application/json' },
method: 'post',
adapter(adapterConfig) {
adapterCalled = true;
return Promise.resolve({
data: null,
status: 200,
statusText: 'OK',
headers: {},
config: adapterConfig,
request: {},
});
},
});
let thrown;
try {
await dispatchRequest(config);
} catch (e) {
thrown = e;
}
assert.ok(thrown instanceof AxiosError, 'must be AxiosError');
assert.strictEqual(thrown.code, AxiosError.ERR_FORM_DATA_DEPTH_EXCEEDED);
assert.strictEqual(adapterCalled, false);
});
});
describe('JSON parse failure on adapter resolution', () => { describe('JSON parse failure on adapter resolution', () => {
it('rejects with AxiosError carrying response and status', async () => { it('rejects with AxiosError carrying response and status', async () => {
const response = { const response = {
@@ -12,6 +12,16 @@ describe('estimateDataURLDecodedBytes', () => {
assert.strictEqual(estimateDataURLDecodedBytes(url), Buffer.byteLength('Hello', 'utf8')); assert.strictEqual(estimateDataURLDecodedBytes(url), Buffer.byteLength('Hello', 'utf8'));
}); });
it('should calculate decoded length for percent-encoded non-base64 data URL', () => {
const url = 'data:text/plain,%E2%82%AC';
assert.strictEqual(estimateDataURLDecodedBytes(url), Buffer.byteLength('\u20ac', 'utf8'));
});
it('should count percent-encoded ASCII as one decoded byte', () => {
const url = 'data:text/plain,hello%20world';
assert.strictEqual(estimateDataURLDecodedBytes(url), Buffer.byteLength('hello world', 'utf8'));
});
it('should calculate decoded length for base64 data URL', () => { it('should calculate decoded length for base64 data URL', () => {
const str = 'Hello'; const str = 'Hello';
const b64 = Buffer.from(str, 'utf8').toString('base64'); const b64 = Buffer.from(str, 'utf8').toString('base64');
+29
View File
@@ -123,6 +123,35 @@ describe('helpers::buildURL', () => {
expect(buildURL('/foo', params, customSerializer)).toEqual('/foo?rendered'); expect(buildURL('/foo', params, customSerializer)).toEqual('/foo?rendered');
}); });
it('should ignore inherited serializer options', () => {
let serializeInvoked = false;
let encodeInvoked = false;
Object.defineProperty(Object.prototype, 'serialize', {
value() {
serializeInvoked = true;
return 'inherited=1';
},
configurable: true,
});
Object.defineProperty(Object.prototype, 'encode', {
value() {
encodeInvoked = true;
return 'inherited';
},
configurable: true,
});
try {
expect(buildURL('/foo', { value: 'a b' }, {})).toEqual('/foo?value=a+b');
expect(serializeInvoked).toBe(false);
expect(encodeInvoked).toBe(false);
} finally {
delete Object.prototype.serialize;
delete Object.prototype.encode;
}
});
}); });
describe('helpers::encode', () => { describe('helpers::encode', () => {
+45
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import formDataToJSON from '../../../lib/helpers/formDataToJSON.js'; import formDataToJSON from '../../../lib/helpers/formDataToJSON.js';
import AxiosError from '../../../lib/core/AxiosError.js';
describe('formDataToJSON', () => { describe('formDataToJSON', () => {
it('should convert a FormData Object to JSON Object', () => { it('should convert a FormData Object to JSON Object', () => {
@@ -116,4 +117,48 @@ describe('formDataToJSON', () => {
delete Object.prototype.injected; delete Object.prototype.injected;
} }
}); });
it('should throw AxiosError when a field path exceeds the default depth limit', () => {
const formData = new FormData();
formData.append('foo' + '[bar]'.repeat(101), '123');
try {
formDataToJSON(formData);
throw new Error('Should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(AxiosError);
expect(err.code).toBe(AxiosError.ERR_FORM_DATA_DEPTH_EXCEEDED);
expect(err).not.toBeInstanceOf(RangeError);
}
});
it('should throw AxiosError while tokenizing very deep field paths', () => {
const formData = new FormData();
formData.append('foo' + '[bar]'.repeat(10000), '123');
try {
formDataToJSON(formData);
throw new Error('Should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(AxiosError);
expect(err.code).toBe(AxiosError.ERR_FORM_DATA_DEPTH_EXCEEDED);
expect(err).not.toBeInstanceOf(RangeError);
}
});
it('should convert a field path at the default depth limit', () => {
const formData = new FormData();
formData.append('foo' + '[bar]'.repeat(100), '123');
let value = formDataToJSON(formData).foo;
for (let i = 0; i < 100; i++) {
value = value.bar;
}
expect(value).toBe('123');
});
}); });
+58
View File
@@ -32,4 +32,62 @@ describe('helpers::resolveConfig', () => {
false false
); );
}); });
it('should ignore inherited nested auth fields', () => {
Object.defineProperty(Object.prototype, 'username', {
value: 'inherited-user',
configurable: true,
});
Object.defineProperty(Object.prototype, 'password', {
value: 'inherited-pass',
configurable: true,
});
try {
const config = resolveConfig({
url: '/foo',
auth: {},
});
assert.strictEqual(config.headers.get('Authorization'), 'Basic Og==');
} finally {
delete Object.prototype.username;
delete Object.prototype.password;
}
});
it('should ignore inherited nested serializer fields', () => {
let serializeInvoked = false;
let encodeInvoked = false;
Object.defineProperty(Object.prototype, 'serialize', {
value() {
serializeInvoked = true;
return 'inherited=1';
},
configurable: true,
});
Object.defineProperty(Object.prototype, 'encode', {
value() {
encodeInvoked = true;
return 'inherited';
},
configurable: true,
});
try {
const config = resolveConfig({
url: '/foo',
params: { value: 'a b' },
paramsSerializer: {},
});
assert.strictEqual(config.url, '/foo?value=a+b');
assert.strictEqual(serializeInvoked, false);
assert.strictEqual(encodeInvoked, false);
} finally {
delete Object.prototype.serialize;
delete Object.prototype.encode;
}
});
}); });
@@ -66,6 +66,66 @@ describe('helpers::shouldBypassProxy', () => {
expect(shouldBypassProxy('http://localhost:7777/')).toBe(true); expect(shouldBypassProxy('http://localhost:7777/')).toBe(true);
}); });
it('should bypass proxy for 0.0.0.0 when no_proxy contains a local entry', () => {
for (const entry of ['localhost', '127.0.0.1', '::1']) {
setNoProxy(entry);
expect(shouldBypassProxy('http://0.0.0.0:7777/')).toBe(true);
}
});
it('should respect explicit ports for 0.0.0.0 local matching', () => {
setNoProxy('localhost:8080');
expect(shouldBypassProxy('http://0.0.0.0:8080/')).toBe(true);
expect(shouldBypassProxy('http://0.0.0.0:9090/')).toBe(false);
});
it('should bypass proxy for the IPv6 unspecified address symmetrically with 0.0.0.0', () => {
for (const entry of ['localhost', '127.0.0.1', '::1']) {
setNoProxy(entry);
expect(shouldBypassProxy('http://[::]:7777/')).toBe(true);
expect(shouldBypassProxy('http://[0:0:0:0:0:0:0:0]:7777/')).toBe(true);
}
});
it('should bypass proxy for compressed IPv6 unspecified request forms', () => {
setNoProxy('localhost,127.0.0.1,::1');
for (const host of ['0::', '::0', '0:0::', '::0:0', '0::0']) {
expect(shouldBypassProxy(`http://[${host}]:7777/`)).toBe(true);
}
});
it('should bypass proxy for compressed IPv6 unspecified no_proxy entries', () => {
for (const entry of ['0::', '::0', '0:0::', '::0:0', '0::0']) {
setNoProxy(entry);
expect(shouldBypassProxy('http://[::]:7777/')).toBe(true);
expect(shouldBypassProxy('http://[0:0:0:0:0:0:0:0]:7777/')).toBe(true);
}
});
it('should respect explicit ports on compressed IPv6 unspecified no_proxy entries', () => {
setNoProxy('[0::]:8080');
expect(shouldBypassProxy('http://[::]:8080/')).toBe(true);
expect(shouldBypassProxy('http://[::]:9090/')).toBe(false);
});
it('should not treat nonzero compressed IPv6 addresses as unspecified', () => {
setNoProxy('0::2');
expect(shouldBypassProxy('http://[::]:7777/')).toBe(false);
});
it('should still route a real public IPv6 host through the proxy', () => {
setNoProxy('localhost');
expect(shouldBypassProxy('http://[2001:db8::1]:7777/')).toBe(false);
});
it('should match wildcard and explicit ports', () => { it('should match wildcard and explicit ports', () => {
setNoProxy('*.example.com,localhost:8080'); setNoProxy('*.example.com,localhost:8080');
+47
View File
@@ -190,6 +190,32 @@ describe('helpers::toFormData', () => {
assert.strictEqual(caught.code, 'ERR_FORM_DATA_DEPTH_EXCEEDED'); assert.strictEqual(caught.code, 'ERR_FORM_DATA_DEPTH_EXCEEDED');
assert.ok(!(caught instanceof RangeError)); assert.ok(!(caught instanceof RangeError));
}); });
it('should reject deeply nested {} metatoken values before JSON.stringify overflows', () => {
try {
toFormData({ 'evil{}': nest(10000) }, new FormData());
assert.fail('Should have thrown');
} catch (err) {
assert.ok(err instanceof AxiosError, 'error must be AxiosError, not RangeError');
assert.strictEqual(err.code, 'ERR_FORM_DATA_DEPTH_EXCEEDED');
assert.ok(!(err instanceof RangeError));
}
});
it('should allow {} metatoken values at the same boundary as normal top-level properties', () => {
const formData = toFormData({ 'safe{}': nest(99) }, new FormData());
assert.ok(formData instanceof FormData);
});
it('should reject {} metatoken values beyond the normal top-level property boundary', () => {
try {
toFormData({ 'evil{}': nest(100) }, new FormData());
assert.fail('Should have thrown');
} catch (err) {
assert.ok(err instanceof AxiosError);
assert.strictEqual(err.code, 'ERR_FORM_DATA_DEPTH_EXCEEDED');
}
});
}); });
describe('maxDepth — params serialization via AxiosURLSearchParams', () => { describe('maxDepth — params serialization via AxiosURLSearchParams', () => {
@@ -208,6 +234,27 @@ describe('helpers::toFormData', () => {
const qs = params.toString(); const qs = params.toString();
assert.ok(typeof qs === 'string' && qs.length > 0); assert.ok(typeof qs === 'string' && qs.length > 0);
}); });
it('should reject deeply nested {} metatoken params before JSON.stringify overflows', () => {
try {
new AxiosURLSearchParams({ 'evil{}': nest(10000) });
assert.fail('Should have thrown');
} catch (err) {
assert.ok(err instanceof AxiosError, 'error must be AxiosError, not RangeError');
assert.strictEqual(err.code, 'ERR_FORM_DATA_DEPTH_EXCEEDED');
assert.ok(!(err instanceof RangeError));
}
});
it('should reject {} metatoken params beyond the normal property boundary', () => {
try {
new AxiosURLSearchParams({ 'evil{}': nest(100) });
assert.fail('Should have thrown');
} catch (err) {
assert.ok(err instanceof AxiosError);
assert.strictEqual(err.code, 'ERR_FORM_DATA_DEPTH_EXCEEDED');
}
});
}); });
it('should NOT recurse into React Native blob properties', () => { it('should NOT recurse into React Native blob properties', () => {
+75
View File
@@ -58,6 +58,81 @@ describe('utils::isX', () => {
expect(utils.isPlainObject(Object.create({}))).toEqual(false); expect(utils.isPlainObject(Object.create({}))).toEqual(false);
}); });
it('should ignore inherited symbol properties when validating plain Object', () => {
try {
Object.prototype[Symbol.iterator] = function* () {
yield ['x-injected', 'yes'];
};
Object.prototype[Symbol.toStringTag] = 'Custom';
expect(utils.isPlainObject({})).toEqual(true);
expect(utils.isPlainObject([])).toEqual(false);
expect(
utils.isPlainObject({
[Symbol.iterator]: function* () {
yield ['x-own', 'yes'];
},
})
).toEqual(false);
expect(
utils.isPlainObject({
[Symbol.toStringTag]: 'Custom',
})
).toEqual(false);
} finally {
delete Object.prototype[Symbol.iterator];
delete Object.prototype[Symbol.toStringTag];
}
});
it('should treat an object with a genuinely inherited iterator as non-plain', () => {
// Iterator inherited from a custom (non-Object.prototype) source: this is a
// real iterable, not prototype pollution, so it must not be classified plain.
const proto = Object.create(null);
proto[Symbol.iterator] = function* () {
yield ['x', '1'];
};
expect(utils.isPlainObject(Object.create(proto))).toEqual(false);
});
it('should not read polluted Object.prototype iterator accessors for safe iterable checks', () => {
let accessed = false;
try {
Object.defineProperty(Object.prototype, Symbol.iterator, {
configurable: true,
get() {
accessed = true;
throw new Error('polluted iterator accessor');
}
});
expect(utils.isSafeIterable({})).toEqual(false);
expect(accessed).toEqual(false);
} finally {
delete Object.prototype[Symbol.iterator];
}
});
it('should stop safe prototype-chain reads on cyclic Proxy prototypes', () => {
let calls = 0;
let proxy;
proxy = new Proxy({}, {
getPrototypeOf() {
calls += 1;
if (calls > 5) {
throw new Error('cycled');
}
return proxy;
}
});
expect(utils.hasOwnInPrototypeChain(proxy, 'missing')).toEqual(false);
expect(utils.getSafeProp(proxy, 'missing')).toEqual(undefined);
expect(calls).toBeLessThanOrEqual(2);
});
it('should validate Date', () => { it('should validate Date', () => {
expect(utils.isDate(new Date())).toEqual(true); expect(utils.isDate(new Date())).toEqual(true);
expect(utils.isDate(Date.now())).toEqual(false); expect(utils.isDate(Date.now())).toEqual(false);