mirror of
https://github.com/tenrok/axios.git
synced 2026-06-17 19:21:29 +03:00
fix: more header pollutions (#10779)
* fix: more header pollutions * fix: more header pollution issues * fix: cubic feedback * fix: prototype test
This commit is contained in:
+44
-44
@@ -6,35 +6,35 @@ This release delivers two critical security patches targeting header injection a
|
||||
|
||||
## 🔒 Security Fixes
|
||||
|
||||
* **Header Injection (CRLF):** Rejects any header value containing `\r` or `\n` characters to block CRLF injection chains that could be used to exfiltrate cloud metadata (IMDS). Behavior change: headers with CR/LF now throw `"Invalid character in header content"`. (__#10660__)
|
||||
- **Header Injection (CRLF):** Rejects any header value containing `\r` or `\n` characters to block CRLF injection chains that could be used to exfiltrate cloud metadata (IMDS). Behavior change: headers with CR/LF now throw `"Invalid character in header content"`. (**#10660**)
|
||||
|
||||
* **SSRF via `no_proxy` Bypass:** Introduces a `shouldBypassProxy` helper that normalises hostnames (strips trailing dots, handles bracketed IPv6) before evaluating `no_proxy`/`NO_PROXY` rules, closing a gap that could cause loopback or internal hosts to be inadvertently proxied. (__#10661__)
|
||||
- **SSRF via `no_proxy` Bypass:** Introduces a `shouldBypassProxy` helper that normalises hostnames (strips trailing dots, handles bracketed IPv6) before evaluating `no_proxy`/`NO_PROXY` rules, closing a gap that could cause loopback or internal hosts to be inadvertently proxied. (**#10661**)
|
||||
|
||||
## 🚀 New Features
|
||||
|
||||
* **Deno & Bun Runtime Support:** Added full smoke test suites for Deno and Bun, with CI workflows that run both runtimes before any release is cut. (__#10652__)
|
||||
- **Deno & Bun Runtime Support:** Added full smoke test suites for Deno and Bun, with CI workflows that run both runtimes before any release is cut. (**#10652**)
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* **Node.js v22 Compatibility:** Replaced deprecated `url.parse()` calls with the WHATWG `URL`/`URLSearchParams` API across examples, sandbox, and tests, eliminating `DEP0169` deprecation warnings on Node.js v22+. (__#10625__)
|
||||
- **Node.js v22 Compatibility:** Replaced deprecated `url.parse()` calls with the WHATWG `URL`/`URLSearchParams` API across examples, sandbox, and tests, eliminating `DEP0169` deprecation warnings on Node.js v22+. (**#10625**)
|
||||
|
||||
## 🔧 Maintenance & Chores
|
||||
|
||||
* **CI Security Hardening:** Added [zizmor](https://github.com/zizmorcore/zizmor) GitHub Actions security scanner; switched npm publish to OIDC Trusted Publishing (removing the long-lived `NODE_AUTH_TOKEN`); pinned all action references to full commit SHAs; narrowed workflow permissions to least privilege; gated the publish step behind a dedicated `npm-publish` environment; and blocked the sponsor-block workflow from running on forks. (__#10618__, __#10619__, __#10627__, __#10637__, __#10641__, __#10666__)
|
||||
- **CI Security Hardening:** Added [zizmor](https://github.com/zizmorcore/zizmor) GitHub Actions security scanner; switched npm publish to OIDC Trusted Publishing (removing the long-lived `NODE_AUTH_TOKEN`); pinned all action references to full commit SHAs; narrowed workflow permissions to least privilege; gated the publish step behind a dedicated `npm-publish` environment; and blocked the sponsor-block workflow from running on forks. (**#10618**, **#10619**, **#10627**, **#10637**, **#10641**, **#10666**)
|
||||
|
||||
* **Docs:** Clarified HTTP/2 support and the unsupported `httpVersion` option; added documentation for header case preservation; improved the `beforeRedirect` example to prevent accidental credential leakage. (__#10644__, __#10654__, __#10624__)
|
||||
- **Docs:** Clarified HTTP/2 support and the unsupported `httpVersion` option; added documentation for header case preservation; improved the `beforeRedirect` example to prevent accidental credential leakage. (**#10644**, **#10654**, **#10624**)
|
||||
|
||||
* **Dependencies:** Bumped `picomatch`, `handlebars`, `serialize-javascript`, `vite` (×3), `denoland/setup-deno`, and 4 additional dev dependencies to latest versions. (__#10564__, __#10565__, __#10567__, __#10568__, __#10572__, __#10574__, __#10663__, __#10664__, __#10665__, __#10669__, __#10670__)
|
||||
- **Dependencies:** Bumped `picomatch`, `handlebars`, `serialize-javascript`, `vite` (×3), `denoland/setup-deno`, and 4 additional dev dependencies to latest versions. (**#10564**, **#10565**, **#10567**, **#10568**, **#10572**, **#10574**, **#10663**, **#10664**, **#10665**, **#10669**, **#10670**)
|
||||
|
||||
## 🌟 New Contributors
|
||||
|
||||
We are thrilled to welcome our new contributors. Thank you for helping improve axios:
|
||||
|
||||
* **@Kilros0817** (__#10625__)
|
||||
* **@shaanmajid** (__#10616__, __#10617__, __#10618__, __#10619__, __#10637__, __#10641__, __#10666__)
|
||||
* **@ashstrc** (__#10624__, __#10644__)
|
||||
* **@Abhi3975** (__#10589__)
|
||||
* **@raashish1601** (__#10573__)
|
||||
- **@Kilros0817** (**#10625**)
|
||||
- **@shaanmajid** (**#10616**, **#10617**, **#10618**, **#10619**, **#10637**, **#10641**, **#10666**)
|
||||
- **@ashstrc** (**#10624**, **#10644**)
|
||||
- **@Abhi3975** (**#10589**)
|
||||
- **@raashish1601** (**#10573**)
|
||||
|
||||
[Full Changelog](https://github.com/axios/axios/compare/v1.14.0...v1.15.0)
|
||||
|
||||
@@ -46,33 +46,33 @@ This release fixes a security vulnerability in the `formidable` dependency, reso
|
||||
|
||||
## 🔒 Security Fixes
|
||||
|
||||
* **Formidable Vulnerability:** Upgraded `formidable` from v2 to v3 to address a reported arbitrary-file vulnerability. Updated test server and assertions to align with the v3 API. (__#7533__)
|
||||
- **Formidable Vulnerability:** Upgraded `formidable` from v2 to v3 to address a reported arbitrary-file vulnerability. Updated test server and assertions to align with the v3 API. (**#7533**)
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* **CommonJS Compatibility:** Restored `require('axios')` in Node.js by correcting the `main` field in `package.json` to point to the built CJS bundle. (__#7532__)
|
||||
- **CommonJS Compatibility:** Restored `require('axios')` in Node.js by correcting the `main` field in `package.json` to point to the built CJS bundle. (**#7532**)
|
||||
|
||||
* **Fetch Adapter:** Cancel the `ReadableStream` body after the request stream capability probe to prevent resource leaks. (__#7515__)
|
||||
- **Fetch Adapter:** Cancel the `ReadableStream` body after the request stream capability probe to prevent resource leaks. (**#7515**)
|
||||
|
||||
* **Proxy:** Upgraded `proxy-from-env` to v2 and switched to the named `getProxyForUrl` export, fixing proxy detection from environment variables and resolving CJS bundling errors. (__#7499__)
|
||||
- **Proxy:** Upgraded `proxy-from-env` to v2 and switched to the named `getProxyForUrl` export, fixing proxy detection from environment variables and resolving CJS bundling errors. (**#7499**)
|
||||
|
||||
* **HTTP/2:** Close detached HTTP/2 sessions on timeout to free resources when no new requests arrive. (__#7457__)
|
||||
- **HTTP/2:** Close detached HTTP/2 sessions on timeout to free resources when no new requests arrive. (**#7457**)
|
||||
|
||||
* **Headers:** Trim trailing CRLF characters from normalised header values. (__#7456__)
|
||||
- **Headers:** Trim trailing CRLF characters from normalised header values. (**#7456**)
|
||||
|
||||
## 🔧 Maintenance & Chores
|
||||
|
||||
* **Toolchain Modernisation:** Migrated test suite to Vitest, updated ESLint to v10, upgraded Rollup and `@rollup/plugin-babel`, migrated to Husky 9, upgraded TypeScript to latest, and modernised the Express test harness. (__#7484__, __#7489__, __#7498__, __#7505__, __#7506__, __#7507__, __#7508__, __#7509__, __#7510__, __#7516__, __#7522__)
|
||||
- **Toolchain Modernisation:** Migrated test suite to Vitest, updated ESLint to v10, upgraded Rollup and `@rollup/plugin-babel`, migrated to Husky 9, upgraded TypeScript to latest, and modernised the Express test harness. (**#7484**, **#7489**, **#7498**, **#7505**, **#7506**, **#7507**, **#7508**, **#7509**, **#7510**, **#7516**, **#7522**)
|
||||
|
||||
* **Dependencies:** Bumped `multer` to v2, `minimatch`, `tar`, `pacote`, `@babel/preset-env`, and additional dev dependencies. (__#7453__, __#7480__, __#7491__, __#7504__, __#7517__, __#7531__)
|
||||
- **Dependencies:** Bumped `multer` to v2, `minimatch`, `tar`, `pacote`, `@babel/preset-env`, and additional dev dependencies. (**#7453**, **#7480**, **#7491**, **#7504**, **#7517**, **#7531**)
|
||||
|
||||
## 🌟 New Contributors
|
||||
|
||||
We are thrilled to welcome our new contributors. Thank you for helping improve axios:
|
||||
|
||||
* **@penkzhou** (__#7515__)
|
||||
* **@aviu16** (__#7456__)
|
||||
* **@fedotov** (__#7457__)
|
||||
- **@penkzhou** (**#7515**)
|
||||
- **@aviu16** (**#7456**)
|
||||
- **@fedotov** (**#7457**)
|
||||
|
||||
[Full Changelog](https://github.com/axios/axios/compare/v1.13.6...v1.14.0)
|
||||
|
||||
@@ -84,31 +84,31 @@ This release adds React Native Blob support, fixes several enumeration and expor
|
||||
|
||||
## 🚀 New Features
|
||||
|
||||
* **React Native Blob Support:** Axios now correctly handles native Blob objects in React Native environments. (__#5764__)
|
||||
- **React Native Blob Support:** Axios now correctly handles native Blob objects in React Native environments. (**#5764**)
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* **AxiosError:** Fixed `AxiosError.from` not copying the `status` field from the source error. (__#7403__)
|
||||
- **AxiosError:** Fixed `AxiosError.from` not copying the `status` field from the source error. (**#7403**)
|
||||
|
||||
* **AxiosError:** Made the `message` property enumerable so it appears in `JSON.stringify` output and `Object.keys`. (__#7392__)
|
||||
- **AxiosError:** Made the `message` property enumerable so it appears in `JSON.stringify` output and `Object.keys`. (**#7392**)
|
||||
|
||||
* **FormData Detection:** Corrected safe FormData detection for WeChat Mini Program environments. (__#7324__)
|
||||
- **FormData Detection:** Corrected safe FormData detection for WeChat Mini Program environments. (**#7324**)
|
||||
|
||||
* **React Native / Browserify Export:** Fixed broken module export that caused import failures in React Native and Browserify. (__#7386__)
|
||||
- **React Native / Browserify Export:** Fixed broken module export that caused import failures in React Native and Browserify. (**#7386**)
|
||||
|
||||
## 🔧 Maintenance & Chores
|
||||
|
||||
* **Dependencies:** Migrated `@rollup/plugin-babel` from v5 to v6 and bumped the development dependencies group. (__#7424__, __#7432__)
|
||||
- **Dependencies:** Migrated `@rollup/plugin-babel` from v5 to v6 and bumped the development dependencies group. (**#7424**, **#7432**)
|
||||
|
||||
## 🌟 New Contributors
|
||||
|
||||
We are thrilled to welcome our new contributors. Thank you for helping improve axios:
|
||||
|
||||
* **@moh3n9595** (__#5764__)
|
||||
* **@skrtheboss** (__#7403__)
|
||||
* **@ybbus** (__#7392__)
|
||||
* **@Shiwaangee** (__#7324__)
|
||||
* **@Gudahtt** (__#7386__)
|
||||
- **@moh3n9595** (**#5764**)
|
||||
- **@skrtheboss** (**#7403**)
|
||||
- **@ybbus** (**#7392**)
|
||||
- **@Shiwaangee** (**#7324**)
|
||||
- **@Gudahtt** (**#7386**)
|
||||
|
||||
[Full Changelog](https://github.com/axios/axios/compare/v1.13.5...v1.13.6)
|
||||
|
||||
@@ -120,29 +120,29 @@ This release patches a prototype pollution denial-of-service vulnerability, fixe
|
||||
|
||||
## 🔒 Security Fixes
|
||||
|
||||
* **Prototype Pollution (DoS):** Hardened `mergeConfig` to ignore `__proto__`, `constructor`, and `prototype` keys, preventing denial-of-service via prototype pollution when merging user-supplied config. (__#7369__)
|
||||
- **Prototype Pollution (DoS):** Hardened `mergeConfig` to ignore `__proto__`, `constructor`, and `prototype` keys, preventing denial-of-service via prototype pollution when merging user-supplied config. (**#7369**)
|
||||
|
||||
## 🚀 New Features
|
||||
|
||||
* **`isAbsoluteURL` Validation:** Added input validation to `isAbsoluteURL` to handle malformed or unexpected input gracefully. (__#7326__)
|
||||
- **`isAbsoluteURL` Validation:** Added input validation to `isAbsoluteURL` to handle malformed or unexpected input gracefully. (**#7326**)
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* **AxiosError `status`:** Restored the `status` field on `AxiosError` instances, which was missing in v1.13.3 and later. (__#7368__)
|
||||
- **AxiosError `status`:** Restored the `status` field on `AxiosError` instances, which was missing in v1.13.3 and later. (**#7368**)
|
||||
|
||||
* **Interceptor Ordering:** Added a `useLegacyInterceptorOrder` option to restore pre-v1.13 interceptor execution order for applications relying on the previous behaviour. ([569f028](https://github.com/axios/axios/commit/569f028a5878faaec8d7d138ba686aac407bda4c))
|
||||
- **Interceptor Ordering:** Added a `useLegacyInterceptorOrder` option to restore pre-v1.13 interceptor execution order for applications relying on the previous behaviour. ([569f028](https://github.com/axios/axios/commit/569f028a5878faaec8d7d138ba686aac407bda4c))
|
||||
|
||||
## 🔧 Maintenance & Chores
|
||||
|
||||
* **CI:** Fixed run conditions and updated workflow YAMLs. (__#7372__, __#7373__)
|
||||
- **CI:** Fixed run conditions and updated workflow YAMLs. (**#7372**, **#7373**)
|
||||
|
||||
* **Dependencies:** Bumped `karma-sourcemap-loader` and minor package versions. (__#7356__, __#7360__)
|
||||
- **Dependencies:** Bumped `karma-sourcemap-loader` and minor package versions. (**#7356**, **#7360**)
|
||||
|
||||
## 🌟 New Contributors
|
||||
|
||||
We are thrilled to welcome our new contributors. Thank you for helping improve axios:
|
||||
|
||||
* **@asmitha-16** (__#7326__)
|
||||
- **@asmitha-16** (**#7326**)
|
||||
|
||||
[Full Changelog](https://github.com/axios/axios/compare/v1.13.4...v1.13.5)
|
||||
|
||||
@@ -154,13 +154,13 @@ Patch release fixing regressions introduced in v1.13.3, including TypeScript exp
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* **v1.13.3 Regressions:** Fixed multiple issues introduced by the v1.13.3 release, including broken merge configs. (__#7352__)
|
||||
- **v1.13.3 Regressions:** Fixed multiple issues introduced by the v1.13.3 release, including broken merge configs. (**#7352**)
|
||||
|
||||
* **TypeScript Exports:** Corrected TypeScript export declarations to restore proper type resolution. (__#4884__)
|
||||
- **TypeScript Exports:** Corrected TypeScript export declarations to restore proper type resolution. (**#4884**)
|
||||
|
||||
## 🔧 Maintenance & Chores
|
||||
|
||||
* **CI & Build:** Refactored CI pipeline and build configuration for stability. (__#7340__)
|
||||
- **CI & Build:** Refactored CI pipeline and build configuration for stability. (**#7340**)
|
||||
|
||||
[Full Changelog](https://github.com/axios/axios/compare/v1.13.3...v1.13.4)
|
||||
|
||||
|
||||
+17
-11
@@ -615,9 +615,10 @@ export default isHttpAdapterSupported &&
|
||||
|
||||
// HTTP basic authentication
|
||||
let auth = undefined;
|
||||
if (config.auth) {
|
||||
const username = config.auth.username || '';
|
||||
const password = config.auth.password || '';
|
||||
const configAuth = own('auth');
|
||||
if (configAuth) {
|
||||
const username = configAuth.username || '';
|
||||
const password = configAuth.password || '';
|
||||
auth = username + ':' + password;
|
||||
}
|
||||
|
||||
@@ -651,7 +652,10 @@ export default isHttpAdapterSupported &&
|
||||
false
|
||||
);
|
||||
|
||||
const options = {
|
||||
// Null-prototype to block prototype pollution gadgets on properties read
|
||||
// directly by Node's http.request (e.g. insecureHTTPParser, lookup).
|
||||
// See GHSA-q8qp-cvcw-x6jj.
|
||||
const options = Object.assign(Object.create(null), {
|
||||
path,
|
||||
method: method,
|
||||
headers: headers.toJSON(),
|
||||
@@ -660,9 +664,9 @@ export default isHttpAdapterSupported &&
|
||||
protocol,
|
||||
family,
|
||||
beforeRedirect: dispatchBeforeRedirect,
|
||||
beforeRedirects: {},
|
||||
beforeRedirects: Object.create(null),
|
||||
http2Options,
|
||||
};
|
||||
});
|
||||
|
||||
// cacheable-lookup integration hotfix
|
||||
!utils.isUndefined(lookup) && (options.lookup = lookup);
|
||||
@@ -723,8 +727,9 @@ export default isHttpAdapterSupported &&
|
||||
if (config.maxRedirects) {
|
||||
options.maxRedirects = config.maxRedirects;
|
||||
}
|
||||
if (config.beforeRedirect) {
|
||||
options.beforeRedirects.config = config.beforeRedirect;
|
||||
const configBeforeRedirect = own('beforeRedirect');
|
||||
if (configBeforeRedirect) {
|
||||
options.beforeRedirects.config = configBeforeRedirect;
|
||||
}
|
||||
transport = isHttpsRequest ? httpsFollow : httpFollow;
|
||||
}
|
||||
@@ -737,9 +742,10 @@ export default isHttpAdapterSupported &&
|
||||
options.maxBodyLength = Infinity;
|
||||
}
|
||||
|
||||
if (config.insecureHTTPParser) {
|
||||
options.insecureHTTPParser = config.insecureHTTPParser;
|
||||
}
|
||||
// Always set an explicit own value so a polluted
|
||||
// Object.prototype.insecureHTTPParser cannot enable the lenient parser
|
||||
// through Node's internal options copy (GHSA-q8qp-cvcw-x6jj).
|
||||
options.insecureHTTPParser = Boolean(own('insecureHTTPParser'));
|
||||
|
||||
// Create the request
|
||||
req = transport.request(options, function handleResponse(res) {
|
||||
|
||||
+12
-1
@@ -17,7 +17,18 @@ const headersToObject = (thing) => (thing instanceof AxiosHeaders ? { ...thing }
|
||||
export default function mergeConfig(config1, config2) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
config2 = config2 || {};
|
||||
const config = {};
|
||||
|
||||
// Use a null-prototype object so that downstream reads such as `config.auth`
|
||||
// or `config.baseURL` cannot inherit polluted values from Object.prototype
|
||||
// (see GHSA-q8qp-cvcw-x6jj). `hasOwnProperty` is restored as a non-enumerable
|
||||
// own slot to preserve ergonomics for user code that relies on it.
|
||||
const config = Object.create(null);
|
||||
Object.defineProperty(config, 'hasOwnProperty', {
|
||||
value: Object.prototype.hasOwnProperty,
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
function getMergedValue(target, source, prop, caseless) {
|
||||
if (utils.isPlainObject(target) && utils.isPlainObject(source)) {
|
||||
|
||||
@@ -10,12 +10,24 @@ import buildURL from './buildURL.js';
|
||||
export default (config) => {
|
||||
const newConfig = mergeConfig({}, config);
|
||||
|
||||
let { data, withXSRFToken, xsrfHeaderName, xsrfCookieName, headers, auth } = newConfig;
|
||||
// Read only own properties to prevent prototype pollution gadgets
|
||||
// (e.g. Object.prototype.baseURL = 'https://evil.com'). See GHSA-q8qp-cvcw-x6jj.
|
||||
const own = (key) => (utils.hasOwnProp(newConfig, key) ? newConfig[key] : undefined);
|
||||
|
||||
const data = own('data');
|
||||
let withXSRFToken = own('withXSRFToken');
|
||||
const xsrfHeaderName = own('xsrfHeaderName');
|
||||
const xsrfCookieName = own('xsrfCookieName');
|
||||
let headers = own('headers');
|
||||
const auth = own('auth');
|
||||
const baseURL = own('baseURL');
|
||||
const allowAbsoluteUrls = own('allowAbsoluteUrls');
|
||||
const url = own('url');
|
||||
|
||||
newConfig.headers = headers = AxiosHeaders.from(headers);
|
||||
|
||||
newConfig.url = buildURL(
|
||||
buildFullPath(newConfig.baseURL, newConfig.url, newConfig.allowAbsoluteUrls),
|
||||
buildFullPath(baseURL, url, allowAbsoluteUrls),
|
||||
config.params,
|
||||
config.paramsSerializer
|
||||
);
|
||||
|
||||
@@ -86,7 +86,9 @@ function assertOptions(options, schema, allowUnknown) {
|
||||
let i = keys.length;
|
||||
while (i-- > 0) {
|
||||
const opt = keys[i];
|
||||
const validator = schema[opt];
|
||||
// Use hasOwnProperty so a polluted Object.prototype.<opt> cannot supply
|
||||
// a non-function validator and cause a TypeError. See GHSA-q8qp-cvcw-x6jj.
|
||||
const validator = Object.prototype.hasOwnProperty.call(schema, opt) ? schema[opt] : undefined;
|
||||
if (validator) {
|
||||
const value = options[opt];
|
||||
const result = value === undefined || validator(value, opt, options);
|
||||
|
||||
+511
@@ -21,6 +21,32 @@ describe('Prototype Pollution Protection', () => {
|
||||
delete Object.prototype.family;
|
||||
delete Object.prototype.http2Options;
|
||||
delete Object.prototype.validateStatus;
|
||||
delete Object.prototype.auth;
|
||||
delete Object.prototype.baseURL;
|
||||
delete Object.prototype.socketPath;
|
||||
delete Object.prototype.beforeRedirect;
|
||||
delete Object.prototype.insecureHTTPParser;
|
||||
delete Object.prototype.adapter;
|
||||
delete Object.prototype.httpAgent;
|
||||
delete Object.prototype.httpsAgent;
|
||||
delete Object.prototype.proxy;
|
||||
delete Object.prototype.maxContentLength;
|
||||
delete Object.prototype.maxBodyLength;
|
||||
delete Object.prototype.maxRedirects;
|
||||
delete Object.prototype.maxRate;
|
||||
delete Object.prototype.timeout;
|
||||
delete Object.prototype.transitional;
|
||||
delete Object.prototype.timeoutErrorMessage;
|
||||
delete Object.prototype.env;
|
||||
delete Object.prototype.cancelToken;
|
||||
delete Object.prototype.signal;
|
||||
delete Object.prototype.decompress;
|
||||
delete Object.prototype.params;
|
||||
delete Object.prototype.paramsSerializer;
|
||||
delete Object.prototype.method;
|
||||
delete Object.prototype.withCredentials;
|
||||
delete Object.prototype.responseType;
|
||||
delete Object.prototype.fetchOptions;
|
||||
});
|
||||
|
||||
describe('utils.merge', () => {
|
||||
@@ -385,4 +411,489 @@ describe('Prototype Pollution Protection', () => {
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
// GHSA-q8qp-cvcw-x6jj: five config properties were read via direct property
|
||||
// access in the http adapter and resolveConfig, bypassing hasOwnProperty and
|
||||
// allowing prototype pollution gadgets (auth, baseURL, socketPath,
|
||||
// beforeRedirect, insecureHTTPParser).
|
||||
describe('GHSA-q8qp-cvcw-x6jj http adapter gadgets', () => {
|
||||
function startServer(handler) {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(handler || ((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));
|
||||
});
|
||||
}
|
||||
|
||||
function stopServer(server) {
|
||||
return new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
|
||||
it('should not pick up Object.prototype.auth as an Authorization header', async () => {
|
||||
Object.prototype.auth = { username: 'attacker', password: 'exfil' };
|
||||
|
||||
const server = await startServer();
|
||||
const { port } = server.address();
|
||||
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${port}/api`);
|
||||
assert.strictEqual(res.data.headers.authorization, undefined);
|
||||
} finally {
|
||||
await stopServer(server);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should not pick up Object.prototype.socketPath and redirect the request', async () => {
|
||||
Object.prototype.socketPath = '/tmp/axios-should-never-be-used.sock';
|
||||
|
||||
const server = await startServer();
|
||||
const { port } = server.address();
|
||||
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${port}/api`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.data.url, '/api');
|
||||
} finally {
|
||||
await stopServer(server);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should not invoke Object.prototype.beforeRedirect during redirects', async () => {
|
||||
let hijackCalled = false;
|
||||
Object.prototype.beforeRedirect = function polluted() {
|
||||
hijackCalled = true;
|
||||
};
|
||||
|
||||
const target = await startServer();
|
||||
const { port: targetPort } = target.address();
|
||||
|
||||
const redirector = await startServer((req, res) => {
|
||||
res.writeHead(302, { Location: `http://127.0.0.1:${targetPort}/final` });
|
||||
res.end();
|
||||
});
|
||||
const { port: redirectorPort } = redirector.address();
|
||||
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${redirectorPort}/start`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(hijackCalled, false);
|
||||
} finally {
|
||||
await stopServer(redirector);
|
||||
await stopServer(target);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should not enable insecureHTTPParser via Object.prototype', async () => {
|
||||
// A raw TCP server emits a response that uses LF-only line terminators
|
||||
// instead of CRLF. Node's strict HTTP parser rejects this payload with
|
||||
// HPE_CR_EXPECTED; the insecure parser accepts it. Verified: with an
|
||||
// explicit `insecureHTTPParser: true` on the request config, this
|
||||
// payload is parsed successfully — so if Object.prototype.insecureHTTPParser
|
||||
// were picked up, the request would succeed. The request must fail when
|
||||
// the gadget is properly blocked.
|
||||
Object.prototype.insecureHTTPParser = true;
|
||||
|
||||
const net = await import('net');
|
||||
const malformedPayload =
|
||||
'HTTP/1.1 200 OK\n' +
|
||||
'Content-Type: application/json\n' +
|
||||
'Content-Length: 2\n' +
|
||||
'\n' +
|
||||
'{}';
|
||||
const malformed = await new Promise((resolve) => {
|
||||
const srv = net.createServer((socket) => {
|
||||
socket.once('data', () => socket.end(malformedPayload));
|
||||
});
|
||||
srv.listen(0, '127.0.0.1', () => resolve(srv));
|
||||
});
|
||||
const { port } = malformed.address();
|
||||
|
||||
try {
|
||||
let threw = false;
|
||||
let caughtCode = '';
|
||||
try {
|
||||
await axios.get(`http://127.0.0.1:${port}/`, {
|
||||
transitional: { clarifyTimeoutError: false },
|
||||
});
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
caughtCode = String(err && (err.code || err.message));
|
||||
}
|
||||
assert.strictEqual(
|
||||
threw,
|
||||
true,
|
||||
`request should be rejected by the strict HTTP parser (got: ${caughtCode || 'success'})`
|
||||
);
|
||||
// The exact llhttp code for LF-only line terminators varies across
|
||||
// Node versions (historically HPE_LF_EXPECTED, more recently
|
||||
// HPE_CR_EXPECTED). Match any parser error to remain stable across
|
||||
// Node releases while still confirming the strict parser rejected
|
||||
// the payload.
|
||||
assert.match(
|
||||
caughtCode,
|
||||
/^HPE_/,
|
||||
`expected an HPE_* parser error, got: ${caughtCode}`
|
||||
);
|
||||
} finally {
|
||||
await new Promise((resolve) => malformed.close(resolve));
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe('GHSA-q8qp-cvcw-x6jj resolveConfig baseURL gadget', () => {
|
||||
// The baseURL branch in buildFullPath only runs when the requested URL is
|
||||
// relative (or allowAbsoluteUrls === false). An absolute URL would skip
|
||||
// baseURL regardless of pollution and would not exercise the gadget. We
|
||||
// therefore issue a relative GET and assert that either:
|
||||
// - the request fails (no host to resolve) because baseURL is correctly
|
||||
// absent from the merged config, OR
|
||||
// - the request is fulfilled without hitting the hijacker.
|
||||
// Critically, hijackHit must always be false.
|
||||
it('should not hijack relative-URL requests via Object.prototype.baseURL', async () => {
|
||||
let hijackHit = false;
|
||||
const hijacker = http.createServer((req, res) => {
|
||||
hijackHit = true;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{"hijacked":true}');
|
||||
});
|
||||
await new Promise((resolve) => hijacker.listen(0, '127.0.0.1', resolve));
|
||||
const { port: hijackerPort } = hijacker.address();
|
||||
|
||||
Object.prototype.baseURL = `http://127.0.0.1:${hijackerPort}`;
|
||||
|
||||
try {
|
||||
let threw = false;
|
||||
try {
|
||||
await axios.get('/api');
|
||||
} catch (_err) {
|
||||
threw = true;
|
||||
}
|
||||
// Either the request fails (desired — no baseURL means no host) or it
|
||||
// resolves, but it must NOT hit the polluted hijacker.
|
||||
assert.strictEqual(hijackHit, false);
|
||||
assert.strictEqual(threw, true);
|
||||
} finally {
|
||||
await new Promise((resolve) => hijacker.close(resolve));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
// Second variant using allowAbsoluteUrls: false to force the baseURL path
|
||||
// even for a fully-qualified requested URL.
|
||||
it('should not hijack requests via Object.prototype.baseURL with allowAbsoluteUrls:false', async () => {
|
||||
let hijackHit = false;
|
||||
const hijacker = http.createServer((req, res) => {
|
||||
hijackHit = true;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{"hijacked":true}');
|
||||
});
|
||||
await new Promise((resolve) => hijacker.listen(0, '127.0.0.1', resolve));
|
||||
const { port: hijackerPort } = hijacker.address();
|
||||
|
||||
const target = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{"ok":true}');
|
||||
});
|
||||
await new Promise((resolve) => target.listen(0, '127.0.0.1', resolve));
|
||||
const { port: targetPort } = target.address();
|
||||
|
||||
Object.prototype.baseURL = `http://127.0.0.1:${hijackerPort}`;
|
||||
|
||||
try {
|
||||
// If the gadget were picked up, combineURLs(hijacker, `http://target`)
|
||||
// would route to the hijacker. It must not.
|
||||
let threw = false;
|
||||
try {
|
||||
await axios.get(`http://127.0.0.1:${targetPort}/api`, {
|
||||
allowAbsoluteUrls: false,
|
||||
});
|
||||
} catch (_err) {
|
||||
threw = true;
|
||||
}
|
||||
assert.strictEqual(hijackHit, false);
|
||||
// allowAbsoluteUrls:false + no baseURL → combineURLs not invoked
|
||||
// (baseURL falsy) → returns requested URL as-is → target receives it.
|
||||
// If baseURL were inherited from prototype, it would be truthy and
|
||||
// combineURLs would be invoked, routing to the hijacker.
|
||||
assert.strictEqual(threw, false);
|
||||
} finally {
|
||||
await new Promise((resolve) => hijacker.close(resolve));
|
||||
await new Promise((resolve) => target.close(resolve));
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
// Structural defense: mergeConfig returns a null-prototype object, so any
|
||||
// property read that is not an own property of config cannot inherit from
|
||||
// Object.prototype. Adding a new key to Object.prototype must never appear
|
||||
// as a property of the merged config.
|
||||
describe('mergeConfig null-prototype structural defense', () => {
|
||||
it('should return an object whose prototype is null', () => {
|
||||
const merged = mergeConfig({ url: '/x' }, { method: 'get' });
|
||||
assert.strictEqual(Object.getPrototypeOf(merged), null);
|
||||
});
|
||||
|
||||
it('should preserve hasOwnProperty as a callable own slot', () => {
|
||||
const merged = mergeConfig({}, { url: '/x', method: 'get' });
|
||||
assert.strictEqual(typeof merged.hasOwnProperty, 'function');
|
||||
assert.strictEqual(merged.hasOwnProperty('url'), true);
|
||||
assert.strictEqual(merged.hasOwnProperty('method'), true);
|
||||
assert.strictEqual(merged.hasOwnProperty('bogus'), false);
|
||||
});
|
||||
|
||||
it('should not serialize hasOwnProperty slot via Object.keys', () => {
|
||||
const merged = mergeConfig({ url: '/x' }, {});
|
||||
assert.ok(!Object.keys(merged).includes('hasOwnProperty'));
|
||||
});
|
||||
|
||||
it('should not expose arbitrary polluted keys as inherited properties', () => {
|
||||
Object.prototype.polluted = 'attacker';
|
||||
try {
|
||||
const merged = mergeConfig({ url: '/x' }, {});
|
||||
assert.strictEqual(merged.polluted, undefined);
|
||||
} finally {
|
||||
delete Object.prototype.polluted;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Verify every gadget enumerated in the audit (extension of GHSA-q8qp-cvcw-x6jj)
|
||||
// is neutralized end-to-end by the null-prototype config.
|
||||
describe('Full gadget coverage via null-prototype config', () => {
|
||||
function startEcho(handler) {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(handler || ((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
body,
|
||||
}));
|
||||
});
|
||||
}));
|
||||
server.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
const stop = (s) => new Promise((r) => s.close(r));
|
||||
|
||||
it('should ignore polluted transformRequest', async () => {
|
||||
let invoked = false;
|
||||
Object.prototype.transformRequest = function polluted(data) {
|
||||
invoked = true;
|
||||
return 'INJECTED';
|
||||
};
|
||||
|
||||
const server = await startEcho();
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await axios.post(`http://127.0.0.1:${port}/`, { hello: 'world' });
|
||||
assert.strictEqual(invoked, false);
|
||||
assert.notStrictEqual(res.data.body, 'INJECTED');
|
||||
} finally {
|
||||
await stop(server);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should ignore polluted transformResponse', async () => {
|
||||
let invoked = false;
|
||||
Object.prototype.transformResponse = function polluted() {
|
||||
invoked = true;
|
||||
return 'HIJACKED';
|
||||
};
|
||||
|
||||
const server = await startEcho();
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${port}/`);
|
||||
assert.strictEqual(invoked, false);
|
||||
assert.notStrictEqual(res.data, 'HIJACKED');
|
||||
} finally {
|
||||
await stop(server);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should ignore polluted adapter', async () => {
|
||||
let hijacked = false;
|
||||
Object.prototype.adapter = function pollutedAdapter() {
|
||||
hijacked = true;
|
||||
return Promise.resolve({ data: 'pwned', status: 200, statusText: 'OK', headers: {}, config: {}, request: {} });
|
||||
};
|
||||
|
||||
const server = await startEcho();
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${port}/ok`);
|
||||
assert.strictEqual(hijacked, false);
|
||||
assert.notStrictEqual(res.data, 'pwned');
|
||||
} finally {
|
||||
await stop(server);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should ignore polluted httpAgent', async () => {
|
||||
let agentUsed = false;
|
||||
Object.prototype.httpAgent = new http.Agent({
|
||||
keepAlive: false,
|
||||
});
|
||||
// Wrap createConnection to detect usage
|
||||
const origCreate = Object.prototype.httpAgent.createConnection;
|
||||
Object.prototype.httpAgent.createConnection = function (...args) {
|
||||
agentUsed = true;
|
||||
return origCreate.apply(this, args);
|
||||
};
|
||||
|
||||
const server = await startEcho();
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${port}/`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(agentUsed, false);
|
||||
} finally {
|
||||
await stop(server);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should ignore polluted proxy', async () => {
|
||||
Object.prototype.proxy = {
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 1, // would fail if actually used
|
||||
};
|
||||
|
||||
const server = await startEcho();
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${port}/`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
} finally {
|
||||
await stop(server);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should ignore polluted maxContentLength', async () => {
|
||||
// Polluted tiny limit would reject a normal response if applied.
|
||||
Object.prototype.maxContentLength = 1;
|
||||
|
||||
const server = await startEcho();
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${port}/`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
} finally {
|
||||
await stop(server);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should ignore polluted maxRedirects', async () => {
|
||||
// Pollute with 0 — if picked up, follow-redirects path would be skipped.
|
||||
// We make sure regular requests still succeed via the expected path.
|
||||
Object.prototype.maxRedirects = 0;
|
||||
|
||||
const server = await startEcho();
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${port}/`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
} finally {
|
||||
await stop(server);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should ignore polluted timeout at the merged config level', () => {
|
||||
Object.prototype.timeout = 1;
|
||||
const merged = mergeConfig({}, { url: '/x' });
|
||||
assert.strictEqual(Object.prototype.hasOwnProperty.call(merged, 'timeout'), false);
|
||||
assert.strictEqual(merged.timeout, undefined);
|
||||
});
|
||||
|
||||
it('should ignore polluted timeoutErrorMessage', async () => {
|
||||
Object.prototype.timeoutErrorMessage = 'INJECTED_TIMEOUT';
|
||||
// Not easy to assert without triggering a real timeout; just confirm
|
||||
// normal requests still succeed and do not read the polluted key.
|
||||
const server = await startEcho();
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${port}/`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
} finally {
|
||||
await stop(server);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should ignore polluted transitional', async () => {
|
||||
Object.prototype.transitional = { forcedJSONParsing: true, silentJSONParsing: false };
|
||||
const server = await startEcho();
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${port}/`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
} finally {
|
||||
await stop(server);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should ignore polluted params and paramsSerializer', async () => {
|
||||
let serializerInvoked = false;
|
||||
Object.prototype.params = { injected: 'yes' };
|
||||
Object.prototype.paramsSerializer = function polluted() {
|
||||
serializerInvoked = true;
|
||||
return 'injected=yes';
|
||||
};
|
||||
|
||||
const server = await startEcho();
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${port}/x`);
|
||||
assert.strictEqual(serializerInvoked, false);
|
||||
assert.strictEqual(res.data.url, '/x');
|
||||
} finally {
|
||||
await stop(server);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should ignore polluted method', async () => {
|
||||
Object.prototype.method = 'DELETE';
|
||||
const server = await startEcho();
|
||||
const { port } = server.address();
|
||||
try {
|
||||
// axios.get should still send GET, not DELETE.
|
||||
const res = await axios.get(`http://127.0.0.1:${port}/ok`);
|
||||
assert.strictEqual(res.data.method, 'GET');
|
||||
} finally {
|
||||
await stop(server);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should ignore polluted decompress', async () => {
|
||||
Object.prototype.decompress = false;
|
||||
const server = await startEcho();
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${port}/`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
} finally {
|
||||
await stop(server);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should ignore polluted responseType', async () => {
|
||||
Object.prototype.responseType = 'arraybuffer';
|
||||
const server = await startEcho();
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${port}/`);
|
||||
// When responseType is not set on config, json parsing should apply
|
||||
// and res.data should be an object, not an ArrayBuffer/Buffer.
|
||||
assert.strictEqual(typeof res.data, 'object');
|
||||
assert.ok(!Buffer.isBuffer(res.data));
|
||||
} finally {
|
||||
await stop(server);
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user