mirror of
https://github.com/tenrok/axios.git
synced 2026-06-20 20:00:40 +03:00
fix: gadgets and smaller issues (#10833)
* chore: remove un-needed ghsa in the comments of files * fix: auth header * fix: escape regex chars in cookies.read * fix: read-side merge and descriptors * fix: enable redaction in the .toJson for errors * fix: general IPv4-mapped IPv6 normalization in NO_PROXY * fix: added regression tests for scenarios already covered * chore: remove un-needed comments * fix: harden proxy host detection and error redaction * fix: make form-data header change opt-in * fix: apply suggestions form github review * fix: cubic review * fix: widen the regexs for matches Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix: smaller issue found by cubic * fix: address prototype chain * fix: update as per cubic --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -752,6 +752,11 @@ These are the available config options for making requests. Only the `url` is re
|
|||||||
firstName: 'Fred'
|
firstName: 'Fred'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// `formDataHeaderPolicy` controls how node.js FormData#getHeaders() is copied.
|
||||||
|
// 'legacy' (default) copies all returned headers for v1 compatibility.
|
||||||
|
// 'content-only' copies only Content-Type and Content-Length.
|
||||||
|
formDataHeaderPolicy: 'legacy',
|
||||||
|
|
||||||
// syntax alternative to send data into the body
|
// syntax alternative to send data into the body
|
||||||
// method post
|
// method post
|
||||||
// only the value is sent, not the key
|
// only the value is sent, not the key
|
||||||
@@ -846,6 +851,10 @@ These are the available config options for making requests. Only the `url` is re
|
|||||||
// `maxBodyLength` (Node only option) defines the max size of the http request content in bytes allowed
|
// `maxBodyLength` (Node only option) defines the max size of the http request content in bytes allowed
|
||||||
maxBodyLength: 2000,
|
maxBodyLength: 2000,
|
||||||
|
|
||||||
|
// `redact` masks matching config keys when AxiosError#toJSON() is called.
|
||||||
|
// Matching is case-insensitive and recursive. It does not change the request.
|
||||||
|
redact: ['authorization', 'password'],
|
||||||
|
|
||||||
// `validateStatus` defines whether to resolve or reject the promise for a given
|
// `validateStatus` defines whether to resolve or reject the promise for a given
|
||||||
// HTTP response status code. If `validateStatus` returns `true` (or is set to `null`
|
// HTTP response status code. If `validateStatus` returns `true` (or is set to `null`
|
||||||
// or `undefined`), the promise will be resolved; otherwise, the promise will be
|
// or `undefined`), the promise will be resolved; otherwise, the promise will be
|
||||||
@@ -1360,6 +1369,17 @@ axios.get('/user/12345').catch(function (error) {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To avoid logging secrets from `error.config`, pass a `redact` array in the request config. Matching config keys are masked case-insensitively at any depth when `AxiosError#toJSON()` is called.
|
||||||
|
|
||||||
|
```js
|
||||||
|
axios.get('/user/12345', {
|
||||||
|
headers: { Authorization: 'Bearer token' },
|
||||||
|
redact: ['authorization']
|
||||||
|
}).catch(function (error) {
|
||||||
|
console.log(error.toJSON().config.headers.Authorization); // [REDACTED ****]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Handling Timeouts
|
## Handling Timeouts
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -1601,6 +1621,8 @@ form.append('my_file', fs.createReadStream('/foo/bar.jpg'));
|
|||||||
axios.post('https://example.com', form);
|
axios.post('https://example.com', form);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In node.js, when a `FormData` object provides `getHeaders()`, axios copies all returned headers by default for v1 compatibility. If the `FormData` object is custom or not fully trusted, set `formDataHeaderPolicy: 'content-only'` to copy only `Content-Type` and `Content-Length`, and set any other request headers explicitly with the request `headers` config.
|
||||||
|
|
||||||
### 🆕 Automatic serialization to FormData
|
### 🆕 Automatic serialization to FormData
|
||||||
|
|
||||||
Starting from `v0.27.0`, Axios supports automatic object serialization to a FormData object if the request `Content-Type`
|
Starting from `v0.27.0`, Axios supports automatic object serialization to a FormData object if the request `Content-Type`
|
||||||
|
|||||||
@@ -70,3 +70,14 @@ axios.get("/user/12345").catch(function (error) {
|
|||||||
console.log(error.toJSON());
|
console.log(error.toJSON());
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To avoid logging secrets from `error.config`, pass a `redact` array in the request config. Matching config keys are masked case-insensitively at any depth when `AxiosError#toJSON()` is called.
|
||||||
|
|
||||||
|
```js
|
||||||
|
axios.get("/user/12345", {
|
||||||
|
headers: { Authorization: "Bearer token" },
|
||||||
|
redact: ["authorization"]
|
||||||
|
}).catch(function (error) {
|
||||||
|
console.log(error.toJSON().config.headers.Authorization); // [REDACTED ****]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|||||||
@@ -108,6 +108,12 @@ The `data` is the data to be sent as the request body. This can be a string, a p
|
|||||||
- Browser only: FormData, File, Blob
|
- Browser only: FormData, File, Blob
|
||||||
- Node only: Stream, Buffer, FormData (form-data package)
|
- Node only: Stream, Buffer, FormData (form-data package)
|
||||||
|
|
||||||
|
For Node.js `FormData` objects that provide a `getHeaders()` method, axios copies all returned headers by default for v1 compatibility. If the `FormData` object is custom or not fully trusted, set `formDataHeaderPolicy: 'content-only'` to copy only `Content-Type` and `Content-Length`, and set any other request headers explicitly via the request `headers` config.
|
||||||
|
|
||||||
|
### `formDataHeaderPolicy` <Badge type="warning" text="Node.js only" />
|
||||||
|
|
||||||
|
Controls how axios copies headers returned by Node.js `FormData#getHeaders()`. The default is `'legacy'`, which copies all returned headers to preserve existing v1 behavior. Set `'content-only'` to copy only `Content-Type` and `Content-Length` from `getHeaders()`.
|
||||||
|
|
||||||
### `timeout`
|
### `timeout`
|
||||||
|
|
||||||
The `timeout` is the number of milliseconds before the request times out. If the request takes longer than `timeout`, the request will be aborted.
|
The `timeout` is the number of milliseconds before the request times out. If the request takes longer than `timeout`, the request will be aborted.
|
||||||
@@ -206,6 +212,22 @@ The `maxContentLength` property defines the maximum number of bytes that the ser
|
|||||||
|
|
||||||
The `maxBodyLength` property defines the maximum number of bytes that the server will accept in the request.
|
The `maxBodyLength` property defines the maximum number of bytes that the server will accept in the request.
|
||||||
|
|
||||||
|
### `redact`
|
||||||
|
|
||||||
|
The `redact` property is an optional array of config key names to mask when an `AxiosError` is serialized with `toJSON()`. Matching is case-insensitive and recursive across the serialized request config. Matching values are replaced with `[REDACTED ****]`.
|
||||||
|
|
||||||
|
`redact` only affects error serialization. It does not change request data, headers, or the original config object.
|
||||||
|
|
||||||
|
```js
|
||||||
|
axios.get('/user/12345', {
|
||||||
|
headers: { Authorization: 'Bearer token' },
|
||||||
|
auth: { username: 'me', password: 'secret' },
|
||||||
|
redact: ['authorization', 'password']
|
||||||
|
}).catch((error) => {
|
||||||
|
console.log(error.toJSON().config);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
### `validateStatus`
|
### `validateStatus`
|
||||||
|
|
||||||
The `validateStatus` function allows you to override the default status code validation. By default, axios will reject the promise if the status code is not in the range of 200-299. You can override this behavior by providing a custom `validateStatus` function. The function should return `true` if the status code is within the range you want to accept.
|
The `validateStatus` function allows you to override the default status code validation. By default, axios will reject the promise if the status code is not in the range of 200-299. You can override this behavior by providing a custom `validateStatus` function. The function should return `true` if the status code is within the range you want to accept.
|
||||||
@@ -362,6 +384,7 @@ The `maxRate` property defines the maximum **bandwidth** (in bytes per second) f
|
|||||||
data: {
|
data: {
|
||||||
firstName: "Fred"
|
firstName: "Fred"
|
||||||
},
|
},
|
||||||
|
formDataHeaderPolicy: "legacy",
|
||||||
// Syntax alternative to send data into the body method post only the value is sent, not the key
|
// Syntax alternative to send data into the body method post only the value is sent, not the key
|
||||||
data: "Country=Brasil&City=Belo Horizonte",
|
data: "Country=Brasil&City=Belo Horizonte",
|
||||||
timeout: 1000,
|
timeout: 1000,
|
||||||
@@ -387,6 +410,7 @@ The `maxRate` property defines the maximum **bandwidth** (in bytes per second) f
|
|||||||
},
|
},
|
||||||
maxContentLength: 2000,
|
maxContentLength: 2000,
|
||||||
maxBodyLength: 2000,
|
maxBodyLength: 2000,
|
||||||
|
redact: ['authorization', 'password'],
|
||||||
validateStatus: function (status) {
|
validateStatus: function (status) {
|
||||||
return status >= 200 && status < 300;
|
return status >= 200 && status < 300;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -552,6 +552,8 @@ declare namespace axios {
|
|||||||
http2Options?: Record<string, any> & {
|
http2Options?: Record<string, any> & {
|
||||||
sessionTimeout?: number;
|
sessionTimeout?: number;
|
||||||
};
|
};
|
||||||
|
formDataHeaderPolicy?: 'legacy' | 'content-only';
|
||||||
|
redact?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alias
|
// Alias
|
||||||
|
|||||||
Vendored
+2
@@ -447,6 +447,8 @@ export interface AxiosRequestConfig<D = any> {
|
|||||||
http2Options?: Record<string, any> & {
|
http2Options?: Record<string, any> & {
|
||||||
sessionTimeout?: number;
|
sessionTimeout?: number;
|
||||||
};
|
};
|
||||||
|
formDataHeaderPolicy?: 'legacy' | 'content-only';
|
||||||
|
redact?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alias
|
// Alias
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ const knownAdapters = {
|
|||||||
utils.forEach(knownAdapters, (fn, value) => {
|
utils.forEach(knownAdapters, (fn, value) => {
|
||||||
if (fn) {
|
if (fn) {
|
||||||
try {
|
try {
|
||||||
Object.defineProperty(fn, 'name', { value });
|
// Null-proto descriptors so a polluted Object.prototype.get cannot turn
|
||||||
|
// these data descriptors into accessor descriptors on the way in.
|
||||||
|
Object.defineProperty(fn, 'name', { __proto__: null, value });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line no-empty
|
// eslint-disable-next-line no-empty
|
||||||
}
|
}
|
||||||
Object.defineProperty(fn, 'adapterName', { value });
|
Object.defineProperty(fn, 'adapterName', { __proto__: null, value });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+68
-30
@@ -47,6 +47,20 @@ const isBrotliSupported = utils.isFunction(zlib.createBrotliDecompress);
|
|||||||
const { http: httpFollow, https: httpsFollow } = followRedirects;
|
const { http: httpFollow, https: httpsFollow } = followRedirects;
|
||||||
|
|
||||||
const isHttps = /https:?/;
|
const isHttps = /https:?/;
|
||||||
|
const FORM_DATA_CONTENT_HEADERS = ['content-type', 'content-length'];
|
||||||
|
|
||||||
|
function setFormDataHeaders(headers, formHeaders, policy) {
|
||||||
|
if (policy !== 'content-only') {
|
||||||
|
headers.set(formHeaders);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(formHeaders).forEach(([key, val]) => {
|
||||||
|
if (FORM_DATA_CONTENT_HEADERS.includes(key.toLowerCase())) {
|
||||||
|
headers.set(key, val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Symbols used to bind a single 'error' listener to a pooled socket and track
|
// Symbols used to bind a single 'error' listener to a pooled socket and track
|
||||||
// the request currently owning that socket across keep-alive reuse (issue #10780).
|
// the request currently owning that socket across keep-alive reuse (issue #10780).
|
||||||
@@ -232,22 +246,43 @@ function setProxy(options, configProxy, location, isRedirect) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (proxy) {
|
if (proxy) {
|
||||||
|
// Read proxy fields without traversing the prototype chain. URL instances expose
|
||||||
|
// username/password/hostname/host/port/protocol via getters on URL.prototype (so
|
||||||
|
// direct reads are shielded), but plain object proxies — and the `auth` field
|
||||||
|
// (which URL does not expose) — must be guarded so a polluted Object.prototype
|
||||||
|
// (e.g. Object.prototype.auth = { username, password }) cannot inject
|
||||||
|
// attacker-controlled credentials into the Proxy-Authorization header or
|
||||||
|
// redirect proxying to an attacker-controlled host.
|
||||||
|
const isProxyURL = proxy instanceof URL;
|
||||||
|
const readProxyField = (key) =>
|
||||||
|
isProxyURL || utils.hasOwnProp(proxy, key) ? proxy[key] : undefined;
|
||||||
|
|
||||||
|
const proxyUsername = readProxyField('username');
|
||||||
|
const proxyPassword = readProxyField('password');
|
||||||
|
let proxyAuth = utils.hasOwnProp(proxy, 'auth') ? proxy.auth : undefined;
|
||||||
|
|
||||||
// Basic proxy authorization
|
// Basic proxy authorization
|
||||||
if (proxy.username) {
|
if (proxyUsername) {
|
||||||
proxy.auth = (proxy.username || '') + ':' + (proxy.password || '');
|
proxyAuth = (proxyUsername || '') + ':' + (proxyPassword || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proxy.auth) {
|
if (proxyAuth) {
|
||||||
// Support proxy auth object form
|
// Support proxy auth object form. Read sub-fields via own-prop checks so a
|
||||||
const validProxyAuth = Boolean(proxy.auth.username || proxy.auth.password);
|
// plain object inheriting from polluted Object.prototype cannot leak creds.
|
||||||
|
const authIsObject = typeof proxyAuth === 'object';
|
||||||
|
const authUsername =
|
||||||
|
authIsObject && utils.hasOwnProp(proxyAuth, 'username') ? proxyAuth.username : undefined;
|
||||||
|
const authPassword =
|
||||||
|
authIsObject && utils.hasOwnProp(proxyAuth, 'password') ? proxyAuth.password : undefined;
|
||||||
|
const validProxyAuth = Boolean(authUsername || authPassword);
|
||||||
|
|
||||||
if (validProxyAuth) {
|
if (validProxyAuth) {
|
||||||
proxy.auth = (proxy.auth.username || '') + ':' + (proxy.auth.password || '');
|
proxyAuth = (authUsername || '') + ':' + (authPassword || '');
|
||||||
} else if (typeof proxy.auth === 'object') {
|
} else if (authIsObject) {
|
||||||
throw new AxiosError('Invalid proxy authorization', AxiosError.ERR_BAD_OPTION, { proxy });
|
throw new AxiosError('Invalid proxy authorization', AxiosError.ERR_BAD_OPTION, { proxy });
|
||||||
}
|
}
|
||||||
|
|
||||||
const base64 = Buffer.from(proxy.auth, 'utf8').toString('base64');
|
const base64 = Buffer.from(proxyAuth, 'utf8').toString('base64');
|
||||||
|
|
||||||
options.headers['Proxy-Authorization'] = 'Basic ' + base64;
|
options.headers['Proxy-Authorization'] = 'Basic ' + base64;
|
||||||
}
|
}
|
||||||
@@ -255,7 +290,7 @@ function setProxy(options, configProxy, location, isRedirect) {
|
|||||||
// Preserve a user-supplied Host header (case-insensitive) so callers can override
|
// Preserve a user-supplied Host header (case-insensitive) so callers can override
|
||||||
// the value forwarded to the proxy; otherwise default to the request URL's host.
|
// the value forwarded to the proxy; otherwise default to the request URL's host.
|
||||||
let hasUserHostHeader = false;
|
let hasUserHostHeader = false;
|
||||||
for (const name in options.headers) {
|
for (const name of Object.keys(options.headers)) {
|
||||||
if (name.toLowerCase() === 'host') {
|
if (name.toLowerCase() === 'host') {
|
||||||
hasUserHostHeader = true;
|
hasUserHostHeader = true;
|
||||||
break;
|
break;
|
||||||
@@ -264,14 +299,15 @@ function setProxy(options, configProxy, location, isRedirect) {
|
|||||||
if (!hasUserHostHeader) {
|
if (!hasUserHostHeader) {
|
||||||
options.headers.host = options.hostname + (options.port ? ':' + options.port : '');
|
options.headers.host = options.hostname + (options.port ? ':' + options.port : '');
|
||||||
}
|
}
|
||||||
const proxyHost = proxy.hostname || proxy.host;
|
const proxyHost = readProxyField('hostname') || readProxyField('host');
|
||||||
options.hostname = proxyHost;
|
options.hostname = proxyHost;
|
||||||
// Replace 'host' since options is not a URL object
|
// Replace 'host' since options is not a URL object
|
||||||
options.host = proxyHost;
|
options.host = proxyHost;
|
||||||
options.port = proxy.port;
|
options.port = readProxyField('port');
|
||||||
options.path = location;
|
options.path = location;
|
||||||
if (proxy.protocol) {
|
const proxyProtocol = readProxyField('protocol');
|
||||||
options.protocol = proxy.protocol.includes(':') ? proxy.protocol : `${proxy.protocol}:`;
|
if (proxyProtocol) {
|
||||||
|
options.protocol = proxyProtocol.includes(':') ? proxyProtocol : `${proxyProtocol}:`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,9 +634,12 @@ export default isHttpAdapterSupported &&
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
// support for https://www.npmjs.com/package/form-data api
|
// support for https://www.npmjs.com/package/form-data api
|
||||||
} else if (utils.isFormData(data) && utils.isFunction(data.getHeaders) &&
|
} else if (
|
||||||
data.getHeaders !== Object.prototype.getHeaders) {
|
utils.isFormData(data) &&
|
||||||
headers.set(data.getHeaders());
|
utils.isFunction(data.getHeaders) &&
|
||||||
|
data.getHeaders !== Object.prototype.getHeaders
|
||||||
|
) {
|
||||||
|
setFormDataHeaders(headers, data.getHeaders(), own('formDataHeaderPolicy'));
|
||||||
|
|
||||||
if (!headers.hasContentLength()) {
|
if (!headers.hasContentLength()) {
|
||||||
try {
|
try {
|
||||||
@@ -724,7 +763,6 @@ export default isHttpAdapterSupported &&
|
|||||||
|
|
||||||
// Null-prototype to block prototype pollution gadgets on properties read
|
// Null-prototype to block prototype pollution gadgets on properties read
|
||||||
// directly by Node's http.request (e.g. insecureHTTPParser, lookup).
|
// directly by Node's http.request (e.g. insecureHTTPParser, lookup).
|
||||||
// See GHSA-q8qp-cvcw-x6jj.
|
|
||||||
const options = Object.assign(Object.create(null), {
|
const options = Object.assign(Object.create(null), {
|
||||||
path,
|
path,
|
||||||
method: method,
|
method: method,
|
||||||
@@ -743,11 +781,9 @@ export default isHttpAdapterSupported &&
|
|||||||
|
|
||||||
if (config.socketPath) {
|
if (config.socketPath) {
|
||||||
if (typeof config.socketPath !== 'string') {
|
if (typeof config.socketPath !== 'string') {
|
||||||
return reject(new AxiosError(
|
return reject(
|
||||||
'socketPath must be a string',
|
new AxiosError('socketPath must be a string', AxiosError.ERR_BAD_OPTION_VALUE, config)
|
||||||
AxiosError.ERR_BAD_OPTION_VALUE,
|
);
|
||||||
config
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.allowedSocketPaths != null) {
|
if (config.allowedSocketPaths != null) {
|
||||||
@@ -761,11 +797,13 @@ export default isHttpAdapterSupported &&
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!isAllowed) {
|
if (!isAllowed) {
|
||||||
return reject(new AxiosError(
|
return reject(
|
||||||
`socketPath "${config.socketPath}" is not permitted by allowedSocketPaths`,
|
new AxiosError(
|
||||||
AxiosError.ERR_BAD_OPTION_VALUE,
|
`socketPath "${config.socketPath}" is not permitted by allowedSocketPaths`,
|
||||||
config
|
AxiosError.ERR_BAD_OPTION_VALUE,
|
||||||
));
|
config
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,7 +854,7 @@ export default isHttpAdapterSupported &&
|
|||||||
|
|
||||||
// Always set an explicit own value so a polluted
|
// Always set an explicit own value so a polluted
|
||||||
// Object.prototype.insecureHTTPParser cannot enable the lenient parser
|
// Object.prototype.insecureHTTPParser cannot enable the lenient parser
|
||||||
// through Node's internal options copy (GHSA-q8qp-cvcw-x6jj).
|
// through Node's internal options copy
|
||||||
options.insecureHTTPParser = Boolean(own('insecureHTTPParser'));
|
options.insecureHTTPParser = Boolean(own('insecureHTTPParser'));
|
||||||
|
|
||||||
// Create the request
|
// Create the request
|
||||||
@@ -904,7 +942,7 @@ 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. See GHSA-vf2m-468p-8v99.
|
// was applied only to buffered responses.
|
||||||
if (config.maxContentLength > -1) {
|
if (config.maxContentLength > -1) {
|
||||||
const limit = config.maxContentLength;
|
const limit = config.maxContentLength;
|
||||||
const source = responseStream;
|
const source = responseStream;
|
||||||
@@ -1121,7 +1159,7 @@ export default isHttpAdapterSupported &&
|
|||||||
|
|
||||||
// Enforce maxBodyLength for streamed uploads on the native http/https
|
// Enforce maxBodyLength for streamed uploads on the native http/https
|
||||||
// transport (maxRedirects === 0); follow-redirects enforces it on the
|
// transport (maxRedirects === 0); follow-redirects enforces it on the
|
||||||
// other path. See GHSA-5c9x-8gcm-mpgx.
|
// other path.
|
||||||
let uploadStream = data;
|
let uploadStream = data;
|
||||||
if (config.maxBodyLength > -1 && config.maxRedirects === 0) {
|
if (config.maxBodyLength > -1 && config.maxRedirects === 0) {
|
||||||
const limit = config.maxBodyLength;
|
const limit = config.maxBodyLength;
|
||||||
|
|||||||
+85
-1
@@ -1,6 +1,76 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
import utils from '../utils.js';
|
import utils from '../utils.js';
|
||||||
|
import AxiosHeaders from './AxiosHeaders.js';
|
||||||
|
|
||||||
|
const REDACTED = '[REDACTED ****]';
|
||||||
|
|
||||||
|
function hasOwnOrPrototypeToJSON(source) {
|
||||||
|
if (utils.hasOwnProp(source, 'toJSON')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prototype = Object.getPrototypeOf(source);
|
||||||
|
|
||||||
|
while (prototype && prototype !== Object.prototype) {
|
||||||
|
if (utils.hasOwnProp(prototype, 'toJSON')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
prototype = Object.getPrototypeOf(prototype);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a plain-object snapshot of `config` and replace the value of any key
|
||||||
|
// (case-insensitive) listed in `redactKeys` with REDACTED. Walks through arrays
|
||||||
|
// and AxiosHeaders, and short-circuits on circular references.
|
||||||
|
function redactConfig(config, redactKeys) {
|
||||||
|
const lowerKeys = new Set(redactKeys.map((k) => String(k).toLowerCase()));
|
||||||
|
const seen = [];
|
||||||
|
|
||||||
|
const visit = (source) => {
|
||||||
|
if (source === null || typeof source !== 'object') return source;
|
||||||
|
if (utils.isBuffer(source)) return source;
|
||||||
|
if (seen.indexOf(source) !== -1) return undefined;
|
||||||
|
|
||||||
|
if (source instanceof AxiosHeaders) {
|
||||||
|
source = source.toJSON();
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.push(source);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (utils.isArray(source)) {
|
||||||
|
result = [];
|
||||||
|
source.forEach((v, i) => {
|
||||||
|
const reducedValue = visit(v);
|
||||||
|
if (!utils.isUndefined(reducedValue)) {
|
||||||
|
result[i] = reducedValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!utils.isPlainObject(source) && hasOwnOrPrototypeToJSON(source)) {
|
||||||
|
seen.pop();
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = Object.create(null);
|
||||||
|
for (const [key, value] of Object.entries(source)) {
|
||||||
|
const reducedValue = lowerKeys.has(key.toLowerCase()) ? REDACTED : visit(value);
|
||||||
|
if (!utils.isUndefined(reducedValue)) {
|
||||||
|
result[key] = reducedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.pop();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
return visit(config);
|
||||||
|
}
|
||||||
|
|
||||||
class AxiosError extends Error {
|
class AxiosError extends Error {
|
||||||
static from(error, code, config, request, response, customProps) {
|
static from(error, code, config, request, response, customProps) {
|
||||||
@@ -35,6 +105,9 @@ class AxiosError extends Error {
|
|||||||
// The native Error constructor sets message as non-enumerable,
|
// The native Error constructor sets message as non-enumerable,
|
||||||
// but axios < v1.13.3 had it as enumerable
|
// but axios < v1.13.3 had it as enumerable
|
||||||
Object.defineProperty(this, 'message', {
|
Object.defineProperty(this, 'message', {
|
||||||
|
// Null-proto descriptor so a polluted Object.prototype.get cannot turn
|
||||||
|
// this data descriptor into an accessor descriptor on the way in.
|
||||||
|
__proto__: null,
|
||||||
value: message,
|
value: message,
|
||||||
enumerable: true,
|
enumerable: true,
|
||||||
writable: true,
|
writable: true,
|
||||||
@@ -53,6 +126,17 @@ class AxiosError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toJSON() {
|
toJSON() {
|
||||||
|
// Opt-in redaction: when the request config carries a `redact` array, the
|
||||||
|
// value of any matching key (case-insensitive, at any depth) is replaced
|
||||||
|
// with REDACTED in the serialized snapshot. Undefined or empty leaves the
|
||||||
|
// existing serialization behavior unchanged.
|
||||||
|
const config = this.config;
|
||||||
|
const redactKeys = config && utils.hasOwnProp(config, 'redact') ? config.redact : undefined;
|
||||||
|
const serializedConfig =
|
||||||
|
utils.isArray(redactKeys) && redactKeys.length > 0
|
||||||
|
? redactConfig(config, redactKeys)
|
||||||
|
: utils.toJSONObject(config);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Standard
|
// Standard
|
||||||
message: this.message,
|
message: this.message,
|
||||||
@@ -66,7 +150,7 @@ class AxiosError extends Error {
|
|||||||
columnNumber: this.columnNumber,
|
columnNumber: this.columnNumber,
|
||||||
stack: this.stack,
|
stack: this.stack,
|
||||||
// Axios
|
// Axios
|
||||||
config: utils.toJSONObject(this.config),
|
config: serializedConfig,
|
||||||
code: this.code,
|
code: this.code,
|
||||||
status: this.status,
|
status: this.status,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ function buildAccessors(obj, header) {
|
|||||||
|
|
||||||
['get', 'set', 'has'].forEach((methodName) => {
|
['get', 'set', 'has'].forEach((methodName) => {
|
||||||
Object.defineProperty(obj, methodName + accessorName, {
|
Object.defineProperty(obj, methodName + accessorName, {
|
||||||
|
// Null-proto descriptor so a polluted Object.prototype.get cannot turn
|
||||||
|
// this data descriptor into an accessor descriptor on the way in.
|
||||||
|
__proto__: null,
|
||||||
value: function (arg1, arg2, arg3) {
|
value: function (arg1, arg2, arg3) {
|
||||||
return this[methodName].call(this, header, arg1, arg2, arg3);
|
return this[methodName].call(this, header, arg1, arg2, arg3);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ export default function mergeConfig(config1, config2) {
|
|||||||
config2 = config2 || {};
|
config2 = config2 || {};
|
||||||
|
|
||||||
// Use a null-prototype object so that downstream reads such as `config.auth`
|
// Use a null-prototype object so that downstream reads such as `config.auth`
|
||||||
// or `config.baseURL` cannot inherit polluted values from Object.prototype
|
// or `config.baseURL` cannot inherit polluted values from Object.prototype.
|
||||||
// (see GHSA-q8qp-cvcw-x6jj). `hasOwnProperty` is restored as a non-enumerable
|
// `hasOwnProperty` is restored as a non-enumerable own slot to preserve
|
||||||
// own slot to preserve ergonomics for user code that relies on it.
|
// ergonomics for user code that relies on it.
|
||||||
const config = Object.create(null);
|
const config = Object.create(null);
|
||||||
Object.defineProperty(config, 'hasOwnProperty', {
|
Object.defineProperty(config, 'hasOwnProperty', {
|
||||||
|
// Null-proto descriptor so a polluted Object.prototype.get cannot turn
|
||||||
|
// this data descriptor into an accessor descriptor on the way in.
|
||||||
|
__proto__: null,
|
||||||
value: Object.prototype.hasOwnProperty,
|
value: Object.prototype.hasOwnProperty,
|
||||||
enumerable: false,
|
enumerable: false,
|
||||||
writable: true,
|
writable: true,
|
||||||
|
|||||||
+14
-2
@@ -30,8 +30,20 @@ export default platform.hasStandardBrowserEnv
|
|||||||
|
|
||||||
read(name) {
|
read(name) {
|
||||||
if (typeof document === 'undefined') return null;
|
if (typeof document === 'undefined') return null;
|
||||||
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
|
// Match name=value by splitting on the semicolon separator instead of building a
|
||||||
return match ? decodeURIComponent(match[1]) : null;
|
// RegExp from `name` — interpolating an unescaped string into a RegExp would let
|
||||||
|
// metacharacters (e.g. `.+?` in an attacker-influenced cookie name) cause ReDoS or
|
||||||
|
// match the wrong cookie. Browsers may serialize cookie pairs as either ";" or
|
||||||
|
// "; ", so ignore optional whitespace before each cookie name.
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i].replace(/^\s+/, '');
|
||||||
|
const eq = cookie.indexOf('=');
|
||||||
|
if (eq !== -1 && cookie.slice(0, eq) === name) {
|
||||||
|
return decodeURIComponent(cookie.slice(eq + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
remove(name) {
|
remove(name) {
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ import mergeConfig from '../core/mergeConfig.js';
|
|||||||
import AxiosHeaders from '../core/AxiosHeaders.js';
|
import AxiosHeaders from '../core/AxiosHeaders.js';
|
||||||
import buildURL from './buildURL.js';
|
import buildURL from './buildURL.js';
|
||||||
|
|
||||||
|
const FORM_DATA_CONTENT_HEADERS = ['content-type', 'content-length'];
|
||||||
|
|
||||||
|
function setFormDataHeaders(headers, formHeaders, policy) {
|
||||||
|
if (policy !== 'content-only') {
|
||||||
|
headers.set(formHeaders);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(formHeaders).forEach(([key, val]) => {
|
||||||
|
if (FORM_DATA_CONTENT_HEADERS.includes(key.toLowerCase())) {
|
||||||
|
headers.set(key, val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode a UTF-8 string to a Latin-1 byte string for use with btoa().
|
* Encode a UTF-8 string to a Latin-1 byte string for use with btoa().
|
||||||
* This is a modern replacement for the deprecated unescape(encodeURIComponent(str)) pattern.
|
* This is a modern replacement for the deprecated unescape(encodeURIComponent(str)) pattern.
|
||||||
@@ -15,16 +30,16 @@ import buildURL from './buildURL.js';
|
|||||||
*
|
*
|
||||||
* @returns {string} UTF-8 bytes as a Latin-1 string
|
* @returns {string} UTF-8 bytes as a Latin-1 string
|
||||||
*/
|
*/
|
||||||
const encodeUTF8 = (str) => encodeURIComponent(str).replace(
|
const encodeUTF8 = (str) =>
|
||||||
/%([0-9A-F]{2})/gi,
|
encodeURIComponent(str).replace(/%([0-9A-F]{2})/gi, (_, hex) =>
|
||||||
(_, hex) => String.fromCharCode(parseInt(hex, 16))
|
String.fromCharCode(parseInt(hex, 16))
|
||||||
);
|
);
|
||||||
|
|
||||||
export default (config) => {
|
export default (config) => {
|
||||||
const newConfig = mergeConfig({}, config);
|
const newConfig = mergeConfig({}, config);
|
||||||
|
|
||||||
// Read only own properties to prevent prototype pollution gadgets
|
// Read only own properties to prevent prototype pollution gadgets
|
||||||
// (e.g. Object.prototype.baseURL = 'https://evil.com'). See GHSA-q8qp-cvcw-x6jj.
|
// (e.g. Object.prototype.baseURL = 'https://evil.com').
|
||||||
const own = (key) => (utils.hasOwnProp(newConfig, key) ? newConfig[key] : undefined);
|
const own = (key) => (utils.hasOwnProp(newConfig, key) ? newConfig[key] : undefined);
|
||||||
|
|
||||||
const data = own('data');
|
const data = own('data');
|
||||||
@@ -47,8 +62,10 @@ export default (config) => {
|
|||||||
|
|
||||||
// HTTP basic authentication
|
// HTTP basic authentication
|
||||||
if (auth) {
|
if (auth) {
|
||||||
headers.set('Authorization', 'Basic ' +
|
headers.set(
|
||||||
btoa((auth.username || '') + ':' + (auth.password ? encodeUTF8(auth.password) : ''))
|
'Authorization',
|
||||||
|
'Basic ' +
|
||||||
|
btoa((auth.username || '') + ':' + (auth.password ? encodeUTF8(auth.password) : ''))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,14 +74,7 @@ export default (config) => {
|
|||||||
headers.setContentType(undefined); // browser handles it
|
headers.setContentType(undefined); // browser handles it
|
||||||
} else if (utils.isFunction(data.getHeaders)) {
|
} else if (utils.isFunction(data.getHeaders)) {
|
||||||
// Node.js FormData (like form-data package)
|
// Node.js FormData (like form-data package)
|
||||||
const formHeaders = data.getHeaders();
|
setFormDataHeaders(headers, data.getHeaders(), own('formDataHeaderPolicy'));
|
||||||
// Only set safe headers to avoid overwriting security headers
|
|
||||||
const allowedHeaders = ['content-type', 'content-length'];
|
|
||||||
Object.entries(formHeaders).forEach(([key, val]) => {
|
|
||||||
if (allowedHeaders.includes(key.toLowerCase())) {
|
|
||||||
headers.set(key, val);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,10 +89,9 @@ export default (config) => {
|
|||||||
|
|
||||||
// Strict boolean check — prevents proto-pollution gadgets (e.g. Object.prototype.withXSRFToken = 1)
|
// Strict boolean check — prevents proto-pollution gadgets (e.g. Object.prototype.withXSRFToken = 1)
|
||||||
// and misconfigurations (e.g. "false") from short-circuiting the same-origin check and leaking
|
// and misconfigurations (e.g. "false") from short-circuiting the same-origin check and leaking
|
||||||
// the XSRF token cross-origin. See GHSA-xx6v-rp6x-q39c.
|
// the XSRF token cross-origin.
|
||||||
const shouldSendXSRF =
|
const shouldSendXSRF =
|
||||||
withXSRFToken === true ||
|
withXSRFToken === true || (withXSRFToken == null && isURLSameOrigin(newConfig.url));
|
||||||
(withXSRFToken == null && isURLSameOrigin(newConfig.url));
|
|
||||||
|
|
||||||
if (shouldSendXSRF) {
|
if (shouldSendXSRF) {
|
||||||
const xsrfValue = xsrfHeaderName && xsrfCookieName && cookies.read(xsrfCookieName);
|
const xsrfValue = xsrfHeaderName && xsrfCookieName && cookies.read(xsrfCookieName);
|
||||||
|
|||||||
@@ -87,6 +87,31 @@ const parseNoProxyEntry = (entry) => {
|
|||||||
return [entryHost, entryPort];
|
return [entryHost, entryPort];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Convert IPv4-mapped IPv6 (::ffff:0:0/96 prefix) to IPv4 dotted form so both
|
||||||
|
// sides of a NO_PROXY comparison see the same canonical address. Without this,
|
||||||
|
// `NO_PROXY=192.168.1.5` would not match a request to `http://[::ffff:192.168.1.5]/`
|
||||||
|
// (Node's URL parser normalises that to `[::ffff:c0a8:105]`), and vice-versa,
|
||||||
|
// allowing the proxy-bypass policy to be circumvented by using the alternate
|
||||||
|
// representation. Returns the input unchanged when not IPv4-mapped.
|
||||||
|
const IPV4_MAPPED_DOTTED_RE = /^(?:::|(?:0{1,4}:){1,4}:|(?:0{1,4}:){5})ffff:(\d+\.\d+\.\d+\.\d+)$/i;
|
||||||
|
const IPV4_MAPPED_HEX_RE = /^(?:::|(?:0{1,4}:){1,4}:|(?:0{1,4}:){5})ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;
|
||||||
|
|
||||||
|
const unmapIPv4MappedIPv6 = (host) => {
|
||||||
|
if (typeof host !== 'string' || host.indexOf(':') === -1) return host;
|
||||||
|
|
||||||
|
const dotted = host.match(IPV4_MAPPED_DOTTED_RE);
|
||||||
|
if (dotted) return dotted[1];
|
||||||
|
|
||||||
|
const hex = host.match(IPV4_MAPPED_HEX_RE);
|
||||||
|
if (hex) {
|
||||||
|
const high = parseInt(hex[1], 16);
|
||||||
|
const low = parseInt(hex[2], 16);
|
||||||
|
return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return host;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeNoProxyHost = (hostname) => {
|
const normalizeNoProxyHost = (hostname) => {
|
||||||
if (!hostname) {
|
if (!hostname) {
|
||||||
return hostname;
|
return hostname;
|
||||||
@@ -96,7 +121,7 @@ const normalizeNoProxyHost = (hostname) => {
|
|||||||
hostname = hostname.slice(1, -1);
|
hostname = hostname.slice(1, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return hostname.replace(/\.+$/, '');
|
return unmapIPv4MappedIPv6(hostname.replace(/\.+$/, ''));
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function shouldBypassProxy(location) {
|
export default function shouldBypassProxy(location) {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function assertOptions(options, schema, allowUnknown) {
|
|||||||
while (i-- > 0) {
|
while (i-- > 0) {
|
||||||
const opt = keys[i];
|
const opt = keys[i];
|
||||||
// Use hasOwnProperty so a polluted Object.prototype.<opt> cannot supply
|
// Use hasOwnProperty so a polluted Object.prototype.<opt> cannot supply
|
||||||
// a non-function validator and cause a TypeError. See GHSA-q8qp-cvcw-x6jj.
|
// a non-function validator and cause a TypeError.
|
||||||
const validator = Object.prototype.hasOwnProperty.call(schema, opt) ? schema[opt] : undefined;
|
const validator = Object.prototype.hasOwnProperty.call(schema, opt) ? schema[opt] : undefined;
|
||||||
if (validator) {
|
if (validator) {
|
||||||
const value = options[opt];
|
const value = options[opt];
|
||||||
|
|||||||
+18
-6
@@ -199,7 +199,7 @@ const isFile = kindOfTest('File');
|
|||||||
*/
|
*/
|
||||||
const isReactNativeBlob = (value) => {
|
const isReactNativeBlob = (value) => {
|
||||||
return !!(value && typeof value.uri !== 'undefined');
|
return !!(value && typeof value.uri !== 'undefined');
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if environment is React Native
|
* Determine if environment is React Native
|
||||||
@@ -259,14 +259,16 @@ const FormDataCtor = typeof G.FormData !== 'undefined' ? G.FormData : undefined;
|
|||||||
const isFormData = (thing) => {
|
const isFormData = (thing) => {
|
||||||
if (!thing) return false;
|
if (!thing) return false;
|
||||||
if (FormDataCtor && thing instanceof FormDataCtor) return true;
|
if (FormDataCtor && thing instanceof FormDataCtor) return true;
|
||||||
// Reject plain objects inheriting directly from Object.prototype so prototype-pollution gadgets can't spoof FormData (GHSA-6chq-wfr3-2hj9).
|
// Reject plain objects inheriting directly from Object.prototype so prototype-pollution gadgets can't spoof FormData.
|
||||||
const proto = getPrototypeOf(thing);
|
const proto = getPrototypeOf(thing);
|
||||||
if (!proto || proto === Object.prototype) return false;
|
if (!proto || proto === Object.prototype) return false;
|
||||||
if (!isFunction(thing.append)) return false;
|
if (!isFunction(thing.append)) return false;
|
||||||
const kind = kindOf(thing);
|
const kind = kindOf(thing);
|
||||||
return kind === 'formdata' ||
|
return (
|
||||||
|
kind === 'formdata' ||
|
||||||
// detect form-data instance
|
// detect form-data instance
|
||||||
(kind === 'object' && isFunction(thing.toString) && thing.toString() === '[object FormData]');
|
(kind === 'object' && isFunction(thing.toString) && thing.toString() === '[object FormData]')
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -411,8 +413,12 @@ function merge(...objs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetKey = (caseless && findKey(result, key)) || key;
|
const targetKey = (caseless && findKey(result, key)) || key;
|
||||||
if (isPlainObject(result[targetKey]) && isPlainObject(val)) {
|
// Read via own-prop only — a bare `result[targetKey]` walks the prototype
|
||||||
result[targetKey] = merge(result[targetKey], val);
|
// chain, so a polluted Object.prototype value could surface here and get
|
||||||
|
// copied into the merged result.
|
||||||
|
const existing = hasOwnProperty(result, targetKey) ? result[targetKey] : undefined;
|
||||||
|
if (isPlainObject(existing) && isPlainObject(val)) {
|
||||||
|
result[targetKey] = merge(existing, val);
|
||||||
} else if (isPlainObject(val)) {
|
} else if (isPlainObject(val)) {
|
||||||
result[targetKey] = merge({}, val);
|
result[targetKey] = merge({}, val);
|
||||||
} else if (isArray(val)) {
|
} else if (isArray(val)) {
|
||||||
@@ -445,6 +451,9 @@ const extend = (a, b, thisArg, { allOwnKeys } = {}) => {
|
|||||||
(val, key) => {
|
(val, key) => {
|
||||||
if (thisArg && isFunction(val)) {
|
if (thisArg && isFunction(val)) {
|
||||||
Object.defineProperty(a, key, {
|
Object.defineProperty(a, key, {
|
||||||
|
// Null-proto descriptor so a polluted Object.prototype.get cannot
|
||||||
|
// hijack defineProperty's accessor-vs-data resolution.
|
||||||
|
__proto__: null,
|
||||||
value: bind(val, thisArg),
|
value: bind(val, thisArg),
|
||||||
writable: true,
|
writable: true,
|
||||||
enumerable: true,
|
enumerable: true,
|
||||||
@@ -452,6 +461,7 @@ const extend = (a, b, thisArg, { allOwnKeys } = {}) => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
Object.defineProperty(a, key, {
|
Object.defineProperty(a, key, {
|
||||||
|
__proto__: null,
|
||||||
value: val,
|
value: val,
|
||||||
writable: true,
|
writable: true,
|
||||||
enumerable: true,
|
enumerable: true,
|
||||||
@@ -490,12 +500,14 @@ const stripBOM = (content) => {
|
|||||||
const inherits = (constructor, superConstructor, props, descriptors) => {
|
const inherits = (constructor, superConstructor, props, descriptors) => {
|
||||||
constructor.prototype = Object.create(superConstructor.prototype, descriptors);
|
constructor.prototype = Object.create(superConstructor.prototype, descriptors);
|
||||||
Object.defineProperty(constructor.prototype, 'constructor', {
|
Object.defineProperty(constructor.prototype, 'constructor', {
|
||||||
|
__proto__: null,
|
||||||
value: constructor,
|
value: constructor,
|
||||||
writable: true,
|
writable: true,
|
||||||
enumerable: false,
|
enumerable: false,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
Object.defineProperty(constructor, 'super', {
|
Object.defineProperty(constructor, 'super', {
|
||||||
|
__proto__: null,
|
||||||
value: superConstructor.prototype,
|
value: superConstructor.prototype,
|
||||||
});
|
});
|
||||||
props && Object.assign(constructor.prototype, props);
|
props && Object.assign(constructor.prototype, props);
|
||||||
|
|||||||
@@ -41,6 +41,27 @@ describe('helpers::cookies (vitest browser)', () => {
|
|||||||
expect(cookies.read('bar')).toBe('def');
|
expect(cookies.read('bar')).toBe('def');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads cookies when the cookie separator has no following space', () => {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(document, 'cookie');
|
||||||
|
|
||||||
|
Object.defineProperty(document, 'cookie', {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return 'foo=abc;bar=def';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(cookies.read('bar')).toBe('def');
|
||||||
|
} finally {
|
||||||
|
if (descriptor) {
|
||||||
|
Object.defineProperty(document, 'cookie', descriptor);
|
||||||
|
} else {
|
||||||
|
delete document.cookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('removes cookies', () => {
|
it('removes cookies', () => {
|
||||||
cookies.write('foo', 'bar');
|
cookies.write('foo', 'bar');
|
||||||
cookies.remove('foo');
|
cookies.remove('foo');
|
||||||
@@ -53,4 +74,20 @@ describe('helpers::cookies (vitest browser)', () => {
|
|||||||
|
|
||||||
expect(document.cookie).toBe('foo=bar%20baz%25');
|
expect(document.cookie).toBe('foo=bar%20baz%25');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('matches cookie names exactly even when the name contains regex metacharacters', () => {
|
||||||
|
// previously cookies.read built a RegExp by interpolating
|
||||||
|
// the requested name. Metacharacters could match a different cookie or trigger
|
||||||
|
// catastrophic backtracking. A name such as "X.Y" must not match a cookie called
|
||||||
|
// "XAY" set by the same site.
|
||||||
|
cookies.write('XAY', 'wrong');
|
||||||
|
|
||||||
|
expect(cookies.read('X.Y')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return a partial match for a name that is a prefix of another cookie', () => {
|
||||||
|
cookies.write('xsrf-token-extra', 'wrong');
|
||||||
|
|
||||||
|
expect(cookies.read('xsrf-token')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -180,9 +180,9 @@ describe('xsrf (vitest browser)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// GHSA-xx6v-rp6x-q39c: non-boolean truthy withXSRFToken must not short-circuit
|
// Non-boolean truthy withXSRFToken must not short-circuit
|
||||||
// the same-origin check and leak the XSRF token cross-origin.
|
// the same-origin check and leak the XSRF token cross-origin.
|
||||||
describe('GHSA-xx6v-rp6x-q39c non-boolean withXSRFToken', () => {
|
describe('non-boolean withXSRFToken', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete Object.prototype.withXSRFToken;
|
delete Object.prototype.withXSRFToken;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -582,35 +582,39 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
|
|||||||
// Timing-sensitive: a 50ms abort race observed by a fake fetch can flake
|
// Timing-sensitive: a 50ms abort race observed by a fake fetch can flake
|
||||||
// under CI runner load even though the production code is fine. Retry as
|
// under CI runner load even though the production code is fine. Retry as
|
||||||
// a backstop.
|
// a backstop.
|
||||||
it('should surface ETIMEDOUT when fetch rejects with a broken DOMException on abort (Safari)', { retry: 2 }, async () => {
|
it(
|
||||||
const safariFetch = (url, init) => {
|
'should surface ETIMEDOUT when fetch rejects with a broken DOMException on abort (Safari)',
|
||||||
const signal = getFetchSignal(url, init);
|
{ retry: 2 },
|
||||||
|
async () => {
|
||||||
|
const safariFetch = (url, init) => {
|
||||||
|
const signal = getFetchSignal(url, init);
|
||||||
|
|
||||||
return new Promise((_resolve, reject) => {
|
return new Promise((_resolve, reject) => {
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
signal.removeEventListener('abort', onAbort);
|
signal.removeEventListener('abort', onAbort);
|
||||||
reject(createBrokenDOMExceptionLikeError());
|
reject(createBrokenDOMExceptionLikeError());
|
||||||
};
|
};
|
||||||
|
|
||||||
if (signal.aborted) return onAbort();
|
if (signal.aborted) return onAbort();
|
||||||
signal.addEventListener('abort', onAbort);
|
signal.addEventListener('abort', onAbort);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() =>
|
() =>
|
||||||
fetchAxios.get('/', {
|
fetchAxios.get('/', {
|
||||||
timeout: 50,
|
timeout: 50,
|
||||||
env: { fetch: safariFetch },
|
env: { fetch: safariFetch },
|
||||||
}),
|
}),
|
||||||
(err) => {
|
(err) => {
|
||||||
assert.strictEqual(err.name, 'AxiosError');
|
assert.strictEqual(err.name, 'AxiosError');
|
||||||
assert.strictEqual(err.code, 'ETIMEDOUT');
|
assert.strictEqual(err.code, 'ETIMEDOUT');
|
||||||
assert.match(err.message, /timeout of 50ms exceeded/);
|
assert.match(err.message, /timeout of 50ms exceeded/);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should combine baseURL and url', async () => {
|
it('should combine baseURL and url', async () => {
|
||||||
@@ -629,7 +633,9 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
|
|||||||
const server = await startHTTPServer(
|
const server = await startHTTPServer(
|
||||||
(req, res) => {
|
(req, res) => {
|
||||||
let body = '';
|
let body = '';
|
||||||
req.on('data', (chunk) => { body += chunk; });
|
req.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
req.on('end', () => {
|
req.on('end', () => {
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
res.end(JSON.stringify({ method: req.method, url: req.url, body }));
|
res.end(JSON.stringify({ method: req.method, url: req.url, body }));
|
||||||
@@ -639,10 +645,9 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await fetchAxios.query(
|
const { data } = await fetchAxios.query(`http://localhost:${server.address().port}/search`, {
|
||||||
`http://localhost:${server.address().port}/search`,
|
selector: 'field1',
|
||||||
{ selector: 'field1' }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
assert.strictEqual(data.method, 'QUERY');
|
assert.strictEqual(data.method, 'QUERY');
|
||||||
assert.strictEqual(data.url, '/search');
|
assert.strictEqual(data.url, '/search');
|
||||||
@@ -945,7 +950,7 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('size limits (GHSA-777c-7fjr-54vf)', () => {
|
describe('size limits', () => {
|
||||||
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) => {
|
||||||
@@ -1034,20 +1039,18 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
|
|||||||
|
|
||||||
it('should reject a data: URL whose decoded size exceeds maxContentLength (base64)', async () => {
|
it('should reject a data: URL whose decoded size exceeds maxContentLength (base64)', async () => {
|
||||||
const payload = 'A'.repeat(4096);
|
const payload = 'A'.repeat(4096);
|
||||||
const dataUrl = 'data:application/octet-stream;base64,' + Buffer.from(payload).toString('base64');
|
const dataUrl =
|
||||||
|
'data:application/octet-stream;base64,' + Buffer.from(payload).toString('base64');
|
||||||
|
|
||||||
// Use a dedicated instance without baseURL — combineURLs would otherwise
|
// Use a dedicated instance without baseURL — combineURLs would otherwise
|
||||||
// prepend baseURL to a data: URL and neutralise the pre-check.
|
// prepend baseURL to a data: URL and neutralise the pre-check.
|
||||||
const bareAxios = axios.create({ adapter: 'fetch' });
|
const bareAxios = axios.create({ adapter: 'fetch' });
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(bareAxios.get(dataUrl, { maxContentLength: 16 }), (err) => {
|
||||||
bareAxios.get(dataUrl, { maxContentLength: 16 }),
|
assert.strictEqual(err.code, 'ERR_BAD_RESPONSE');
|
||||||
(err) => {
|
assert.match(err.message, /maxContentLength size of 16 exceeded/);
|
||||||
assert.strictEqual(err.code, 'ERR_BAD_RESPONSE');
|
return true;
|
||||||
assert.match(err.message, /maxContentLength size of 16 exceeded/);
|
});
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject a data: URL whose body size exceeds maxContentLength (non-base64)', async () => {
|
it('should reject a data: URL whose body size exceeds maxContentLength (non-base64)', async () => {
|
||||||
@@ -1055,14 +1058,11 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
|
|||||||
|
|
||||||
const bareAxios = axios.create({ adapter: 'fetch' });
|
const bareAxios = axios.create({ adapter: 'fetch' });
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(bareAxios.get(dataUrl, { maxContentLength: 16 }), (err) => {
|
||||||
bareAxios.get(dataUrl, { maxContentLength: 16 }),
|
assert.strictEqual(err.code, 'ERR_BAD_RESPONSE');
|
||||||
(err) => {
|
assert.match(err.message, /maxContentLength size of 16 exceeded/);
|
||||||
assert.strictEqual(err.code, 'ERR_BAD_RESPONSE');
|
return true;
|
||||||
assert.match(err.message, /maxContentLength size of 16 exceeded/);
|
});
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow a response at or below maxContentLength', async () => {
|
it('should allow a response at or below maxContentLength', async () => {
|
||||||
|
|||||||
+410
-170
@@ -1012,9 +1012,7 @@ describe('supports http with nodejs', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(
|
const response = await axios.get(`http://user%:foo%zz@localhost:${server.address().port}/`);
|
||||||
`http://user%:foo%zz@localhost:${server.address().port}/`
|
|
||||||
);
|
|
||||||
const base64 = Buffer.from('user%:foo%zz', 'utf8').toString('base64');
|
const base64 = Buffer.from('user%:foo%zz', 'utf8').toString('base64');
|
||||||
assert.strictEqual(response.data, `Basic ${base64}`);
|
assert.strictEqual(response.data, `Basic ${base64}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1184,7 +1182,7 @@ describe('supports http with nodejs', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should enforce maxContentLength for streamed responses (GHSA-vf2m-468p-8v99)', async () => {
|
it('should enforce maxContentLength for streamed responses', async () => {
|
||||||
const size = 2 * 1024 * 1024;
|
const size = 2 * 1024 * 1024;
|
||||||
const body = Buffer.alloc(size, 0x63);
|
const body = Buffer.alloc(size, 0x63);
|
||||||
const server = await startHTTPServer(
|
const server = await startHTTPServer(
|
||||||
@@ -1203,17 +1201,16 @@ describe('supports http with nodejs', () => {
|
|||||||
|
|
||||||
let bytesRead = 0;
|
let bytesRead = 0;
|
||||||
const err = await new Promise((resolve) => {
|
const err = await new Promise((resolve) => {
|
||||||
response.data.on('data', (chunk) => { bytesRead += chunk.length; });
|
response.data.on('data', (chunk) => {
|
||||||
|
bytesRead += chunk.length;
|
||||||
|
});
|
||||||
response.data.on('error', resolve);
|
response.data.on('error', resolve);
|
||||||
response.data.on('end', () => resolve(null));
|
response.data.on('end', () => resolve(null));
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.ok(err, 'stream should emit an error');
|
assert.ok(err, 'stream should emit an error');
|
||||||
assert.strictEqual(err.message, 'maxContentLength size of 1024 exceeded');
|
assert.strictEqual(err.message, 'maxContentLength size of 1024 exceeded');
|
||||||
assert.ok(
|
assert.ok(bytesRead <= 1024 * 64, `stream should not deliver full payload; got ${bytesRead}`);
|
||||||
bytesRead <= 1024 * 64,
|
|
||||||
`stream should not deliver full payload; got ${bytesRead}`
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
await stopHTTPServer(server);
|
await stopHTTPServer(server);
|
||||||
}
|
}
|
||||||
@@ -1248,7 +1245,7 @@ describe('supports http with nodejs', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should enforce maxBodyLength for streamed uploads with maxRedirects: 0 (GHSA-5c9x-8gcm-mpgx)', async () => {
|
it('should enforce maxBodyLength for streamed uploads with maxRedirects: 0', async () => {
|
||||||
let bytesReceived = 0;
|
let bytesReceived = 0;
|
||||||
const server = await startHTTPServer(
|
const server = await startHTTPServer(
|
||||||
(req, res) => {
|
(req, res) => {
|
||||||
@@ -1308,15 +1305,11 @@ describe('supports http with nodejs', () => {
|
|||||||
const payload = Buffer.alloc(512, 0x62);
|
const payload = Buffer.alloc(512, 0x62);
|
||||||
const source = stream.Readable.from([payload]);
|
const source = stream.Readable.from([payload]);
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(`http://localhost:${server.address().port}/`, source, {
|
||||||
`http://localhost:${server.address().port}/`,
|
maxBodyLength: 1024,
|
||||||
source,
|
maxRedirects: 0,
|
||||||
{
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
maxBodyLength: 1024,
|
});
|
||||||
maxRedirects: 0,
|
|
||||||
headers: { 'Content-Type': 'application/octet-stream' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.strictEqual(response.data.received, payload.length);
|
assert.strictEqual(response.data.received, payload.length);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2559,9 +2552,28 @@ describe('supports http with nodejs', () => {
|
|||||||
assert.strictEqual(options.headers.Host, 'example.com');
|
assert.strictEqual(options.headers.Host, 'example.com');
|
||||||
assert.strictEqual(options.headers.host, undefined);
|
assert.strictEqual(options.headers.host, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores polluted prototype Host fields when detecting user-supplied headers', () => {
|
||||||
|
Object.prototype.host = 'polluted.example.com';
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
headers: {},
|
||||||
|
beforeRedirects: {},
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: 4000,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
__setProxy(options, proxyConfig, 'http://127.0.0.1:4000/');
|
||||||
|
|
||||||
|
assert.strictEqual(options.headers.host, '127.0.0.1:4000');
|
||||||
|
} finally {
|
||||||
|
delete Object.prototype.host;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Proxy-Authorization header leak on redirect (GHSA-j5f8-grm9-p9fc)', () => {
|
describe('Proxy-Authorization header leak on redirect', () => {
|
||||||
it('clears a stale Proxy-Authorization header when redirected request resolves to no proxy (configProxy=false)', () => {
|
it('clears a stale Proxy-Authorization header when redirected request resolves to no proxy (configProxy=false)', () => {
|
||||||
const options = {
|
const options = {
|
||||||
headers: {},
|
headers: {},
|
||||||
@@ -2571,7 +2583,11 @@ describe('supports http with nodejs', () => {
|
|||||||
port: 80,
|
port: 80,
|
||||||
};
|
};
|
||||||
|
|
||||||
__setProxy(options, { host: '127.0.0.1', port: 8030, auth: { username: 'user', password: 'pass' } }, 'http://initial.example.com/start');
|
__setProxy(
|
||||||
|
options,
|
||||||
|
{ host: '127.0.0.1', port: 8030, auth: { username: 'user', password: 'pass' } },
|
||||||
|
'http://initial.example.com/start'
|
||||||
|
);
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
options.headers['Proxy-Authorization'],
|
options.headers['Proxy-Authorization'],
|
||||||
'Basic ' + Buffer.from('user:pass', 'utf8').toString('base64'),
|
'Basic ' + Buffer.from('user:pass', 'utf8').toString('base64'),
|
||||||
@@ -2637,9 +2653,12 @@ describe('supports http with nodejs', () => {
|
|||||||
'stale Proxy-Authorization must be stripped when redirect target is covered by NO_PROXY'
|
'stale Proxy-Authorization must be stripped when redirect target is covered by NO_PROXY'
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (originalHttpProxy === undefined) delete process.env.http_proxy; else process.env.http_proxy = originalHttpProxy;
|
if (originalHttpProxy === undefined) delete process.env.http_proxy;
|
||||||
if (originalHttpsProxy === undefined) delete process.env.https_proxy; else process.env.https_proxy = originalHttpsProxy;
|
else process.env.http_proxy = originalHttpProxy;
|
||||||
if (originalNoProxy === undefined) delete process.env.no_proxy; else process.env.no_proxy = originalNoProxy;
|
if (originalHttpsProxy === undefined) delete process.env.https_proxy;
|
||||||
|
else process.env.https_proxy = originalHttpsProxy;
|
||||||
|
if (originalNoProxy === undefined) delete process.env.no_proxy;
|
||||||
|
else process.env.no_proxy = originalNoProxy;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2652,8 +2671,15 @@ describe('supports http with nodejs', () => {
|
|||||||
port: 80,
|
port: 80,
|
||||||
};
|
};
|
||||||
|
|
||||||
__setProxy(options, { host: '127.0.0.1', port: 8030, auth: { username: 'user', password: 'pass' } }, 'http://initial.example.com/start');
|
__setProxy(
|
||||||
assert.ok(options.headers['Proxy-Authorization'], 'precondition: initial proxy auth header set');
|
options,
|
||||||
|
{ host: '127.0.0.1', port: 8030, auth: { username: 'user', password: 'pass' } },
|
||||||
|
'http://initial.example.com/start'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
options.headers['Proxy-Authorization'],
|
||||||
|
'precondition: initial proxy auth header set'
|
||||||
|
);
|
||||||
|
|
||||||
const redirectOptions = {
|
const redirectOptions = {
|
||||||
headers: { ...options.headers },
|
headers: { ...options.headers },
|
||||||
@@ -2662,7 +2688,12 @@ describe('supports http with nodejs', () => {
|
|||||||
host: 'second.example.com',
|
host: 'second.example.com',
|
||||||
port: 80,
|
port: 80,
|
||||||
};
|
};
|
||||||
__setProxy(redirectOptions, { host: '127.0.0.2', port: 8031 }, 'http://second.example.com/final', true);
|
__setProxy(
|
||||||
|
redirectOptions,
|
||||||
|
{ host: '127.0.0.2', port: 8031 },
|
||||||
|
'http://second.example.com/final',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
redirectOptions.headers['Proxy-Authorization'],
|
redirectOptions.headers['Proxy-Authorization'],
|
||||||
@@ -2673,7 +2704,9 @@ describe('supports http with nodejs', () => {
|
|||||||
|
|
||||||
it('strips stale Proxy-Authorization when the beforeRedirects.proxy hook is invoked with configProxy=false', () => {
|
it('strips stale Proxy-Authorization when the beforeRedirects.proxy hook is invoked with configProxy=false', () => {
|
||||||
const options = {
|
const options = {
|
||||||
headers: { 'Proxy-Authorization': 'Basic ' + Buffer.from('user:pass', 'utf8').toString('base64') },
|
headers: {
|
||||||
|
'Proxy-Authorization': 'Basic ' + Buffer.from('user:pass', 'utf8').toString('base64'),
|
||||||
|
},
|
||||||
beforeRedirects: {},
|
beforeRedirects: {},
|
||||||
hostname: 'initial.example.com',
|
hostname: 'initial.example.com',
|
||||||
host: 'initial.example.com',
|
host: 'initial.example.com',
|
||||||
@@ -2681,10 +2714,16 @@ describe('supports http with nodejs', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
__setProxy(options, false, 'http://initial.example.com/start');
|
__setProxy(options, false, 'http://initial.example.com/start');
|
||||||
assert.strictEqual(typeof options.beforeRedirects.proxy, 'function', 'initial setProxy must install redirect hook');
|
assert.strictEqual(
|
||||||
|
typeof options.beforeRedirects.proxy,
|
||||||
|
'function',
|
||||||
|
'initial setProxy must install redirect hook'
|
||||||
|
);
|
||||||
|
|
||||||
const redirectOptions = {
|
const redirectOptions = {
|
||||||
headers: { 'Proxy-Authorization': 'Basic ' + Buffer.from('user:pass', 'utf8').toString('base64') },
|
headers: {
|
||||||
|
'Proxy-Authorization': 'Basic ' + Buffer.from('user:pass', 'utf8').toString('base64'),
|
||||||
|
},
|
||||||
beforeRedirects: {},
|
beforeRedirects: {},
|
||||||
hostname: 'attacker.example.com',
|
hostname: 'attacker.example.com',
|
||||||
host: 'attacker.example.com',
|
host: 'attacker.example.com',
|
||||||
@@ -2722,7 +2761,12 @@ describe('supports http with nodejs', () => {
|
|||||||
|
|
||||||
it('strips stale Proxy-Authorization regardless of header key casing', () => {
|
it('strips stale Proxy-Authorization regardless of header key casing', () => {
|
||||||
const staleValue = 'Basic ' + Buffer.from('user:pass', 'utf8').toString('base64');
|
const staleValue = 'Basic ' + Buffer.from('user:pass', 'utf8').toString('base64');
|
||||||
const casings = ['proxy-authorization', 'PROXY-AUTHORIZATION', 'Proxy-authorization', 'pRoXy-AuThOrIzAtIoN'];
|
const casings = [
|
||||||
|
'proxy-authorization',
|
||||||
|
'PROXY-AUTHORIZATION',
|
||||||
|
'Proxy-authorization',
|
||||||
|
'pRoXy-AuThOrIzAtIoN',
|
||||||
|
];
|
||||||
|
|
||||||
for (const casing of casings) {
|
for (const casing of casings) {
|
||||||
const redirectOptions = {
|
const redirectOptions = {
|
||||||
@@ -2745,6 +2789,77 @@ describe('supports http with nodejs', () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// End-to-end exercise of the redirect leak. An
|
||||||
|
// authenticated env-supplied proxy sees the initial request, 302s the
|
||||||
|
// client to a target that NO_PROXY excludes, and the redirected request
|
||||||
|
// must not carry the stale Proxy-Authorization to the direct target.
|
||||||
|
it('does not forward Proxy-Authorization to a redirect target that resolves to no-proxy', async () => {
|
||||||
|
const startServer = (handler) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const s = http.createServer(handler);
|
||||||
|
s.listen(0, '127.0.0.1', () => resolve(s));
|
||||||
|
});
|
||||||
|
const stop = (s) => new Promise((r) => s.close(r));
|
||||||
|
|
||||||
|
let attackerPort;
|
||||||
|
const proxySaw = [];
|
||||||
|
const attackerSaw = [];
|
||||||
|
|
||||||
|
// The proxy receives the absolute-form URL (`GET http://target/path`) on
|
||||||
|
// the initial request, then forwards to the destination. We short-circuit
|
||||||
|
// by responding directly with the redirect.
|
||||||
|
const corpProxy = await startServer((req, res) => {
|
||||||
|
proxySaw.push({ url: req.url, proxyAuth: req.headers['proxy-authorization'] });
|
||||||
|
res.writeHead(302, { Location: `http://127.0.0.1:${attackerPort}/final` });
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const attacker = await startServer((req, res) => {
|
||||||
|
attackerSaw.push({
|
||||||
|
url: req.url,
|
||||||
|
proxyAuth: req.headers['proxy-authorization'],
|
||||||
|
authorization: req.headers.authorization,
|
||||||
|
});
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end('{"final":true}');
|
||||||
|
});
|
||||||
|
attackerPort = attacker.address().port;
|
||||||
|
|
||||||
|
const corpProxyPort = corpProxy.address().port;
|
||||||
|
const originalHttpProxy = process.env.http_proxy;
|
||||||
|
const originalNoProxy = process.env.no_proxy;
|
||||||
|
process.env.http_proxy = `http://user:pass@127.0.0.1:${corpProxyPort}`;
|
||||||
|
// NO_PROXY entry covers only the attacker target (port-specific), so the
|
||||||
|
// initial request still uses the proxy but the redirect resolves direct.
|
||||||
|
process.env.no_proxy = `127.0.0.1:${attackerPort}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.get('http://example.com/start');
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
proxySaw.some((h) => h.proxyAuth),
|
||||||
|
'precondition: corp proxy must see Proxy-Authorization on the initial request'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
attackerSaw.length,
|
||||||
|
1,
|
||||||
|
'attacker target must receive exactly the redirected request'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
attackerSaw[0].proxyAuth,
|
||||||
|
undefined,
|
||||||
|
'stale Proxy-Authorization must not leak to the redirect target'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (originalHttpProxy === undefined) delete process.env.http_proxy;
|
||||||
|
else process.env.http_proxy = originalHttpProxy;
|
||||||
|
if (originalNoProxy === undefined) delete process.env.no_proxy;
|
||||||
|
else process.env.no_proxy = originalNoProxy;
|
||||||
|
await stop(corpProxy);
|
||||||
|
await stop(attacker);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support cancel', async () => {
|
it('should support cancel', async () => {
|
||||||
@@ -3105,7 +3220,7 @@ describe('supports http with nodejs', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('prototype pollution (GHSA-6chq-wfr3-2hj9)', () => {
|
describe('prototype pollution', () => {
|
||||||
const pollutedKeys = ['getHeaders', 'append', 'pipe', 'on', 'once'];
|
const pollutedKeys = ['getHeaders', 'append', 'pipe', 'on', 'once'];
|
||||||
const toStringTagSym = Symbol.toStringTag;
|
const toStringTagSym = Symbol.toStringTag;
|
||||||
|
|
||||||
@@ -3114,11 +3229,18 @@ describe('supports http with nodejs', () => {
|
|||||||
Object.prototype.append = () => {};
|
Object.prototype.append = () => {};
|
||||||
Object.prototype.getHeaders = () => ({
|
Object.prototype.getHeaders = () => ({
|
||||||
'x-injected': 'attacker',
|
'x-injected': 'attacker',
|
||||||
'authorization': 'Bearer ATTACKER_TOKEN',
|
authorization: 'Bearer ATTACKER_TOKEN',
|
||||||
});
|
});
|
||||||
Object.prototype.pipe = function (d) { if (d && d.end) d.end(); return d; };
|
Object.prototype.pipe = function (d) {
|
||||||
Object.prototype.on = function () { return this; };
|
if (d && d.end) d.end();
|
||||||
Object.prototype.once = function () { return this; };
|
return d;
|
||||||
|
};
|
||||||
|
Object.prototype.on = function () {
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
Object.prototype.once = function () {
|
||||||
|
return this;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
@@ -3161,7 +3283,7 @@ describe('supports http with nodejs', () => {
|
|||||||
'http://stub.invalid/',
|
'http://stub.invalid/',
|
||||||
{ userId: 42 },
|
{ userId: 42 },
|
||||||
{
|
{
|
||||||
headers: { 'Authorization': 'Bearer VALID_USER_TOKEN' },
|
headers: { Authorization: 'Bearer VALID_USER_TOKEN' },
|
||||||
transport: stubTransport,
|
transport: stubTransport,
|
||||||
maxRedirects: 0,
|
maxRedirects: 0,
|
||||||
}
|
}
|
||||||
@@ -3176,6 +3298,97 @@ describe('supports http with nodejs', () => {
|
|||||||
assert.notStrictEqual(capturedHeaders['authorization'], 'Bearer ATTACKER_TOKEN');
|
assert.notStrictEqual(capturedHeaders['authorization'], 'Bearer ATTACKER_TOKEN');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formDataHeaderPolicy', () => {
|
||||||
|
function createStubTransport(captureHeaders) {
|
||||||
|
return {
|
||||||
|
request(options, handleResponse) {
|
||||||
|
captureHeaders({ ...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 = {};
|
||||||
|
res.rawHeaders = [];
|
||||||
|
res.req = req;
|
||||||
|
process.nextTick(() => {
|
||||||
|
handleResponse(res);
|
||||||
|
res.push(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return req;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomFormData extends stream.Readable {
|
||||||
|
_read() {
|
||||||
|
this.push(null);
|
||||||
|
}
|
||||||
|
append() {}
|
||||||
|
getHeaders() {
|
||||||
|
return {
|
||||||
|
'content-type': 'multipart/form-data; boundary=----fake',
|
||||||
|
'x-injected': 'custom',
|
||||||
|
'x-forwarded-for': '10.0.0.1',
|
||||||
|
authorization: 'Bearer CUSTOM_TOKEN',
|
||||||
|
host: 'custom.example.com',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
get [Symbol.toStringTag]() {
|
||||||
|
return 'FormData';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('preserves legacy getHeaders() propagation by default', async () => {
|
||||||
|
let capturedHeaders;
|
||||||
|
|
||||||
|
await axios.post('http://stub.invalid/', new CustomFormData(), {
|
||||||
|
transport: createStubTransport((headers) => {
|
||||||
|
capturedHeaders = headers;
|
||||||
|
}),
|
||||||
|
maxRedirects: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(capturedHeaders, 'transport was not invoked');
|
||||||
|
const ct = capturedHeaders['Content-Type'] || capturedHeaders['content-type'];
|
||||||
|
assert.match(ct, /multipart\/form-data/);
|
||||||
|
assert.strictEqual(capturedHeaders['x-injected'], 'custom');
|
||||||
|
assert.strictEqual(capturedHeaders['x-forwarded-for'], '10.0.0.1');
|
||||||
|
assert.strictEqual(
|
||||||
|
capturedHeaders.Authorization || capturedHeaders.authorization,
|
||||||
|
'Bearer CUSTOM_TOKEN'
|
||||||
|
);
|
||||||
|
assert.strictEqual(capturedHeaders.Host || capturedHeaders.host, 'custom.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only copies content headers when formDataHeaderPolicy is content-only', async () => {
|
||||||
|
let capturedHeaders;
|
||||||
|
|
||||||
|
await axios.post('http://stub.invalid/', new CustomFormData(), {
|
||||||
|
transport: createStubTransport((headers) => {
|
||||||
|
capturedHeaders = headers;
|
||||||
|
}),
|
||||||
|
maxRedirects: 0,
|
||||||
|
formDataHeaderPolicy: 'content-only',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(capturedHeaders, 'transport was not invoked');
|
||||||
|
const ct = capturedHeaders['Content-Type'] || capturedHeaders['content-type'];
|
||||||
|
assert.match(ct, /multipart\/form-data/);
|
||||||
|
assert.strictEqual(capturedHeaders['x-injected'], undefined);
|
||||||
|
assert.strictEqual(capturedHeaders['x-forwarded-for'], undefined);
|
||||||
|
assert.strictEqual(
|
||||||
|
capturedHeaders.Authorization || capturedHeaders.authorization,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.strictEqual(capturedHeaders.Host || capturedHeaders.host, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toFormData helper', () => {
|
describe('toFormData helper', () => {
|
||||||
@@ -4348,126 +4561,137 @@ describe('supports http with nodejs', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use different sessions for requests with different http2Options set', { retry: 2 }, async () => {
|
it(
|
||||||
const server = await startHTTPServer(
|
'should use different sessions for requests with different http2Options set',
|
||||||
(req, res) => {
|
{ retry: 2 },
|
||||||
setTimeout(() => {
|
async () => {
|
||||||
res.end('OK');
|
const server = await startHTTPServer(
|
||||||
}, 1000);
|
(req, res) => {
|
||||||
},
|
setTimeout(() => {
|
||||||
{
|
res.end('OK');
|
||||||
useHTTP2: true,
|
}, 1000);
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const localServerURL = `https://localhost:${server.address().port}`;
|
|
||||||
const http2Axios = createHttp2Axios(localServerURL);
|
|
||||||
|
|
||||||
const [response1, response2] = await Promise.all([
|
|
||||||
http2Axios.get(localServerURL, {
|
|
||||||
http2Options: {
|
|
||||||
sessionTimeout: 2000,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
http2Axios.get(localServerURL, {
|
|
||||||
http2Options: {
|
|
||||||
sessionTimeout: 4000,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.notStrictEqual(response1.request.session, response2.request.session);
|
|
||||||
assert.deepStrictEqual([response1.data, response2.data], ['OK', 'OK']);
|
|
||||||
} finally {
|
|
||||||
await stopHTTPServer(server);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use the same session for request with the same resolved http2Options set', { retry: 2 }, async () => {
|
|
||||||
const server = await startHTTPServer(
|
|
||||||
(req, res) => {
|
|
||||||
setTimeout(() => res.end('OK'), 1000);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
useHTTP2: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const localServerURL = `https://localhost:${server.address().port}`;
|
|
||||||
const http2Axios = createHttp2Axios(localServerURL);
|
|
||||||
|
|
||||||
const responses = await Promise.all([
|
|
||||||
http2Axios.get(localServerURL, {
|
|
||||||
responseType: 'stream',
|
|
||||||
}),
|
|
||||||
http2Axios.get(localServerURL, {
|
|
||||||
responseType: 'stream',
|
|
||||||
http2Options: undefined,
|
|
||||||
}),
|
|
||||||
http2Axios.get(localServerURL, {
|
|
||||||
responseType: 'stream',
|
|
||||||
http2Options: {},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.strictEqual(responses[1].data.session, responses[0].data.session);
|
|
||||||
assert.strictEqual(responses[2].data.session, responses[0].data.session);
|
|
||||||
|
|
||||||
assert.deepStrictEqual(await Promise.all(responses.map(({ data }) => getStream(data))), [
|
|
||||||
'OK',
|
|
||||||
'OK',
|
|
||||||
'OK',
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
await stopHTTPServer(server);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use different sessions after previous session timeout', { retry: 2, timeout: 15000 }, async () => {
|
|
||||||
const server = await startHTTPServer(
|
|
||||||
(req, res) => {
|
|
||||||
setTimeout(() => res.end('OK'), 100);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
useHTTP2: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const localServerURL = `https://localhost:${server.address().port}`;
|
|
||||||
const http2Axios = createHttp2Axios(localServerURL);
|
|
||||||
|
|
||||||
const response1 = await http2Axios.get(localServerURL, {
|
|
||||||
responseType: 'stream',
|
|
||||||
http2Options: {
|
|
||||||
sessionTimeout: 1000,
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
useHTTP2: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const session1 = response1.data.session;
|
try {
|
||||||
const data1 = await getStream(response1.data);
|
const localServerURL = `https://localhost:${server.address().port}`;
|
||||||
|
const http2Axios = createHttp2Axios(localServerURL);
|
||||||
|
|
||||||
await setTimeoutAsync(5000);
|
const [response1, response2] = await Promise.all([
|
||||||
|
http2Axios.get(localServerURL, {
|
||||||
|
http2Options: {
|
||||||
|
sessionTimeout: 2000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
http2Axios.get(localServerURL, {
|
||||||
|
http2Options: {
|
||||||
|
sessionTimeout: 4000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const response2 = await http2Axios.get(localServerURL, {
|
assert.notStrictEqual(response1.request.session, response2.request.session);
|
||||||
responseType: 'stream',
|
assert.deepStrictEqual([response1.data, response2.data], ['OK', 'OK']);
|
||||||
http2Options: {
|
} finally {
|
||||||
sessionTimeout: 1000,
|
await stopHTTPServer(server);
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const session2 = response2.data.session;
|
|
||||||
const data2 = await getStream(response2.data);
|
|
||||||
|
|
||||||
assert.notStrictEqual(session1, session2);
|
|
||||||
assert.strictEqual(data1, 'OK');
|
|
||||||
assert.strictEqual(data2, 'OK');
|
|
||||||
} finally {
|
|
||||||
await stopHTTPServer(server);
|
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should use the same session for request with the same resolved http2Options set',
|
||||||
|
{ retry: 2 },
|
||||||
|
async () => {
|
||||||
|
const server = await startHTTPServer(
|
||||||
|
(req, res) => {
|
||||||
|
setTimeout(() => res.end('OK'), 1000);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
useHTTP2: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localServerURL = `https://localhost:${server.address().port}`;
|
||||||
|
const http2Axios = createHttp2Axios(localServerURL);
|
||||||
|
|
||||||
|
const responses = await Promise.all([
|
||||||
|
http2Axios.get(localServerURL, {
|
||||||
|
responseType: 'stream',
|
||||||
|
}),
|
||||||
|
http2Axios.get(localServerURL, {
|
||||||
|
responseType: 'stream',
|
||||||
|
http2Options: undefined,
|
||||||
|
}),
|
||||||
|
http2Axios.get(localServerURL, {
|
||||||
|
responseType: 'stream',
|
||||||
|
http2Options: {},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.strictEqual(responses[1].data.session, responses[0].data.session);
|
||||||
|
assert.strictEqual(responses[2].data.session, responses[0].data.session);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
await Promise.all(responses.map(({ data }) => getStream(data))),
|
||||||
|
['OK', 'OK', 'OK']
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stopHTTPServer(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should use different sessions after previous session timeout',
|
||||||
|
{ retry: 2, timeout: 15000 },
|
||||||
|
async () => {
|
||||||
|
const server = await startHTTPServer(
|
||||||
|
(req, res) => {
|
||||||
|
setTimeout(() => res.end('OK'), 100);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
useHTTP2: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localServerURL = `https://localhost:${server.address().port}`;
|
||||||
|
const http2Axios = createHttp2Axios(localServerURL);
|
||||||
|
|
||||||
|
const response1 = await http2Axios.get(localServerURL, {
|
||||||
|
responseType: 'stream',
|
||||||
|
http2Options: {
|
||||||
|
sessionTimeout: 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const session1 = response1.data.session;
|
||||||
|
const data1 = await getStream(response1.data);
|
||||||
|
|
||||||
|
await setTimeoutAsync(5000);
|
||||||
|
|
||||||
|
const response2 = await http2Axios.get(localServerURL, {
|
||||||
|
responseType: 'stream',
|
||||||
|
http2Options: {
|
||||||
|
sessionTimeout: 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const session2 = response2.data.session;
|
||||||
|
const data2 = await getStream(response2.data);
|
||||||
|
|
||||||
|
assert.notStrictEqual(session1, session2);
|
||||||
|
assert.strictEqual(data1, 'OK');
|
||||||
|
assert.strictEqual(data2, 'OK');
|
||||||
|
} finally {
|
||||||
|
await stopHTTPServer(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4922,11 +5146,18 @@ describe('supports http with nodejs', () => {
|
|||||||
await setTimeoutAsync(0);
|
await setTimeoutAsync(0);
|
||||||
|
|
||||||
const firstReq = createdReqs[0];
|
const firstReq = createdReqs[0];
|
||||||
assert.ok(firstReq && firstReq.destroyed === false, 'first request must not have been destroyed by a socket error');
|
assert.ok(
|
||||||
|
firstReq && firstReq.destroyed === false,
|
||||||
|
'first request must not have been destroyed by a socket error'
|
||||||
|
);
|
||||||
|
|
||||||
// Stray socket error after first req has closed: must not destroy firstReq.
|
// Stray socket error after first req has closed: must not destroy firstReq.
|
||||||
socket.emit('error', new Error('stray error after close'));
|
socket.emit('error', new Error('stray error after close'));
|
||||||
assert.strictEqual(firstReq.destroyed, false, 'socket error after close must not destroy the old request');
|
assert.strictEqual(
|
||||||
|
firstReq.destroyed,
|
||||||
|
false,
|
||||||
|
'socket error after close must not destroy the old request'
|
||||||
|
);
|
||||||
|
|
||||||
// Second request claims the socket, then its socket errors. It should reject.
|
// Second request claims the socket, then its socket errors. It should reject.
|
||||||
const err = await axios
|
const err = await axios
|
||||||
@@ -4937,7 +5168,11 @@ describe('supports http with nodejs', () => {
|
|||||||
assert.strictEqual(err.code, 'EPIPE');
|
assert.strictEqual(err.code, 'EPIPE');
|
||||||
|
|
||||||
const secondReq = createdReqs[1];
|
const secondReq = createdReqs[1];
|
||||||
assert.strictEqual(secondReq.destroyed, true, 'second request should be destroyed by its own active socket error');
|
assert.strictEqual(
|
||||||
|
secondReq.destroyed,
|
||||||
|
true,
|
||||||
|
'second request should be destroyed by its own active socket error'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5077,13 +5312,13 @@ describe('supports http with nodejs', () => {
|
|||||||
}, 30000);
|
}, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('socketPath security (GHSA-j96w-fp6f-pq6v)', () => {
|
describe('socketPath security', () => {
|
||||||
function makeSocketPath() {
|
function makeSocketPath() {
|
||||||
const pipe = `axios-socketpath-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const pipe = `axios-socketpath-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
return os.platform() === 'win32' ?
|
return os.platform() === 'win32'
|
||||||
`\\\\.\\pipe\\${pipe}` :
|
? `\\\\.\\pipe\\${pipe}`
|
||||||
path.join(os.tmpdir(), `${pipe}.sock`);
|
: path.join(os.tmpdir(), `${pipe}.sock`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startUnixServer(socketPath) {
|
function startUnixServer(socketPath) {
|
||||||
@@ -5092,7 +5327,11 @@ describe('supports http with nodejs', () => {
|
|||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ ok: true, url: req.url }));
|
res.end(JSON.stringify({ ok: true, url: req.url }));
|
||||||
});
|
});
|
||||||
try { fs.unlinkSync(socketPath); } catch (_) { /* noop */ }
|
try {
|
||||||
|
fs.unlinkSync(socketPath);
|
||||||
|
} catch (_) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
server.once('error', rejectStart);
|
server.once('error', rejectStart);
|
||||||
server.listen(socketPath, () => resolveStart(server));
|
server.listen(socketPath, () => resolveStart(server));
|
||||||
});
|
});
|
||||||
@@ -5101,7 +5340,11 @@ describe('supports http with nodejs', () => {
|
|||||||
function stopUnixServer(server, socketPath) {
|
function stopUnixServer(server, socketPath) {
|
||||||
return new Promise((done) => {
|
return new Promise((done) => {
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
try { fs.unlinkSync(socketPath); } catch (_) { /* noop */ }
|
try {
|
||||||
|
fs.unlinkSync(socketPath);
|
||||||
|
} catch (_) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5193,15 +5436,12 @@ describe('supports http with nodejs', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects non-string socketPath', async () => {
|
it('rejects non-string socketPath', async () => {
|
||||||
await assert.rejects(
|
await assert.rejects(axios.get('http://localhost/echo', { socketPath: 12345 }), (err) => {
|
||||||
axios.get('http://localhost/echo', { socketPath: 12345 }),
|
assert.ok(err instanceof AxiosError);
|
||||||
(err) => {
|
assert.strictEqual(err.code, AxiosError.ERR_BAD_OPTION_VALUE);
|
||||||
assert.ok(err instanceof AxiosError);
|
assert.match(err.message, /socketPath must be a string/);
|
||||||
assert.strictEqual(err.code, AxiosError.ERR_BAD_OPTION_VALUE);
|
return true;
|
||||||
assert.match(err.message, /socketPath must be a string/);
|
});
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('empty allowedSocketPaths array blocks all socketPath values', async () => {
|
it('empty allowedSocketPaths array blocks all socketPath values', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { isNativeError } from 'node:util/types';
|
import { isNativeError } from 'node:util/types';
|
||||||
import AxiosError from '../../../lib/core/AxiosError.js';
|
import AxiosError from '../../../lib/core/AxiosError.js';
|
||||||
|
import AxiosHeaders from '../../../lib/core/AxiosHeaders.js';
|
||||||
|
|
||||||
describe('core::AxiosError', () => {
|
describe('core::AxiosError', () => {
|
||||||
it('creates an error with message, config, code, request, response, stack and isAxiosError', () => {
|
it('creates an error with message, config, code, request, response, stack and isAxiosError', () => {
|
||||||
@@ -143,4 +144,220 @@ describe('core::AxiosError', () => {
|
|||||||
expect({ ...error }.message).toBe('Test error message');
|
expect({ ...error }.message).toBe('Test error message');
|
||||||
expect(Object.getOwnPropertyDescriptor(error, 'message')?.enumerable).toBe(true);
|
expect(Object.getOwnPropertyDescriptor(error, 'message')?.enumerable).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Opt-in redaction: when `config.redact` is an array of key names, every
|
||||||
|
// matching key (case-insensitive, at any depth) has its value replaced with
|
||||||
|
// the redaction marker in the toJSON snapshot. Undefined leaves the legacy
|
||||||
|
// serialization untouched so existing consumers see no behavior change.
|
||||||
|
describe('toJSON redaction via config.redact', () => {
|
||||||
|
it('leaves config untouched when redact is undefined', () => {
|
||||||
|
const config = {
|
||||||
|
url: '/api',
|
||||||
|
auth: { username: 'alice', password: 'secret' },
|
||||||
|
};
|
||||||
|
const error = new AxiosError('Boom', 'ECODE', config);
|
||||||
|
|
||||||
|
const json = error.toJSON();
|
||||||
|
|
||||||
|
expect(json.config.auth.username).toBe('alice');
|
||||||
|
expect(json.config.auth.password).toBe('secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores inherited redact accessors', () => {
|
||||||
|
const prototype = {};
|
||||||
|
Object.defineProperty(prototype, 'redact', {
|
||||||
|
get() {
|
||||||
|
throw new Error('inherited redact getter should not run');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = Object.create(prototype);
|
||||||
|
config.auth = { username: 'alice', password: 'secret' };
|
||||||
|
const error = new AxiosError('Boom', 'ECODE', config);
|
||||||
|
|
||||||
|
const json = error.toJSON();
|
||||||
|
|
||||||
|
expect(json.config.auth.username).toBe('alice');
|
||||||
|
expect(json.config.auth.password).toBe('secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves config untouched when redact is an empty array', () => {
|
||||||
|
const config = {
|
||||||
|
auth: { username: 'alice', password: 'secret' },
|
||||||
|
redact: [],
|
||||||
|
};
|
||||||
|
const error = new AxiosError('Boom', 'ECODE', config);
|
||||||
|
|
||||||
|
expect(error.toJSON().config.auth.password).toBe('secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces top-level matching keys with the redaction marker', () => {
|
||||||
|
const config = {
|
||||||
|
url: '/api',
|
||||||
|
auth: { username: 'alice', password: 'secret' },
|
||||||
|
redact: ['auth'],
|
||||||
|
};
|
||||||
|
const error = new AxiosError('Boom', 'ECODE', config);
|
||||||
|
|
||||||
|
const json = error.toJSON();
|
||||||
|
|
||||||
|
expect(json.config.url).toBe('/api');
|
||||||
|
expect(json.config.auth).toBe('[REDACTED ****]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces matching keys at any nesting depth', () => {
|
||||||
|
const config = {
|
||||||
|
auth: { username: 'alice', password: 'secret' },
|
||||||
|
proxy: { auth: { username: 'pu', password: 'pp' } },
|
||||||
|
redact: ['password'],
|
||||||
|
};
|
||||||
|
const error = new AxiosError('Boom', 'ECODE', config);
|
||||||
|
|
||||||
|
const json = error.toJSON();
|
||||||
|
|
||||||
|
expect(json.config.auth.username).toBe('alice');
|
||||||
|
expect(json.config.auth.password).toBe('[REDACTED ****]');
|
||||||
|
expect(json.config.proxy.auth.password).toBe('[REDACTED ****]');
|
||||||
|
expect(json.config.proxy.auth.username).toBe('pu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches case-insensitively', () => {
|
||||||
|
const config = {
|
||||||
|
headers: { Authorization: 'Bearer abc' },
|
||||||
|
redact: ['authorization'],
|
||||||
|
};
|
||||||
|
const error = new AxiosError('Boom', 'ECODE', config);
|
||||||
|
|
||||||
|
expect(error.toJSON().config.headers.Authorization).toBe('[REDACTED ****]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts headers stored in an AxiosHeaders instance', () => {
|
||||||
|
const headers = new AxiosHeaders();
|
||||||
|
headers.set('Authorization', 'Bearer abc');
|
||||||
|
headers.set('X-Trace', 'trace-id');
|
||||||
|
|
||||||
|
const config = { headers, redact: ['Authorization'] };
|
||||||
|
const error = new AxiosError('Boom', 'ECODE', config);
|
||||||
|
|
||||||
|
const serialized = error.toJSON().config.headers;
|
||||||
|
expect(serialized.Authorization).toBe('[REDACTED ****]');
|
||||||
|
expect(serialized['X-Trace']).toBe('trace-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts inside arrays of objects', () => {
|
||||||
|
const config = {
|
||||||
|
items: [{ token: 't1' }, { token: 't2', name: 'keep' }],
|
||||||
|
redact: ['token'],
|
||||||
|
};
|
||||||
|
const error = new AxiosError('Boom', 'ECODE', config);
|
||||||
|
|
||||||
|
const json = error.toJSON();
|
||||||
|
expect(json.config.items[0].token).toBe('[REDACTED ****]');
|
||||||
|
expect(json.config.items[1].token).toBe('[REDACTED ****]');
|
||||||
|
expect(json.config.items[1].name).toBe('keep');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not crash on circular config references', () => {
|
||||||
|
const config = { auth: { password: 'secret' }, redact: ['password'] };
|
||||||
|
config.self = config;
|
||||||
|
|
||||||
|
const error = new AxiosError('Boom', 'ECODE', config);
|
||||||
|
|
||||||
|
const json = error.toJSON();
|
||||||
|
expect(json.config.auth.password).toBe('[REDACTED ****]');
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(json.config, 'self')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves legacy toJSONObject handling for values with toJSON', () => {
|
||||||
|
const issuedAt = new Date('2026-01-01T00:00:00.000Z');
|
||||||
|
const endpoint = new URL('https://example.com/users');
|
||||||
|
const config = {
|
||||||
|
issuedAt,
|
||||||
|
endpoint,
|
||||||
|
auth: { password: 'secret' },
|
||||||
|
redact: ['password'],
|
||||||
|
};
|
||||||
|
const error = new AxiosError('Boom', 'ECODE', config);
|
||||||
|
|
||||||
|
const json = error.toJSON();
|
||||||
|
|
||||||
|
expect(json.config.issuedAt).toBe(issuedAt);
|
||||||
|
expect(json.config.endpoint).toBe(endpoint);
|
||||||
|
expect(json.config.auth.password).toBe('[REDACTED ****]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a polluted Object.prototype.toJSON bypass redaction', () => {
|
||||||
|
class Credentials {
|
||||||
|
constructor() {
|
||||||
|
this.password = 'secret';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.prototype.toJSON = function () {
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
auth: { password: 'secret' },
|
||||||
|
credentials: new Credentials(),
|
||||||
|
items: [{ token: 't1' }],
|
||||||
|
redact: ['password', 'token'],
|
||||||
|
};
|
||||||
|
const error = new AxiosError('Boom', 'ECODE', config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = error.toJSON();
|
||||||
|
|
||||||
|
expect(json.config.auth.password).toBe('[REDACTED ****]');
|
||||||
|
expect(json.config.credentials.password).toBe('[REDACTED ****]');
|
||||||
|
expect(json.config.items[0].token).toBe('[REDACTED ****]');
|
||||||
|
} finally {
|
||||||
|
delete Object.prototype.toJSON;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies __proto__ as data without changing the redaction output prototype', () => {
|
||||||
|
const config = { redact: ['password'] };
|
||||||
|
Object.defineProperty(config, '__proto__', {
|
||||||
|
value: { password: 'secret' },
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = new AxiosError('Boom', 'ECODE', config);
|
||||||
|
const json = error.toJSON();
|
||||||
|
|
||||||
|
expect(Object.getPrototypeOf(json.config)).toBe(null);
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(json.config, '__proto__')).toBe(true);
|
||||||
|
expect(json.config.__proto__.password).toBe('[REDACTED ****]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate the original config or AxiosHeaders', () => {
|
||||||
|
const headers = new AxiosHeaders();
|
||||||
|
headers.set('Authorization', 'Bearer abc');
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
auth: { username: 'alice', password: 'secret' },
|
||||||
|
headers,
|
||||||
|
redact: ['password', 'Authorization'],
|
||||||
|
};
|
||||||
|
const error = new AxiosError('Boom', 'ECODE', config);
|
||||||
|
|
||||||
|
error.toJSON();
|
||||||
|
|
||||||
|
expect(config.auth.password).toBe('secret');
|
||||||
|
expect(headers.get('Authorization')).toBe('Bearer abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the redact array itself visible in the snapshot', () => {
|
||||||
|
const config = {
|
||||||
|
auth: { password: 'secret' },
|
||||||
|
redact: ['password'],
|
||||||
|
};
|
||||||
|
const error = new AxiosError('Boom', 'ECODE', config);
|
||||||
|
|
||||||
|
// Useful for debugging — operators can see what was being redacted.
|
||||||
|
expect(error.toJSON().config.redact).toEqual(['password']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,14 +34,17 @@ const collect = async (stream) => {
|
|||||||
return Buffer.concat(chunks).toString('utf8');
|
return Buffer.concat(chunks).toString('utf8');
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('formDataToStream (GHSA-445q-vr5w-6q77 CRLF injection)', () => {
|
describe('formDataToStream', () => {
|
||||||
it('should strip CRLF sequences from blob.type to prevent multipart header injection', async () => {
|
it('should strip CRLF sequences from blob.type to prevent multipart header injection', async () => {
|
||||||
const fd = new SpecFormData();
|
const fd = new SpecFormData();
|
||||||
fd.append('photo', makeBlobLike({
|
fd.append(
|
||||||
type: 'image/jpeg\r\nX-Injected-Header: PWNED\r\nX-Evil: bad',
|
'photo',
|
||||||
name: 'photo.jpg',
|
makeBlobLike({
|
||||||
payload: Buffer.from('PAYLOAD'),
|
type: 'image/jpeg\r\nX-Injected-Header: PWNED\r\nX-Evil: bad',
|
||||||
}));
|
name: 'photo.jpg',
|
||||||
|
payload: Buffer.from('PAYLOAD'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const body = await collect(formDataToStream(fd, () => {}));
|
const body = await collect(formDataToStream(fd, () => {}));
|
||||||
|
|
||||||
@@ -52,11 +55,14 @@ describe('formDataToStream (GHSA-445q-vr5w-6q77 CRLF injection)', () => {
|
|||||||
|
|
||||||
it('should strip bare \\r and bare \\n from blob.type', async () => {
|
it('should strip bare \\r and bare \\n from blob.type', async () => {
|
||||||
const fd = new SpecFormData();
|
const fd = new SpecFormData();
|
||||||
fd.append('f', makeBlobLike({
|
fd.append(
|
||||||
type: 'text/plain\rX-A: 1\nX-B: 2',
|
'f',
|
||||||
name: 'f.txt',
|
makeBlobLike({
|
||||||
payload: Buffer.from('x'),
|
type: 'text/plain\rX-A: 1\nX-B: 2',
|
||||||
}));
|
name: 'f.txt',
|
||||||
|
payload: Buffer.from('x'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const body = await collect(formDataToStream(fd, () => {}));
|
const body = await collect(formDataToStream(fd, () => {}));
|
||||||
|
|
||||||
@@ -66,11 +72,14 @@ describe('formDataToStream (GHSA-445q-vr5w-6q77 CRLF injection)', () => {
|
|||||||
|
|
||||||
it('should preserve legitimate Content-Type values', async () => {
|
it('should preserve legitimate Content-Type values', async () => {
|
||||||
const fd = new SpecFormData();
|
const fd = new SpecFormData();
|
||||||
fd.append('doc', makeBlobLike({
|
fd.append(
|
||||||
type: 'application/json; charset=utf-8',
|
'doc',
|
||||||
name: 'doc.json',
|
makeBlobLike({
|
||||||
payload: Buffer.from('{}'),
|
type: 'application/json; charset=utf-8',
|
||||||
}));
|
name: 'doc.json',
|
||||||
|
payload: Buffer.from('{}'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const body = await collect(formDataToStream(fd, () => {}));
|
const body = await collect(formDataToStream(fd, () => {}));
|
||||||
|
|
||||||
@@ -79,11 +88,14 @@ describe('formDataToStream (GHSA-445q-vr5w-6q77 CRLF injection)', () => {
|
|||||||
|
|
||||||
it('should default missing blob.type to application/octet-stream', async () => {
|
it('should default missing blob.type to application/octet-stream', async () => {
|
||||||
const fd = new SpecFormData();
|
const fd = new SpecFormData();
|
||||||
fd.append('bin', makeBlobLike({
|
fd.append(
|
||||||
type: '',
|
'bin',
|
||||||
name: 'bin',
|
makeBlobLike({
|
||||||
payload: Buffer.from([0x00, 0x01]),
|
type: '',
|
||||||
}));
|
name: 'bin',
|
||||||
|
payload: Buffer.from([0x00, 0x01]),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const body = await collect(formDataToStream(fd, () => {}));
|
const body = await collect(formDataToStream(fd, () => {}));
|
||||||
|
|
||||||
@@ -92,11 +104,14 @@ describe('formDataToStream (GHSA-445q-vr5w-6q77 CRLF injection)', () => {
|
|||||||
|
|
||||||
it('should escape CRLF and quotes in blob.name (Content-Disposition)', async () => {
|
it('should escape CRLF and quotes in blob.name (Content-Disposition)', async () => {
|
||||||
const fd = new SpecFormData();
|
const fd = new SpecFormData();
|
||||||
fd.append('up', makeBlobLike({
|
fd.append(
|
||||||
type: 'text/plain',
|
'up',
|
||||||
name: 'evil\r\nX-Bad: 1".jpg',
|
makeBlobLike({
|
||||||
payload: Buffer.from('x'),
|
type: 'text/plain',
|
||||||
}));
|
name: 'evil\r\nX-Bad: 1".jpg',
|
||||||
|
payload: Buffer.from('x'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const body = await collect(formDataToStream(fd, () => {}));
|
const body = await collect(formDataToStream(fd, () => {}));
|
||||||
|
|
||||||
@@ -106,16 +121,23 @@ describe('formDataToStream (GHSA-445q-vr5w-6q77 CRLF injection)', () => {
|
|||||||
|
|
||||||
it('should report stable contentLength that matches emitted bytes', async () => {
|
it('should report stable contentLength that matches emitted bytes', async () => {
|
||||||
const fd = new SpecFormData();
|
const fd = new SpecFormData();
|
||||||
fd.append('photo', makeBlobLike({
|
fd.append(
|
||||||
type: 'image/jpeg\r\nX-Injected: PWNED',
|
'photo',
|
||||||
name: 'photo.jpg',
|
makeBlobLike({
|
||||||
payload: Buffer.from('PAYLOAD'),
|
type: 'image/jpeg\r\nX-Injected: PWNED',
|
||||||
}));
|
name: 'photo.jpg',
|
||||||
|
payload: Buffer.from('PAYLOAD'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
let reportedLength;
|
let reportedLength;
|
||||||
const stream = formDataToStream(fd, (h) => {
|
const stream = formDataToStream(
|
||||||
reportedLength = h['Content-Length'];
|
fd,
|
||||||
}, { boundary: 'test-boundary-abc' });
|
(h) => {
|
||||||
|
reportedLength = h['Content-Length'];
|
||||||
|
},
|
||||||
|
{ boundary: 'test-boundary-abc' }
|
||||||
|
);
|
||||||
|
|
||||||
const body = await collect(stream);
|
const body = await collect(stream);
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ describe('helpers::shouldBypassProxy', () => {
|
|||||||
expect(shouldBypassProxy('not a url')).toBe(false);
|
expect(shouldBypassProxy('not a url')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should bypass proxy for 127.0.0.0/8 subnet when no_proxy contains 127.0.0.1 (GHSA-pmwg-cvhr-8vh7)', () => {
|
it('should bypass proxy for 127.0.0.0/8 subnet when no_proxy contains 127.0.0.1', () => {
|
||||||
setNoProxy('localhost,127.0.0.1,::1');
|
setNoProxy('localhost,127.0.0.1,::1');
|
||||||
|
|
||||||
expect(shouldBypassProxy('http://127.0.0.2:9191/secret')).toBe(true);
|
expect(shouldBypassProxy('http://127.0.0.2:9191/secret')).toBe(true);
|
||||||
@@ -166,4 +166,98 @@ describe('helpers::shouldBypassProxy', () => {
|
|||||||
expect(shouldBypassProxy('http://10.0.0.127:8080/')).toBe(false);
|
expect(shouldBypassProxy('http://10.0.0.127:8080/')).toBe(false);
|
||||||
expect(shouldBypassProxy('http://200.127.0.1:8080/')).toBe(false);
|
expect(shouldBypassProxy('http://200.127.0.1:8080/')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// IPv4-mapped IPv6 normalization: an attacker (or naive caller) can use the
|
||||||
|
// IPv4-mapped IPv6 representation of an address (e.g. ::ffff:192.168.1.5)
|
||||||
|
// to dodge a NO_PROXY policy expressed in IPv4 form, or vice-versa. After
|
||||||
|
// canonicalising both sides, equivalent addresses compare equal.
|
||||||
|
describe('IPv4-mapped IPv6 normalization', () => {
|
||||||
|
it('should bypass via IPv4-mapped IPv6 request when NO_PROXY uses the IPv4 form', () => {
|
||||||
|
setNoProxy('192.168.1.5');
|
||||||
|
|
||||||
|
expect(shouldBypassProxy('http://[::ffff:192.168.1.5]/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should bypass via Node-normalised IPv4-mapped hex request against an IPv4 NO_PROXY', () => {
|
||||||
|
// Node's URL parser canonicalises [::ffff:192.168.1.5] → [::ffff:c0a8:105].
|
||||||
|
// The hex form must unmap to 192.168.1.5 to match the entry.
|
||||||
|
setNoProxy('192.168.1.5');
|
||||||
|
|
||||||
|
expect(shouldBypassProxy('http://[::ffff:c0a8:105]/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should bypass via plain IPv4 request when NO_PROXY uses the IPv4-mapped IPv6 dotted form', () => {
|
||||||
|
setNoProxy('::ffff:192.168.1.5');
|
||||||
|
|
||||||
|
expect(shouldBypassProxy('http://192.168.1.5/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should bypass via plain IPv4 request when NO_PROXY uses the IPv4-mapped IPv6 hex form', () => {
|
||||||
|
setNoProxy('::ffff:a00:1');
|
||||||
|
|
||||||
|
expect(shouldBypassProxy('http://10.0.0.1/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should bypass via plain IPv4 request when NO_PROXY uses a bracketed IPv4-mapped IPv6 entry', () => {
|
||||||
|
setNoProxy('[::ffff:192.168.1.5]');
|
||||||
|
|
||||||
|
expect(shouldBypassProxy('http://192.168.1.5/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat the uncompressed 0:0:0:0:0:ffff:<v4> form as equivalent', () => {
|
||||||
|
setNoProxy('0:0:0:0:0:ffff:10.0.0.1');
|
||||||
|
|
||||||
|
expect(shouldBypassProxy('http://10.0.0.1/')).toBe(true);
|
||||||
|
expect(shouldBypassProxy('http://[::ffff:10.0.0.1]/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat compressed zero-prefix IPv4-mapped IPv6 dotted forms as equivalent', () => {
|
||||||
|
for (const entry of [
|
||||||
|
'0::ffff:192.168.1.5',
|
||||||
|
'0:0::ffff:192.168.1.5',
|
||||||
|
'0:0:0::ffff:192.168.1.5',
|
||||||
|
'0:0:0:0::ffff:192.168.1.5',
|
||||||
|
]) {
|
||||||
|
setNoProxy(entry);
|
||||||
|
|
||||||
|
expect(shouldBypassProxy('http://192.168.1.5/')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat compressed zero-prefix IPv4-mapped IPv6 hex forms as equivalent', () => {
|
||||||
|
for (const entry of [
|
||||||
|
'0::ffff:c0a8:105',
|
||||||
|
'0:0::ffff:c0a8:105',
|
||||||
|
'0:0:0::ffff:c0a8:105',
|
||||||
|
'0:0:0:0::ffff:c0a8:105',
|
||||||
|
]) {
|
||||||
|
setNoProxy(entry);
|
||||||
|
|
||||||
|
expect(shouldBypassProxy('http://192.168.1.5/')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support compressed bracketed IPv4-mapped IPv6 entries with explicit ports', () => {
|
||||||
|
setNoProxy('[0:0::ffff:192.168.1.5]:8080');
|
||||||
|
|
||||||
|
expect(shouldBypassProxy('http://192.168.1.5:8080/')).toBe(true);
|
||||||
|
expect(shouldBypassProxy('http://192.168.1.5:9090/')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT cross-match unrelated addresses', () => {
|
||||||
|
setNoProxy('192.168.1.5');
|
||||||
|
|
||||||
|
// Different IPv4 address inside an IPv4-mapped form must not bypass.
|
||||||
|
expect(shouldBypassProxy('http://[::ffff:192.168.1.6]/')).toBe(false);
|
||||||
|
// Non-mapped IPv6 must not be treated as IPv4.
|
||||||
|
expect(shouldBypassProxy('http://[2001:db8::1]/')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave non-mapped IPv6 addresses comparing as IPv6', () => {
|
||||||
|
setNoProxy('2001:db8::1');
|
||||||
|
|
||||||
|
expect(shouldBypassProxy('http://[2001:db8::1]/')).toBe(true);
|
||||||
|
expect(shouldBypassProxy('http://[2001:db8::2]/')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+322
-38
@@ -5,6 +5,8 @@ import http from 'http';
|
|||||||
import utils from '../../lib/utils.js';
|
import utils from '../../lib/utils.js';
|
||||||
import mergeConfig from '../../lib/core/mergeConfig.js';
|
import mergeConfig from '../../lib/core/mergeConfig.js';
|
||||||
import defaults from '../../lib/defaults/index.js';
|
import defaults from '../../lib/defaults/index.js';
|
||||||
|
import AxiosError from '../../lib/core/AxiosError.js';
|
||||||
|
import AxiosHeaders from '../../lib/core/AxiosHeaders.js';
|
||||||
import axios from '../../index.js';
|
import axios from '../../index.js';
|
||||||
|
|
||||||
describe('Prototype Pollution Protection', () => {
|
describe('Prototype Pollution Protection', () => {
|
||||||
@@ -47,6 +49,16 @@ describe('Prototype Pollution Protection', () => {
|
|||||||
delete Object.prototype.withCredentials;
|
delete Object.prototype.withCredentials;
|
||||||
delete Object.prototype.responseType;
|
delete Object.prototype.responseType;
|
||||||
delete Object.prototype.fetchOptions;
|
delete Object.prototype.fetchOptions;
|
||||||
|
delete Object.prototype.username;
|
||||||
|
delete Object.prototype.password;
|
||||||
|
delete Object.prototype.hostname;
|
||||||
|
delete Object.prototype.host;
|
||||||
|
delete Object.prototype.port;
|
||||||
|
delete Object.prototype.protocol;
|
||||||
|
delete Object.prototype.get;
|
||||||
|
delete Object.prototype.set;
|
||||||
|
delete Object.prototype.headers;
|
||||||
|
delete Object.prototype.customNested;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('utils.merge', () => {
|
describe('utils.merge', () => {
|
||||||
@@ -247,7 +259,7 @@ describe('Prototype Pollution Protection', () => {
|
|||||||
assert.strictEqual(result.headers.common['Content-Type'], 'application/json');
|
assert.strictEqual(result.headers.common['Content-Type'], 'application/json');
|
||||||
});
|
});
|
||||||
|
|
||||||
// GHSA-pf86-5x62-jrwf gadget 3: polluted transformRequest/Response must not
|
// Polluted transformRequest/Response must not
|
||||||
// replace the safe defaults through inherited reads during merge.
|
// replace the safe defaults through inherited reads during merge.
|
||||||
it('should not inherit polluted transformRequest from Object.prototype', () => {
|
it('should not inherit polluted transformRequest from Object.prototype', () => {
|
||||||
const polluted = () => 'attacker';
|
const polluted = () => 'attacker';
|
||||||
@@ -270,7 +282,7 @@ describe('Prototype Pollution Protection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// GHSA-pf86-5x62-jrwf gadget 1: parseReviver read via prototype chain.
|
// parseReviver read via prototype chain.
|
||||||
describe('defaults.transformResponse parseReviver', () => {
|
describe('defaults.transformResponse parseReviver', () => {
|
||||||
it('should ignore Object.prototype.parseReviver when parsing JSON', () => {
|
it('should ignore Object.prototype.parseReviver when parsing JSON', () => {
|
||||||
let reviverCalled = false;
|
let reviverCalled = false;
|
||||||
@@ -281,10 +293,7 @@ describe('Prototype Pollution Protection', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ctx = { transitional: defaults.transitional };
|
const ctx = { transitional: defaults.transitional };
|
||||||
const result = defaults.transformResponse[0].call(
|
const result = defaults.transformResponse[0].call(ctx, '{"role":"user","balance":100}');
|
||||||
ctx,
|
|
||||||
'{"role":"user","balance":100}'
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.strictEqual(reviverCalled, false);
|
assert.strictEqual(reviverCalled, false);
|
||||||
assert.strictEqual(result.role, 'user');
|
assert.strictEqual(result.role, 'user');
|
||||||
@@ -302,9 +311,9 @@ describe('Prototype Pollution Protection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// GHSA-w9j2-pvgh-6h63: mergeDirectKeys must not inherit validateStatus from
|
// mergeDirectKeys must not inherit validateStatus from
|
||||||
// Object.prototype (was using the `in` operator which traverses the chain).
|
// Object.prototype (was using the `in` operator which traverses the chain).
|
||||||
describe('GHSA-w9j2-pvgh-6h63 validateStatus merge', () => {
|
describe('validateStatus merge', () => {
|
||||||
it('should not inherit a polluted validateStatus during mergeConfig', () => {
|
it('should not inherit a polluted validateStatus during mergeConfig', () => {
|
||||||
Object.prototype.validateStatus = () => true;
|
Object.prototype.validateStatus = () => true;
|
||||||
|
|
||||||
@@ -339,9 +348,9 @@ describe('Prototype Pollution Protection', () => {
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GHSA-3w6x-2g7m-8v23: end-to-end check that a polluted parseReviver does not
|
// end-to-end check that a polluted parseReviver does not
|
||||||
// tamper with JSON response bodies through the full axios.get pipeline.
|
// tamper with JSON response bodies through the full axios.get pipeline.
|
||||||
describe('GHSA-3w6x-2g7m-8v23 parseReviver end-to-end', () => {
|
describe('parseReviver end-to-end', () => {
|
||||||
it('should not let Object.prototype.parseReviver tamper with JSON responses', async () => {
|
it('should not let Object.prototype.parseReviver tamper with JSON responses', async () => {
|
||||||
let reviverCalled = false;
|
let reviverCalled = false;
|
||||||
const stolen = {};
|
const stolen = {};
|
||||||
@@ -382,7 +391,7 @@ describe('Prototype Pollution Protection', () => {
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GHSA-pf86-5x62-jrwf gadget 2: http adapter must not read config.transport
|
// http adapter must not read config.transport
|
||||||
// (or related keys) from Object.prototype.
|
// (or related keys) from Object.prototype.
|
||||||
describe('http adapter prototype reads', () => {
|
describe('http adapter prototype reads', () => {
|
||||||
it('should not invoke Object.prototype.transport on a request', async () => {
|
it('should not invoke Object.prototype.transport on a request', async () => {
|
||||||
@@ -412,17 +421,20 @@ describe('Prototype Pollution Protection', () => {
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GHSA-q8qp-cvcw-x6jj: five config properties were read via direct property
|
// Five config properties were read via direct property
|
||||||
// access in the http adapter and resolveConfig, bypassing hasOwnProperty and
|
// access in the http adapter and resolveConfig, bypassing hasOwnProperty and
|
||||||
// allowing prototype pollution gadgets (auth, baseURL, socketPath,
|
// allowing prototype pollution gadgets (auth, baseURL, socketPath,
|
||||||
// beforeRedirect, insecureHTTPParser).
|
// beforeRedirect, insecureHTTPParser).
|
||||||
describe('GHSA-q8qp-cvcw-x6jj http adapter gadgets', () => {
|
describe('http adapter gadgets', () => {
|
||||||
function startServer(handler) {
|
function startServer(handler) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const server = http.createServer(handler || ((req, res) => {
|
const server = http.createServer(
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
handler ||
|
||||||
res.end(JSON.stringify({ headers: req.headers, url: req.url }));
|
((req, res) => {
|
||||||
}));
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ headers: req.headers, url: req.url }));
|
||||||
|
})
|
||||||
|
);
|
||||||
server.listen(0, '127.0.0.1', () => resolve(server));
|
server.listen(0, '127.0.0.1', () => resolve(server));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -531,18 +543,72 @@ describe('Prototype Pollution Protection', () => {
|
|||||||
// HPE_CR_EXPECTED). Match any parser error to remain stable across
|
// HPE_CR_EXPECTED). Match any parser error to remain stable across
|
||||||
// Node releases while still confirming the strict parser rejected
|
// Node releases while still confirming the strict parser rejected
|
||||||
// the payload.
|
// the payload.
|
||||||
assert.match(
|
assert.match(caughtCode, /^HPE_/, `expected an HPE_* parser error, got: ${caughtCode}`);
|
||||||
caughtCode,
|
|
||||||
/^HPE_/,
|
|
||||||
`expected an HPE_* parser error, got: ${caughtCode}`
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise((resolve) => malformed.close(resolve));
|
await new Promise((resolve) => malformed.close(resolve));
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
|
it('should not inject Proxy-Authorization from polluted Object.prototype.auth', async () => {
|
||||||
|
// setProxy reads `proxy.auth` directly. When `proxy` is a
|
||||||
|
// URL instance from the environment proxy or a plain object without an own `auth`,
|
||||||
|
// a polluted Object.prototype.auth would otherwise be base64-encoded into the
|
||||||
|
// Proxy-Authorization header, leaking attacker-controlled credentials.
|
||||||
|
Object.prototype.auth = { username: 'attacker', password: 'exfil' };
|
||||||
|
|
||||||
|
const proxy = await startServer();
|
||||||
|
const { port: proxyPort } = proxy.address();
|
||||||
|
|
||||||
|
const target = await startServer();
|
||||||
|
const { port: targetPort } = target.address();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`http://127.0.0.1:${targetPort}/api`, {
|
||||||
|
proxy: { host: '127.0.0.1', port: proxyPort, protocol: 'http' },
|
||||||
|
});
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert.strictEqual(
|
||||||
|
res.data.headers['proxy-authorization'],
|
||||||
|
undefined,
|
||||||
|
'polluted Object.prototype.auth must not produce a Proxy-Authorization header'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stopServer(target);
|
||||||
|
await stopServer(proxy);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
it('should not inject Proxy-Authorization from polluted Object.prototype.username', async () => {
|
||||||
|
// The setProxy username/password branch builds basic creds from `proxy.username`
|
||||||
|
// and `proxy.password`. For a plain object proxy, both reads must be guarded
|
||||||
|
// against prototype pollution.
|
||||||
|
Object.prototype.username = 'attacker';
|
||||||
|
Object.prototype.password = 'exfil';
|
||||||
|
|
||||||
|
const proxy = await startServer();
|
||||||
|
const { port: proxyPort } = proxy.address();
|
||||||
|
|
||||||
|
const target = await startServer();
|
||||||
|
const { port: targetPort } = target.address();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`http://127.0.0.1:${targetPort}/api`, {
|
||||||
|
proxy: { host: '127.0.0.1', port: proxyPort, protocol: 'http' },
|
||||||
|
});
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert.strictEqual(
|
||||||
|
res.data.headers['proxy-authorization'],
|
||||||
|
undefined,
|
||||||
|
'polluted Object.prototype.username must not produce a Proxy-Authorization header'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stopServer(target);
|
||||||
|
await stopServer(proxy);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GHSA-q8qp-cvcw-x6jj resolveConfig baseURL gadget', () => {
|
describe('resolveConfig baseURL gadget', () => {
|
||||||
// The baseURL branch in buildFullPath only runs when the requested URL is
|
// The baseURL branch in buildFullPath only runs when the requested URL is
|
||||||
// relative (or allowAbsoluteUrls === false). An absolute URL would skip
|
// relative (or allowAbsoluteUrls === false). An absolute URL would skip
|
||||||
// baseURL regardless of pollution and would not exercise the gadget. We
|
// baseURL regardless of pollution and would not exercise the gadget. We
|
||||||
@@ -658,24 +724,29 @@ describe('Prototype Pollution Protection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify every gadget enumerated in the audit (extension of GHSA-q8qp-cvcw-x6jj)
|
// Verify every gadget enumerated in the audit
|
||||||
// is neutralized end-to-end by the null-prototype config.
|
// is neutralized end-to-end by the null-prototype config.
|
||||||
describe('Full gadget coverage via null-prototype config', () => {
|
describe('Full gadget coverage via null-prototype config', () => {
|
||||||
function startEcho(handler) {
|
function startEcho(handler) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const server = http.createServer(handler || ((req, res) => {
|
const server = http.createServer(
|
||||||
let body = '';
|
handler ||
|
||||||
req.on('data', (c) => (body += c));
|
((req, res) => {
|
||||||
req.on('end', () => {
|
let body = '';
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
req.on('data', (c) => (body += c));
|
||||||
res.end(JSON.stringify({
|
req.on('end', () => {
|
||||||
url: req.url,
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
method: req.method,
|
res.end(
|
||||||
headers: req.headers,
|
JSON.stringify({
|
||||||
body,
|
url: req.url,
|
||||||
}));
|
method: req.method,
|
||||||
});
|
headers: req.headers,
|
||||||
}));
|
body,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
server.listen(0, '127.0.0.1', () => resolve(server));
|
server.listen(0, '127.0.0.1', () => resolve(server));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -721,7 +792,14 @@ describe('Prototype Pollution Protection', () => {
|
|||||||
let hijacked = false;
|
let hijacked = false;
|
||||||
Object.prototype.adapter = function pollutedAdapter() {
|
Object.prototype.adapter = function pollutedAdapter() {
|
||||||
hijacked = true;
|
hijacked = true;
|
||||||
return Promise.resolve({ data: 'pwned', status: 200, statusText: 'OK', headers: {}, config: {}, request: {} });
|
return Promise.resolve({
|
||||||
|
data: 'pwned',
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
headers: {},
|
||||||
|
config: {},
|
||||||
|
request: {},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const server = await startEcho();
|
const server = await startEcho();
|
||||||
@@ -896,4 +974,210 @@ describe('Prototype Pollution Protection', () => {
|
|||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// utils.merge previously read `result[targetKey]` directly, which walks the
|
||||||
|
// prototype chain. A polluted Object.prototype.<key> object would surface as
|
||||||
|
// the existing value and be merged into the result.
|
||||||
|
describe('utils.merge prototype-chain read', () => {
|
||||||
|
it('should not pick up polluted Object.prototype.<key> as the existing value', () => {
|
||||||
|
Object.prototype.headers = { evil: 'yes' };
|
||||||
|
|
||||||
|
const result = utils.merge({}, { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
|
assert.strictEqual(result.headers.evil, undefined);
|
||||||
|
assert.strictEqual(result.headers['Content-Type'], 'application/json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not absorb polluted nested objects when the key is absent from inputs', () => {
|
||||||
|
// When the source does not carry `customNested`, the merged result should
|
||||||
|
// not surface it either, even if Object.prototype carries it.
|
||||||
|
Object.prototype.customNested = { evil: 'yes' };
|
||||||
|
|
||||||
|
const result = utils.merge({}, { safe: 'value' });
|
||||||
|
|
||||||
|
assert.strictEqual(result.hasOwnProperty('customNested'), false);
|
||||||
|
assert.strictEqual(result.safe, 'value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Object.defineProperty calls a HasProperty check on `get`/`set` of the
|
||||||
|
// descriptor. A polluted Object.prototype.get with a non-function value would
|
||||||
|
// throw TypeError at every defineProperty site that uses a plain literal
|
||||||
|
// descriptor. Each fixed site should be shielded with `__proto__: null`.
|
||||||
|
describe('Object.defineProperty descriptor literals', () => {
|
||||||
|
it('should construct AxiosError when Object.prototype.get is polluted', () => {
|
||||||
|
Object.prototype.get = 'attacker';
|
||||||
|
|
||||||
|
const err = new AxiosError('hello', 'ECODE');
|
||||||
|
|
||||||
|
assert.strictEqual(err.message, 'hello');
|
||||||
|
assert.strictEqual(err.code, 'ECODE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should construct AxiosHeaders accessor methods when Object.prototype.get is polluted', () => {
|
||||||
|
Object.prototype.get = 'attacker';
|
||||||
|
|
||||||
|
// AxiosHeaders.accessor uses Object.defineProperty on the prototype.
|
||||||
|
// Triggering a fresh accessor definition exercises the descriptor literal.
|
||||||
|
AxiosHeaders.accessor('X-Pp-Test');
|
||||||
|
|
||||||
|
const h = new AxiosHeaders();
|
||||||
|
h.setXPpTest('value');
|
||||||
|
assert.strictEqual(h.getXPpTest(), 'value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw in mergeConfig when Object.prototype.get is polluted', () => {
|
||||||
|
Object.prototype.get = 'attacker';
|
||||||
|
|
||||||
|
const result = mergeConfig({}, { url: '/x', method: 'get' });
|
||||||
|
|
||||||
|
assert.strictEqual(result.url, '/x');
|
||||||
|
assert.strictEqual(result.method, 'get');
|
||||||
|
assert.strictEqual(typeof result.hasOwnProperty, 'function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw in utils.extend when Object.prototype.get is polluted', () => {
|
||||||
|
Object.prototype.get = 'attacker';
|
||||||
|
|
||||||
|
const a = {};
|
||||||
|
const b = { x: 1, fn() {} };
|
||||||
|
utils.extend(a, b);
|
||||||
|
|
||||||
|
assert.strictEqual(a.x, 1);
|
||||||
|
assert.strictEqual(typeof a.fn, 'function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw in utils.extend with thisArg when Object.prototype.get is polluted', () => {
|
||||||
|
Object.prototype.get = 'attacker';
|
||||||
|
|
||||||
|
const a = {};
|
||||||
|
const ctx = { tag: 'ctx' };
|
||||||
|
const b = {
|
||||||
|
method() {
|
||||||
|
return this.tag;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
utils.extend(a, b, ctx);
|
||||||
|
|
||||||
|
assert.strictEqual(a.method(), 'ctx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw in utils.inherits when Object.prototype.get is polluted', () => {
|
||||||
|
Object.prototype.get = 'attacker';
|
||||||
|
|
||||||
|
function Parent() {}
|
||||||
|
function Child() {}
|
||||||
|
utils.inherits(Child, Parent);
|
||||||
|
|
||||||
|
assert.strictEqual(Child.prototype.constructor, Child);
|
||||||
|
assert.strictEqual(Child.super, Parent.prototype);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should also be shielded against a polluted Object.prototype.set', () => {
|
||||||
|
Object.prototype.set = 'attacker';
|
||||||
|
|
||||||
|
// Same surface as `get` — ToPropertyDescriptor checks both. One spot-check
|
||||||
|
// covers them all since they share the same fix.
|
||||||
|
const err = new AxiosError('hello');
|
||||||
|
assert.strictEqual(err.message, 'hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// End-to-end regressions covering published advisory PoCs against full axios
|
||||||
|
// request flow. Each test mirrors the exploit scenario from the advisory and
|
||||||
|
// asserts the attack does not succeed.
|
||||||
|
describe('advisory regression — full request flow', () => {
|
||||||
|
function startServer(handler) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = http.createServer(handler);
|
||||||
|
server.listen(0, '127.0.0.1', () => resolve(server));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const stop = (s) => new Promise((r) => s.close(r));
|
||||||
|
|
||||||
|
// Full MITM via prototype pollution gadget in
|
||||||
|
// `config.proxy`. mergeConfig must not surface a polluted Object.prototype.proxy
|
||||||
|
// as the merged config's proxy, otherwise every request would route through
|
||||||
|
// an attacker-controlled host.
|
||||||
|
it('polluted Object.prototype.proxy must not redirect requests through an attacker proxy', async () => {
|
||||||
|
const proxyHits = [];
|
||||||
|
const attackerProxy = await startServer((req, res) => {
|
||||||
|
proxyHits.push({
|
||||||
|
url: req.url,
|
||||||
|
authorization: req.headers.authorization,
|
||||||
|
host: req.headers.host,
|
||||||
|
});
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end('{"hijacked":true}');
|
||||||
|
});
|
||||||
|
|
||||||
|
const realHits = [];
|
||||||
|
const realServer = await startServer((req, res) => {
|
||||||
|
realHits.push({ url: req.url });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end('{"data":"real"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object.prototype.proxy = {
|
||||||
|
protocol: 'http',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: attackerProxy.address().port,
|
||||||
|
};
|
||||||
|
|
||||||
|
const realPort = realServer.address().port;
|
||||||
|
const res = await axios.get(`http://127.0.0.1:${realPort}/api/secrets`, {
|
||||||
|
auth: { username: 'admin', password: 'SuperSecret123!' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(proxyHits.length, 0, 'attacker proxy must not receive any request');
|
||||||
|
assert.strictEqual(realHits.length, 1, 'request must reach the real target');
|
||||||
|
assert.deepStrictEqual(res.data, { data: 'real' });
|
||||||
|
} finally {
|
||||||
|
await stop(attackerProxy);
|
||||||
|
await stop(realServer);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
// Credential theft and response hijacking via
|
||||||
|
// prototype pollution gadget in config merge. A polluted
|
||||||
|
// Object.prototype.transformResponse function would otherwise execute with
|
||||||
|
// `this = config`, exposing `auth.username`/`auth.password` to the attacker.
|
||||||
|
it('polluted Object.prototype.transformResponse must not be invoked or leak request credentials', async () => {
|
||||||
|
let invoked = false;
|
||||||
|
let stolen = null;
|
||||||
|
Object.prototype.transformResponse = function pollutedTransform(data) {
|
||||||
|
invoked = true;
|
||||||
|
stolen = {
|
||||||
|
url: this && this.url,
|
||||||
|
username: this && this.auth && this.auth.username,
|
||||||
|
password: this && this.auth && this.auth.password,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = await startServer((req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end('{"secret":"keep-me"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await axios.get(`http://127.0.0.1:${port}/users`, {
|
||||||
|
auth: { username: 'svc-account', password: 'prod-secret-key-123!' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(invoked, false, 'polluted transformResponse must not run');
|
||||||
|
assert.strictEqual(stolen, null, 'no request context must be captured');
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
res.data,
|
||||||
|
{ secret: 'keep-me' },
|
||||||
|
'response data must reach the caller untampered'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stop(server);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user