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:
@@ -8,6 +8,7 @@
|
||||
|
||||
## 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**)
|
||||
- **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**)
|
||||
|
||||
|
||||
@@ -20,6 +20,16 @@ Do not store raw diffs or line-number-only instructions here; prefer stable sect
|
||||
|
||||
## 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
|
||||
|
||||
- **Change:** Document the Node.js `sensitiveHeaders` request config option for stripping custom secret headers from cross-origin redirects.
|
||||
|
||||
+113
-37
@@ -234,14 +234,28 @@ const factory = (env) => {
|
||||
|
||||
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 {
|
||||
// HTTP basic authentication
|
||||
let auth = undefined;
|
||||
const configAuth = own('auth');
|
||||
|
||||
if (configAuth) {
|
||||
const username = configAuth.username || '';
|
||||
const password = configAuth.password || '';
|
||||
const username = utils.getSafeProp(configAuth, 'username') || '';
|
||||
const password = utils.getSafeProp(configAuth, 'password') || '';
|
||||
auth = {
|
||||
username,
|
||||
password
|
||||
@@ -290,53 +304,96 @@ const factory = (env) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce maxBodyLength against the outbound request body before dispatch.
|
||||
// Mirrors http.js behavior (ERR_BAD_REQUEST / 'Request body larger than
|
||||
// maxBodyLength limit'). Skip when the body length cannot be determined
|
||||
// (e.g. a live ReadableStream supplied by the caller).
|
||||
// Enforce maxBodyLength against known-size bodies before dispatch using
|
||||
// the body's *actual* size — never a caller-declared Content-Length,
|
||||
// which could under-report to slip an oversized body past the check.
|
||||
// Unknown-size streams return undefined here and are counted per-chunk
|
||||
// below as fetch consumes them.
|
||||
if (hasMaxBodyLength && method !== 'get' && method !== 'head') {
|
||||
const outboundLength = await resolveBodyLength(headers, data);
|
||||
if (
|
||||
typeof outboundLength === 'number' &&
|
||||
isFinite(outboundLength) &&
|
||||
outboundLength > maxBodyLength
|
||||
) {
|
||||
throw new AxiosError(
|
||||
'Request body larger than maxBodyLength limit',
|
||||
AxiosError.ERR_BAD_REQUEST,
|
||||
config,
|
||||
request
|
||||
);
|
||||
const outboundLength = await getBodyLength(data);
|
||||
if (typeof outboundLength === 'number' && isFinite(outboundLength)) {
|
||||
requestContentLength = outboundLength;
|
||||
if (outboundLength > maxBodyLength) {
|
||||
throw maxBodyLengthError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (
|
||||
onUploadProgress &&
|
||||
supportsRequestStream &&
|
||||
method !== 'get' &&
|
||||
method !== 'head' &&
|
||||
(requestContentLength = await resolveBodyLength(headers, data)) !== 0
|
||||
(onUploadProgress || mustEnforceStreamBody)
|
||||
) {
|
||||
let _request = new Request(url, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
duplex: 'half',
|
||||
});
|
||||
requestContentLength =
|
||||
requestContentLength == null ? await resolveBodyLength(headers, data) : requestContentLength;
|
||||
|
||||
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'))) {
|
||||
headers.setContentType(contentTypeHeader);
|
||||
}
|
||||
let contentTypeHeader;
|
||||
|
||||
if (_request.body) {
|
||||
const [onProgress, flush] = progressEventDecorator(
|
||||
requestContentLength,
|
||||
progressEventReducer(asyncDecorator(onUploadProgress))
|
||||
);
|
||||
if (utils.isFormData(data) && (contentTypeHeader = _request.headers.get('content-type'))) {
|
||||
headers.setContentType(contentTypeHeader);
|
||||
}
|
||||
|
||||
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)) {
|
||||
@@ -379,10 +436,12 @@ const factory = (env) => {
|
||||
? _fetch(request, fetchOptions)
|
||||
: _fetch(url, resolvedOptions));
|
||||
|
||||
const responseHeaders = AxiosHeaders.from(response.headers);
|
||||
|
||||
// Cheap pre-check: if the server honestly declares a content-length that
|
||||
// already exceeds the cap, reject before we start streaming.
|
||||
if (hasMaxContentLength) {
|
||||
const declaredLength = utils.toFiniteNumber(response.headers.get('content-length'));
|
||||
const declaredLength = utils.toFiniteNumber(responseHeaders.getContentLength());
|
||||
if (declaredLength != null && declaredLength > maxContentLength) {
|
||||
throw new AxiosError(
|
||||
'maxContentLength size of ' + maxContentLength + ' exceeded',
|
||||
@@ -407,7 +466,7 @@ const factory = (env) => {
|
||||
options[prop] = response[prop];
|
||||
});
|
||||
|
||||
const responseContentLength = utils.toFiniteNumber(response.headers.get('content-length'));
|
||||
const responseContentLength = utils.toFiniteNumber(responseHeaders.getContentLength());
|
||||
|
||||
const [onProgress, flush] =
|
||||
(onDownloadProgress &&
|
||||
@@ -502,6 +561,23 @@ const factory = (env) => {
|
||||
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)) {
|
||||
throw Object.assign(
|
||||
new AxiosError(
|
||||
|
||||
+61
-41
@@ -318,7 +318,7 @@ function setProxy(options, configProxy, location, isRedirect, configHttpsAgent)
|
||||
}
|
||||
const tunnelingAgent = getTunnelingAgent(agentOptions, configHttpsAgent);
|
||||
// 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`
|
||||
// is present.
|
||||
options.agent = tunnelingAgent;
|
||||
@@ -462,7 +462,12 @@ const http2Transport = {
|
||||
export default isHttpAdapterSupported &&
|
||||
function httpAdapter(config) {
|
||||
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;
|
||||
let data = own('data');
|
||||
let lookup = own('lookup');
|
||||
@@ -472,7 +477,13 @@ export default isHttpAdapterSupported &&
|
||||
let http2Options = own('http2Options');
|
||||
const responseType = own('responseType');
|
||||
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 rejected = false;
|
||||
let req;
|
||||
@@ -529,11 +540,13 @@ export default isHttpAdapterSupported &&
|
||||
}
|
||||
|
||||
function createTimeoutError() {
|
||||
let timeoutErrorMessage = config.timeout
|
||||
? 'timeout of ' + config.timeout + 'ms exceeded'
|
||||
const configTimeout = own('timeout');
|
||||
let timeoutErrorMessage = configTimeout
|
||||
? 'timeout of ' + configTimeout + 'ms exceeded'
|
||||
: 'timeout exceeded';
|
||||
if (config.timeoutErrorMessage) {
|
||||
timeoutErrorMessage = config.timeoutErrorMessage;
|
||||
const configTimeoutErrorMessage = own('timeoutErrorMessage');
|
||||
if (configTimeoutErrorMessage) {
|
||||
timeoutErrorMessage = configTimeoutErrorMessage;
|
||||
}
|
||||
return new AxiosError(
|
||||
timeoutErrorMessage,
|
||||
@@ -589,21 +602,21 @@ export default isHttpAdapterSupported &&
|
||||
});
|
||||
|
||||
// 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 protocol = parsed.protocol || supportedProtocols[0];
|
||||
|
||||
if (protocol === 'data:') {
|
||||
// Apply the same semantics as HTTP: only enforce if a finite, non-negative cap is set.
|
||||
if (config.maxContentLength > -1) {
|
||||
// Use the exact string passed to fromDataURI (config.url); fall back to fullPath if needed.
|
||||
const dataUrl = String(config.url || fullPath || '');
|
||||
if (maxContentLength > -1) {
|
||||
// Use the exact string passed to fromDataURI (the configured url); fall back to fullPath if needed.
|
||||
const dataUrl = String(own('url') || fullPath || '');
|
||||
const estimated = estimateDataURLDecodedBytes(dataUrl);
|
||||
|
||||
if (estimated > config.maxContentLength) {
|
||||
if (estimated > maxContentLength) {
|
||||
return reject(
|
||||
new AxiosError(
|
||||
'maxContentLength size of ' + config.maxContentLength + ' exceeded',
|
||||
'maxContentLength size of ' + maxContentLength + ' exceeded',
|
||||
AxiosError.ERR_BAD_RESPONSE,
|
||||
config
|
||||
)
|
||||
@@ -623,7 +636,7 @@ export default isHttpAdapterSupported &&
|
||||
}
|
||||
|
||||
try {
|
||||
convertedData = fromDataURI(config.url, responseType === 'blob', {
|
||||
convertedData = fromDataURI(own('url'), responseType === 'blob', {
|
||||
Blob: config.env && config.env.Blob,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -723,7 +736,7 @@ export default isHttpAdapterSupported &&
|
||||
// Add Content-Length header if data exists
|
||||
headers.setContentLength(data.length, false);
|
||||
|
||||
if (config.maxBodyLength > -1 && data.length > config.maxBodyLength) {
|
||||
if (maxBodyLength > -1 && data.length > maxBodyLength) {
|
||||
return reject(
|
||||
new AxiosError(
|
||||
'Request body larger than maxBodyLength limit',
|
||||
@@ -775,8 +788,8 @@ export default isHttpAdapterSupported &&
|
||||
let auth = undefined;
|
||||
const configAuth = own('auth');
|
||||
if (configAuth) {
|
||||
const username = configAuth.username || '';
|
||||
const password = configAuth.password || '';
|
||||
const username = utils.getSafeProp(configAuth, 'username') || '';
|
||||
const password = utils.getSafeProp(configAuth, 'password') || '';
|
||||
auth = username + ':' + password;
|
||||
}
|
||||
|
||||
@@ -793,13 +806,13 @@ export default isHttpAdapterSupported &&
|
||||
try {
|
||||
path = buildURL(
|
||||
parsed.pathname + parsed.search,
|
||||
config.params,
|
||||
config.paramsSerializer
|
||||
own('params'),
|
||||
own('paramsSerializer')
|
||||
).replace(/^\?/, '');
|
||||
} catch (err) {
|
||||
const customErr = new Error(err.message);
|
||||
customErr.config = config;
|
||||
customErr.url = config.url;
|
||||
customErr.url = own('url');
|
||||
customErr.exists = true;
|
||||
return reject(customErr);
|
||||
}
|
||||
@@ -817,7 +830,7 @@ export default isHttpAdapterSupported &&
|
||||
path,
|
||||
method: method,
|
||||
headers: toByteStringHeaderObject(headers),
|
||||
agents: { http: config.httpAgent, https: config.httpsAgent },
|
||||
agents: { http: httpAgent, https: httpsAgent },
|
||||
auth,
|
||||
protocol,
|
||||
family,
|
||||
@@ -867,19 +880,24 @@ export default isHttpAdapterSupported &&
|
||||
options.port = parsed.port;
|
||||
setProxy(
|
||||
options,
|
||||
config.proxy,
|
||||
own('proxy'),
|
||||
protocol + '//' + parsed.hostname + (parsed.port ? ':' + parsed.port : '') + options.path,
|
||||
false,
|
||||
config.httpsAgent
|
||||
httpsAgent
|
||||
);
|
||||
}
|
||||
let transport;
|
||||
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);
|
||||
// Don't clobber a CONNECT-tunneling agent installed by setProxy() for an
|
||||
// HTTPS target.
|
||||
if (options.agent == null) {
|
||||
options.agent = isHttpsRequest ? config.httpsAgent : config.httpAgent;
|
||||
options.agent = isHttpsRequest ? httpsAgent : httpAgent;
|
||||
}
|
||||
|
||||
if (isHttp2) {
|
||||
@@ -888,13 +906,14 @@ export default isHttpAdapterSupported &&
|
||||
const configTransport = own('transport');
|
||||
if (configTransport) {
|
||||
transport = configTransport;
|
||||
} else if (config.maxRedirects === 0) {
|
||||
} else if (maxRedirects === 0) {
|
||||
transport = isHttpsRequest ? https : http;
|
||||
isNativeTransport = true;
|
||||
} else {
|
||||
transportEnforcesMaxBodyLength = true;
|
||||
options.sensitiveHeaders = [];
|
||||
if (config.maxRedirects) {
|
||||
options.maxRedirects = config.maxRedirects;
|
||||
if (maxRedirects) {
|
||||
options.maxRedirects = maxRedirects;
|
||||
}
|
||||
const configBeforeRedirect = own('beforeRedirect');
|
||||
if (configBeforeRedirect) {
|
||||
@@ -960,8 +979,8 @@ export default isHttpAdapterSupported &&
|
||||
}
|
||||
}
|
||||
|
||||
if (config.maxBodyLength > -1) {
|
||||
options.maxBodyLength = config.maxBodyLength;
|
||||
if (maxBodyLength > -1) {
|
||||
options.maxBodyLength = maxBodyLength;
|
||||
} else {
|
||||
// follow-redirects does not skip comparison, so it should always succeed for axios -1 unlimited
|
||||
options.maxBodyLength = Infinity;
|
||||
@@ -1009,7 +1028,7 @@ export default isHttpAdapterSupported &&
|
||||
const lastRequest = res.req || req;
|
||||
|
||||
// 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,
|
||||
// remove the header not confuse downstream operations
|
||||
if (method === 'HEAD' || res.statusCode === 204) {
|
||||
@@ -1065,8 +1084,8 @@ export default isHttpAdapterSupported &&
|
||||
if (responseType === 'stream') {
|
||||
// Enforce maxContentLength on streamed responses; previously this
|
||||
// was applied only to buffered responses.
|
||||
if (config.maxContentLength > -1) {
|
||||
const limit = config.maxContentLength;
|
||||
if (maxContentLength > -1) {
|
||||
const limit = maxContentLength;
|
||||
const source = responseStream;
|
||||
async function* enforceMaxContentLength() {
|
||||
let totalResponseBytes = 0;
|
||||
@@ -1098,13 +1117,13 @@ export default isHttpAdapterSupported &&
|
||||
totalResponseBytes += chunk.length;
|
||||
|
||||
// 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
|
||||
rejected = true;
|
||||
responseStream.destroy();
|
||||
abort(
|
||||
new AxiosError(
|
||||
'maxContentLength size of ' + config.maxContentLength + ' exceeded',
|
||||
'maxContentLength size of ' + maxContentLength + ' exceeded',
|
||||
AxiosError.ERR_BAD_RESPONSE,
|
||||
config,
|
||||
lastRequest
|
||||
@@ -1219,9 +1238,9 @@ export default isHttpAdapterSupported &&
|
||||
});
|
||||
|
||||
// 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.
|
||||
const timeout = parseInt(config.timeout, 10);
|
||||
const timeout = parseInt(own('timeout'), 10);
|
||||
|
||||
if (Number.isNaN(timeout)) {
|
||||
abort(
|
||||
@@ -1279,12 +1298,13 @@ export default isHttpAdapterSupported &&
|
||||
}
|
||||
});
|
||||
|
||||
// Enforce maxBodyLength for streamed uploads on the native http/https
|
||||
// transport (maxRedirects === 0); follow-redirects enforces it on the
|
||||
// other path.
|
||||
// Enforce maxBodyLength for streamed uploads on every transport that
|
||||
// does not apply options.maxBodyLength itself (native http/https, http2,
|
||||
// and user-supplied custom transports). The follow-redirects transport
|
||||
// enforces it on the redirected HTTP/1 path.
|
||||
let uploadStream = data;
|
||||
if (config.maxBodyLength > -1 && config.maxRedirects === 0) {
|
||||
const limit = config.maxBodyLength;
|
||||
if (maxBodyLength > -1 && !transportEnforcesMaxBodyLength) {
|
||||
const limit = maxBodyLength;
|
||||
let bytesSent = 0;
|
||||
uploadStream = stream.pipeline(
|
||||
[
|
||||
|
||||
+2
-2
@@ -234,7 +234,7 @@ class Axios {
|
||||
|
||||
getUri(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);
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,7 @@ utils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData
|
||||
mergeConfig(config || {}, {
|
||||
method,
|
||||
url,
|
||||
data: (config || {}).data,
|
||||
data: config && utils.hasOwnProp(config, 'data') ? config.data : undefined,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@@ -111,8 +111,8 @@ class AxiosHeaders {
|
||||
setHeaders(header, valueOrRewrite);
|
||||
} else if (utils.isString(header) && (header = header.trim()) && !isValidHeaderName(header)) {
|
||||
setHeaders(parseHeaders(header), valueOrRewrite);
|
||||
} else if (utils.isObject(header) && utils.isIterable(header)) {
|
||||
let obj = {},
|
||||
} else if (utils.isObject(header) && utils.isSafeIterable(header)) {
|
||||
let obj = Object.create(null),
|
||||
dest,
|
||||
key;
|
||||
for (const entry of header) {
|
||||
@@ -120,11 +120,14 @@ class AxiosHeaders {
|
||||
throw new TypeError('Object iterator must return a key-value pair');
|
||||
}
|
||||
|
||||
obj[(key = entry[0])] = (dest = obj[key])
|
||||
? utils.isArray(dest)
|
||||
? [...dest, entry[1]]
|
||||
: [dest, entry[1]]
|
||||
: entry[1];
|
||||
key = entry[0];
|
||||
|
||||
if (utils.hasOwnProp(obj, key)) {
|
||||
dest = obj[key];
|
||||
obj[key] = utils.isArray(dest) ? [...dest, entry[1]] : [dest, entry[1]];
|
||||
} else {
|
||||
obj[key] = entry[1];
|
||||
}
|
||||
}
|
||||
|
||||
setHeaders(obj, valueOrRewrite);
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
'use strict';
|
||||
|
||||
import AxiosError from './AxiosError.js';
|
||||
import isAbsoluteURL from '../helpers/isAbsoluteURL.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,
|
||||
* 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
|
||||
*/
|
||||
export default function buildFullPath(baseURL, requestedURL, allowAbsoluteUrls) {
|
||||
export default function buildFullPath(baseURL, requestedURL, allowAbsoluteUrls, config) {
|
||||
assertValidHttpProtocolURL(requestedURL, config);
|
||||
let isRelativeUrl = !isAbsoluteURL(requestedURL);
|
||||
if (baseURL && (isRelativeUrl || allowAbsoluteUrls === false)) {
|
||||
assertValidHttpProtocolURL(baseURL, config);
|
||||
return combineURLs(baseURL, requestedURL);
|
||||
}
|
||||
return requestedURL;
|
||||
|
||||
@@ -33,15 +33,17 @@ export default function buildURL(url, params, options) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const _encode = (options && options.encode) || encode;
|
||||
|
||||
const _options = utils.isFunction(options)
|
||||
? {
|
||||
serialize: 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;
|
||||
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
* Estimate decoded byte length of a data:// URL *without* allocating large buffers.
|
||||
* - For base64: compute exact decoded size using length and padding;
|
||||
* 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
|
||||
* @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) {
|
||||
if (!url || typeof url !== 'string') 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) {
|
||||
const a = body.charCodeAt(i + 1);
|
||||
const b = body.charCodeAt(i + 2);
|
||||
const isHex =
|
||||
((a >= 48 && a <= 57) || (a >= 65 && a <= 70) || (a >= 97 && a <= 102)) &&
|
||||
((b >= 48 && b <= 57) || (b >= 65 && b <= 70) || (b >= 97 && b <= 102));
|
||||
const isHex = isHexDigit(a) && isHexDigit(b);
|
||||
|
||||
if (isHex) {
|
||||
effectiveLen -= 2;
|
||||
@@ -69,18 +75,17 @@ export default function estimateDataURLDecodedBytes(url) {
|
||||
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
|
||||
// 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
|
||||
// but 3 UTF-8 bytes).
|
||||
// Valid %XX triplets count as one decoded byte; this matches the bytes that
|
||||
// decodeURIComponent(body) would produce before Buffer re-encodes the string.
|
||||
let bytes = 0;
|
||||
for (let i = 0, len = body.length; i < len; 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;
|
||||
} else if (c < 0x800) {
|
||||
bytes += 2;
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
'use strict';
|
||||
|
||||
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']
|
||||
@@ -14,9 +27,16 @@ function parsePropPath(name) {
|
||||
// foo.x.y.z
|
||||
// foo-x-y-z
|
||||
// foo x y z
|
||||
return utils.matchAll(/\w+|\[(\w*)]/g, name).map((match) => {
|
||||
return match[0] === '[]' ? '' : match[1] || match[0];
|
||||
});
|
||||
const path = [];
|
||||
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 buildPath(path, value, target, index) {
|
||||
throwIfDepthExceeded(index);
|
||||
|
||||
let name = path[index++];
|
||||
|
||||
if (name === '__proto__') return true;
|
||||
|
||||
@@ -55,17 +55,19 @@ function resolveConfig(config) {
|
||||
newConfig.headers = headers = AxiosHeaders.from(headers);
|
||||
|
||||
newConfig.url = buildURL(
|
||||
buildFullPath(baseURL, url, allowAbsoluteUrls),
|
||||
buildFullPath(baseURL, url, allowAbsoluteUrls, newConfig),
|
||||
own('params'),
|
||||
own('paramsSerializer')
|
||||
);
|
||||
|
||||
// HTTP basic authentication
|
||||
if (auth) {
|
||||
const username = utils.getSafeProp(auth, 'username') || '';
|
||||
const password = utils.getSafeProp(auth, 'password') || '';
|
||||
|
||||
headers.set(
|
||||
'Authorization',
|
||||
'Basic ' +
|
||||
btoa((auth.username || '') + ':' + (auth.password ? encodeUTF8(auth.password) : ''))
|
||||
'Basic ' + btoa(username + ':' + (password ? encodeUTF8(password) : ''))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const LOOPBACK_HOSTNAMES = new Set(['localhost']);
|
||||
const LOOPBACK_HOSTNAMES = new Set(['localhost', '0.0.0.0']);
|
||||
|
||||
const isIPv4Loopback = (host) => {
|
||||
const parts = host.split('.');
|
||||
@@ -7,6 +7,37 @@ const isIPv4Loopback = (host) => {
|
||||
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) => {
|
||||
// Collapse all-zero groups: any form of ::1 / 0:0:...:0:1
|
||||
// First, strip any leading "::" by normalising with Set lookup of common forms,
|
||||
@@ -42,6 +73,7 @@ const isLoopback = (host) => {
|
||||
if (!host) return false;
|
||||
if (LOOPBACK_HOSTNAMES.has(host)) return true;
|
||||
if (isIPv4Loopback(host)) return true;
|
||||
if (isIPv6Unspecified(host)) return true;
|
||||
return isIPv6Loopback(host);
|
||||
};
|
||||
|
||||
|
||||
+40
-10
@@ -5,6 +5,10 @@ import AxiosError from '../core/AxiosError.js';
|
||||
// temporary hotfix to avoid circular references until AxiosURLSearchParams is refactored
|
||||
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.
|
||||
*
|
||||
@@ -115,8 +119,9 @@ function toFormData(obj, formData, options) {
|
||||
const dots = options.dots;
|
||||
const indexes = options.indexes;
|
||||
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 stack = [];
|
||||
|
||||
if (!utils.isFunction(visitor)) {
|
||||
throw new TypeError('visitor must be a function');
|
||||
@@ -144,6 +149,38 @@ function toFormData(obj, formData, options) {
|
||||
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.
|
||||
*
|
||||
@@ -167,7 +204,7 @@ function toFormData(obj, formData, options) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
key = metaTokens ? key : key.slice(0, -2);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
value = JSON.stringify(value);
|
||||
value = stringifyWithDepthLimit(value, 1);
|
||||
} else if (
|
||||
(utils.isArray(value) && isFlatArray(value)) ||
|
||||
((utils.isFileList(value) || utils.endsWith(key, '[]')) && (arr = utils.toArray(value)))
|
||||
@@ -200,8 +237,6 @@ function toFormData(obj, formData, options) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stack = [];
|
||||
|
||||
const exposedHelpers = Object.assign(predicates, {
|
||||
defaultVisitor,
|
||||
convertValue,
|
||||
@@ -211,12 +246,7 @@ function toFormData(obj, formData, options) {
|
||||
function build(value, path, depth = 0) {
|
||||
if (utils.isUndefined(value)) return;
|
||||
|
||||
if (depth > maxDepth) {
|
||||
throw new AxiosError(
|
||||
'Object is too deeply nested (' + depth + ' levels). Max depth: ' + maxDepth,
|
||||
AxiosError.ERR_FORM_DATA_DEPTH_EXCEEDED
|
||||
);
|
||||
}
|
||||
throwIfMaxDepthExceeded(depth);
|
||||
|
||||
if (stack.indexOf(value) !== -1) {
|
||||
throw new Error('Circular reference detected in ' + path.join('.'));
|
||||
|
||||
+75
-11
@@ -8,6 +8,57 @@ const { toString } = Object.prototype;
|
||||
const { getPrototypeOf } = Object;
|
||||
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 str = toString.call(thing);
|
||||
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
|
||||
*/
|
||||
const isPlainObject = (val) => {
|
||||
if (kindOf(val) !== 'object') {
|
||||
if (!isObject(val)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -141,9 +192,12 @@ const isPlainObject = (val) => {
|
||||
return (
|
||||
(prototype === null ||
|
||||
prototype === Object.prototype ||
|
||||
Object.getPrototypeOf(prototype) === null) &&
|
||||
!(toStringTag in val) &&
|
||||
!(iterator in val)
|
||||
getPrototypeOf(prototype) === null) &&
|
||||
// Treat any genuine (non-Object.prototype-polluted) Symbol.toStringTag or
|
||||
// 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;
|
||||
|
||||
/**
|
||||
@@ -890,6 +937,20 @@ const asap =
|
||||
|
||||
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 {
|
||||
isArray,
|
||||
isArrayBuffer,
|
||||
@@ -934,6 +995,8 @@ export default {
|
||||
isHTMLForm,
|
||||
hasOwnProperty,
|
||||
hasOwnProp: hasOwnProperty, // an alias to avoid ESLint no-prototype-builtins detection
|
||||
hasOwnInPrototypeChain,
|
||||
getSafeProp,
|
||||
reduceDescriptors,
|
||||
freezeMethods,
|
||||
toObjectSet,
|
||||
@@ -950,4 +1013,5 @@ export default {
|
||||
setImmediate: _setImmediate,
|
||||
asap,
|
||||
isIterable,
|
||||
isSafeIterable,
|
||||
};
|
||||
|
||||
@@ -125,6 +125,30 @@ describe('basicAuth (vitest browser)', () => {
|
||||
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 () => {
|
||||
await expect(axios('/foo', {
|
||||
auth: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import axios from '../../index.js';
|
||||
import AxiosError from '../../lib/core/AxiosError.js';
|
||||
|
||||
class MockXMLHttpRequest {
|
||||
constructor() {
|
||||
@@ -222,6 +223,27 @@ describe('requests (vitest browser)', () => {
|
||||
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 () => {
|
||||
const { request, promise } = startRequest('/foo');
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
makeEchoStream,
|
||||
} from '../../setup/server.js';
|
||||
import axios from '../../../index.js';
|
||||
import AxiosError from '../../../lib/core/AxiosError.js';
|
||||
import utils from '../../../lib/utils.js';
|
||||
import { getFetch } from '../../../lib/adapters/fetch.js';
|
||||
import stream from 'stream';
|
||||
@@ -51,6 +52,28 @@ const createBrokenDOMExceptionLikeError = () =>
|
||||
);
|
||||
|
||||
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 () => {
|
||||
const server = await startHTTPServer(
|
||||
(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 () => {
|
||||
const server = await startHTTPServer(
|
||||
(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 () => {
|
||||
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', () => {
|
||||
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 () => {
|
||||
const server = await startHTTPServer(
|
||||
(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 () => {
|
||||
const payload = 'A'.repeat(8 * 1024);
|
||||
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 () => {
|
||||
const server = await startHTTPServer(
|
||||
(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 () => {
|
||||
const payload = 'ok';
|
||||
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 () => {
|
||||
const payload = 'hello';
|
||||
let received;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../../setup/server.js';
|
||||
import axios from '../../../index.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 http from 'http';
|
||||
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 () => {
|
||||
const server = await startHTTPServer(
|
||||
(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 () => {
|
||||
const server = await startHTTPServer(
|
||||
(req, res) => {
|
||||
@@ -3942,6 +4116,60 @@ describe('supports http with nodejs', () => {
|
||||
const pollutedKeys = ['getHeaders', 'append', 'pipe', 'on', 'once'];
|
||||
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() {
|
||||
Object.prototype[toStringTagSym] = 'FormData';
|
||||
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 () => {
|
||||
if (typeof FormData !== 'function') {
|
||||
return;
|
||||
|
||||
@@ -69,6 +69,66 @@ describe('static api', () => {
|
||||
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', () => {
|
||||
assert.strictEqual(typeof axios.isAxiosError, 'function');
|
||||
});
|
||||
|
||||
@@ -84,6 +84,104 @@ describe('AxiosHeaders', () => {
|
||||
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;
|
||||
runIfNode18OrHigher(
|
||||
'should support setting multiple header values from an iterable source',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import buildFullPath from '../../../lib/core/buildFullPath.js';
|
||||
import AxiosError from '../../../lib/core/AxiosError.js';
|
||||
|
||||
describe('core::buildFullPath', () => {
|
||||
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', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,43 @@ function baseConfig(overrides = {}) {
|
||||
}
|
||||
|
||||
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', () => {
|
||||
it('rejects with AxiosError carrying response and status', async () => {
|
||||
const response = {
|
||||
|
||||
@@ -12,6 +12,16 @@ describe('estimateDataURLDecodedBytes', () => {
|
||||
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', () => {
|
||||
const str = 'Hello';
|
||||
const b64 = Buffer.from(str, 'utf8').toString('base64');
|
||||
|
||||
@@ -123,6 +123,35 @@ describe('helpers::buildURL', () => {
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import formDataToJSON from '../../../lib/helpers/formDataToJSON.js';
|
||||
import AxiosError from '../../../lib/core/AxiosError.js';
|
||||
|
||||
describe('formDataToJSON', () => {
|
||||
it('should convert a FormData Object to JSON Object', () => {
|
||||
@@ -116,4 +117,48 @@ describe('formDataToJSON', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,4 +32,62 @@ describe('helpers::resolveConfig', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
setNoProxy('*.example.com,localhost:8080');
|
||||
|
||||
|
||||
@@ -190,6 +190,32 @@ describe('helpers::toFormData', () => {
|
||||
assert.strictEqual(caught.code, 'ERR_FORM_DATA_DEPTH_EXCEEDED');
|
||||
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', () => {
|
||||
@@ -208,6 +234,27 @@ describe('helpers::toFormData', () => {
|
||||
const qs = params.toString();
|
||||
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', () => {
|
||||
|
||||
@@ -58,6 +58,81 @@ describe('utils::isX', () => {
|
||||
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', () => {
|
||||
expect(utils.isDate(new Date())).toEqual(true);
|
||||
expect(utils.isDate(Date.now())).toEqual(false);
|
||||
|
||||
Reference in New Issue
Block a user