From 47915144662f2733e6c051bdcb895a8c8f0586aa Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 20 Apr 2026 21:33:19 +0200 Subject: [PATCH] fix: more header pollutions (#10779) * fix: more header pollutions * fix: more header pollution issues * fix: cubic feedback * fix: prototype test --- CHANGELOG.md | 88 ++--- lib/adapters/http.js | 28 +- lib/core/mergeConfig.js | 13 +- lib/helpers/resolveConfig.js | 16 +- lib/helpers/validator.js | 4 +- tests/unit/prototypePollution.test.js | 511 ++++++++++++++++++++++++++ 6 files changed, 601 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c280562..1979e46b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/lib/adapters/http.js b/lib/adapters/http.js index e3f22f7e..58ebfa4a 100755 --- a/lib/adapters/http.js +++ b/lib/adapters/http.js @@ -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) { diff --git a/lib/core/mergeConfig.js b/lib/core/mergeConfig.js index 2c4bf4ce..65e0e394 100644 --- a/lib/core/mergeConfig.js +++ b/lib/core/mergeConfig.js @@ -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)) { diff --git a/lib/helpers/resolveConfig.js b/lib/helpers/resolveConfig.js index 24610670..f5ff1898 100644 --- a/lib/helpers/resolveConfig.js +++ b/lib/helpers/resolveConfig.js @@ -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 ); diff --git a/lib/helpers/validator.js b/lib/helpers/validator.js index 22798aa9..e84947a8 100644 --- a/lib/helpers/validator.js +++ b/lib/helpers/validator.js @@ -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. 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); diff --git a/tests/unit/prototypePollution.test.js b/tests/unit/prototypePollution.test.js index cab0472f..307c41db 100644 --- a/tests/unit/prototypePollution.test.js +++ b/tests/unit/prototypePollution.test.js @@ -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); + }); });