From 73a7c552820291ee491e4dd8fa901cef113ec800 Mon Sep 17 00:00:00 2001 From: Mohammad Faiz Date: Wed, 27 May 2026 00:39:03 +0530 Subject: [PATCH] fix: clean up error handling, fix a proto-pollution gap, and seal a few loose ends (#10922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Clean up error handling, fix a proto-pollution gap, and seal a few loose ends. Been poking around the codebase and found a handful of things that needed tidying up: - resolveConfig.js - config.params and config.paramsSerializer were being accessed directly off user input instead of going through the own() guard. If someone crafted a config with inherited params from the prototype, you'd get unexpected behavior. Swapped to own('params') / own('paramsSerializer') like the rest of the module does. - http.js - there was a stray console.warn('emit error', err) in an abort-event catch block. Debug leftover, shouldn't reach production. Replaced with a quiet catch. - AxiosHeaders.js - three places were throwing raw Error or TypeError instead of AxiosError. Swapped them over with ERR_BAD_OPTION_VALUE. Also added the missing AxiosError import (creates a circular dep with AxiosError.js which imports AxiosHeaders, but it works fine at runtime since the throws are inside method bodies, not at module eval time). - toFormData.js - the circular reference detection was throwing a bare Error. Changed to AxiosError without a code, so it stays distinguishable from the depth-exceeded error that uses ERR_FORM_DATA_DEPTH_EXCEEDED. (There's a test that explicitly checks this distinction.) - formDataToStream.js - two more raw throws (TypeError and Error) → AxiosError. - buildURL.js - import self-path ../helpers/AxiosURLSearchParams.js when it lives in the same directory as the importer. Changed to ./AxiosURLSearchParams.js. - index.d.cts - CanceledError was missing readonly name: 'CanceledError' that index.d.ts already has. Added it to keep the CJS declarations in sync. Lint passes clean, all 770 unit tests green. Nothing breaking - all changes are either internal (no consumer-facing API change) or type-only. * Update AxiosHeaders.js * Update AxiosHeaders.js * fix: revert breaking error-type changes per review feedback Reverts AxiosError throws back to native Error/TypeError in AxiosHeaders, formDataToStream, and toFormData to avoid breaking existing consumers who catch by constructor name or check isAxiosError(). Adds regression tests for resolveConfig own('params')/own('paramsSerializer') guard as requested in review. Removes unused AxiosError imports from AxiosHeaders and formDataToStream. * docs: add pre-release notes for config hardening --------- Co-authored-by: Jason Saayman --- PRE_RELEASE_CHANGELOG.md | 2 ++ index.d.cts | 4 +++- lib/adapters/http.js | 2 +- lib/core/AxiosHeaders.js | 2 +- lib/helpers/buildURL.js | 2 +- lib/helpers/formDataToStream.js | 4 ++-- lib/helpers/resolveConfig.js | 4 ++-- lib/helpers/toFormData.js | 2 +- tests/unit/prototypePollution.test.js | 34 +++++++++++++++++++++++++++ 9 files changed, 47 insertions(+), 9 deletions(-) diff --git a/PRE_RELEASE_CHANGELOG.md b/PRE_RELEASE_CHANGELOG.md index b41d2094..226a861f 100644 --- a/PRE_RELEASE_CHANGELOG.md +++ b/PRE_RELEASE_CHANGELOG.md @@ -9,6 +9,8 @@ ## Bug Fixes - **AxiosHeaders:** Silently skip empty response header names emitted by some React Native Android responses instead of throwing. (**#6959**, **#10875**) +- **Config Security:** Ignore inherited `params` and `paramsSerializer` values when resolving request config, preventing prototype-pollution gadgets from changing serialized URLs. (**#10922**) +- **Types:** Add the missing readonly `name: 'CanceledError'` declaration to CommonJS `CanceledError` typings to match the ESM declarations. (**#10922**) - **HTTP Adapter - Auth on Redirect:** HTTP Basic credentials supplied via `config.auth` are now restored on same-origin redirects, fixing a regression caused by `follow-redirects` >= 1.15.8 that broke `POST` requests answered with a 303 Location. Cross-origin redirects continue to drop credentials, preserving the existing T-R2 mitigation in `THREATMODEL.md`. (**#6929**) - **HTTP Adapter - Socket Path:** Ignore inherited `socketPath` and `allowedSocketPaths` config values when building Node.js requests, preventing prototype-pollution SSRF via Unix sockets. (**#10901**) - **React Native FormData:** Clear the default `Content-Type` header for React Native `FormData` requests so Android can build multipart bodies with the correct boundary. (**#10898**) diff --git a/index.d.cts b/index.d.cts index 2bcae97d..30b093ef 100644 --- a/index.d.cts +++ b/index.d.cts @@ -162,7 +162,9 @@ declare class AxiosError extends Error { static readonly ETIMEDOUT = 'ETIMEDOUT'; } -declare class CanceledError extends AxiosError {} +declare class CanceledError extends AxiosError { + readonly name: 'CanceledError'; +} declare class Axios { constructor(config?: axios.AxiosRequestConfig); diff --git a/lib/adapters/http.js b/lib/adapters/http.js index 6aaee563..5def8c4b 100755 --- a/lib/adapters/http.js +++ b/lib/adapters/http.js @@ -481,7 +481,7 @@ export default isHttpAdapterSupported && !reason || reason.type ? new CanceledError(null, config, req) : reason ); } catch (err) { - console.warn('emit error', err); + // ignore emit errors } } diff --git a/lib/core/AxiosHeaders.js b/lib/core/AxiosHeaders.js index 551f5994..c662a8b2 100644 --- a/lib/core/AxiosHeaders.js +++ b/lib/core/AxiosHeaders.js @@ -117,7 +117,7 @@ class AxiosHeaders { key; for (const entry of header) { if (!utils.isArray(entry)) { - throw TypeError('Object iterator must return a key-value pair'); + throw new TypeError('Object iterator must return a key-value pair'); } obj[(key = entry[0])] = (dest = obj[key]) diff --git a/lib/helpers/buildURL.js b/lib/helpers/buildURL.js index b3e230e5..27c0f6c8 100644 --- a/lib/helpers/buildURL.js +++ b/lib/helpers/buildURL.js @@ -1,7 +1,7 @@ 'use strict'; import utils from '../utils.js'; -import AxiosURLSearchParams from '../helpers/AxiosURLSearchParams.js'; +import AxiosURLSearchParams from './AxiosURLSearchParams.js'; /** * It replaces URL-encoded forms of `:`, `$`, `,`, and spaces with diff --git a/lib/helpers/formDataToStream.js b/lib/helpers/formDataToStream.js index 38320493..17f369da 100644 --- a/lib/helpers/formDataToStream.js +++ b/lib/helpers/formDataToStream.js @@ -73,11 +73,11 @@ const formDataToStream = (form, headersHandler, options) => { } = options || {}; if (!utils.isFormData(form)) { - throw TypeError('FormData instance required'); + throw new TypeError('FormData instance required'); } if (boundary.length < 1 || boundary.length > 70) { - throw Error('boundary must be 1-70 characters long'); + throw new Error('boundary must be 1-70 characters long'); } const boundaryBytes = textEncoder.encode('--' + boundary + CRLF); diff --git a/lib/helpers/resolveConfig.js b/lib/helpers/resolveConfig.js index ea9d346a..b746d4b2 100644 --- a/lib/helpers/resolveConfig.js +++ b/lib/helpers/resolveConfig.js @@ -56,8 +56,8 @@ function resolveConfig(config) { newConfig.url = buildURL( buildFullPath(baseURL, url, allowAbsoluteUrls), - config.params, - config.paramsSerializer + own('params'), + own('paramsSerializer') ); // HTTP basic authentication diff --git a/lib/helpers/toFormData.js b/lib/helpers/toFormData.js index a1c7cf9e..b1d1b0b5 100644 --- a/lib/helpers/toFormData.js +++ b/lib/helpers/toFormData.js @@ -219,7 +219,7 @@ function toFormData(obj, formData, options) { } if (stack.indexOf(value) !== -1) { - throw Error('Circular reference detected in ' + path.join('.')); + throw new Error('Circular reference detected in ' + path.join('.')); } stack.push(value); diff --git a/tests/unit/prototypePollution.test.js b/tests/unit/prototypePollution.test.js index 1d9faa1b..c533991c 100644 --- a/tests/unit/prototypePollution.test.js +++ b/tests/unit/prototypePollution.test.js @@ -7,6 +7,7 @@ import mergeConfig from '../../lib/core/mergeConfig.js'; import defaults from '../../lib/defaults/index.js'; import AxiosError from '../../lib/core/AxiosError.js'; import AxiosHeaders from '../../lib/core/AxiosHeaders.js'; +import resolveConfig from '../../lib/helpers/resolveConfig.js'; import axios from '../../index.js'; describe('Prototype Pollution Protection', () => { @@ -690,6 +691,39 @@ describe('Prototype Pollution Protection', () => { }, 10000); }); + describe('resolveConfig params and paramsSerializer gadget', () => { + it('should not inherit polluted params via resolveConfig', () => { + Object.prototype.params = { injected: 'yes' }; + + try { + const resolved = resolveConfig({ url: '/api', method: 'get' }); + + assert.ok(resolved.url.indexOf('injected') === -1, 'polluted params must not appear in URL'); + assert.strictEqual(resolved.url, '/api', 'URL must remain unchanged'); + } finally { + delete Object.prototype.params; + } + }); + + it('should not invoke polluted paramsSerializer via resolveConfig', () => { + let serializerInvoked = false; + Object.prototype.paramsSerializer = function polluted() { + serializerInvoked = true; + return 'injected=yes'; + }; + + try { + const resolved = resolveConfig({ url: '/api', method: 'get', params: { legit: 'true' } }); + + assert.strictEqual(serializerInvoked, false, 'polluted paramsSerializer must not be called'); + // The URL should have legit param serialized normally + assert.ok(resolved.url.indexOf('legit=true') !== -1, 'legitimate params must still be serialized'); + } finally { + delete Object.prototype.paramsSerializer; + } + }); + }); + // 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