From 28c721588c7a77e7503d0a434e016f852c597b57 Mon Sep 17 00:00:00 2001 From: Jay Date: Wed, 4 Feb 2026 20:25:06 +0200 Subject: [PATCH] fix: Denial of Service via __proto__ Key in mergeConfig (#7369) * fix: sec issue as per advisory * chore: expand and add tests --- lib/core/mergeConfig.js | 32 ++- lib/utils.js | 352 ++++++++++++++++----------- test/unit/core/prototypePollution.js | 226 +++++++++++++++++ 3 files changed, 457 insertions(+), 153 deletions(-) create mode 100644 test/unit/core/prototypePollution.js diff --git a/lib/core/mergeConfig.js b/lib/core/mergeConfig.js index b7a43d5..de8e394 100644 --- a/lib/core/mergeConfig.js +++ b/lib/core/mergeConfig.js @@ -1,9 +1,10 @@ -'use strict'; +"use strict"; -import utils from '../utils.js'; +import utils from "../utils.js"; import AxiosHeaders from "./AxiosHeaders.js"; -const headersToObject = (thing) => thing instanceof AxiosHeaders ? { ...thing } : thing; +const headersToObject = (thing) => + thing instanceof AxiosHeaders ? { ...thing } : thing; /** * Config-specific merge-function which creates a new config-object @@ -92,14 +93,27 @@ export default function mergeConfig(config1, config2) { socketPath: defaultToConfig2, responseEncoding: defaultToConfig2, validateStatus: mergeDirectKeys, - headers: (a, b, prop) => mergeDeepProperties(headersToObject(a), headersToObject(b), prop, true) + headers: (a, b, prop) => + mergeDeepProperties(headersToObject(a), headersToObject(b), prop, true), }; - utils.forEach(Object.keys({ ...config1, ...config2 }), function computeConfigValue(prop) { - const merge = mergeMap[prop] || mergeDeepProperties; - const configValue = merge(config1[prop], config2[prop], prop); - (utils.isUndefined(configValue) && merge !== mergeDirectKeys) || (config[prop] = configValue); - }); + utils.forEach( + Object.keys({ ...config1, ...config2 }), + function computeConfigValue(prop) { + if ( + prop === "__proto__" || + prop === "constructor" || + prop === "prototype" + ) + return; + const merge = utils.hasOwnProp(mergeMap, prop) + ? mergeMap[prop] + : mergeDeepProperties; + const configValue = merge(config1[prop], config2[prop], prop); + (utils.isUndefined(configValue) && merge !== mergeDirectKeys) || + (config[prop] = configValue); + }, + ); return config; } diff --git a/lib/utils.js b/lib/utils.js index 245282e..b464a62 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,24 +1,24 @@ -'use strict'; +"use strict"; -import bind from './helpers/bind.js'; +import bind from "./helpers/bind.js"; // utils is a library of generic helper functions non-specific to axios -const {toString} = Object.prototype; -const {getPrototypeOf} = Object; -const {iterator, toStringTag} = Symbol; +const { toString } = Object.prototype; +const { getPrototypeOf } = Object; +const { iterator, toStringTag } = Symbol; -const kindOf = (cache => thing => { - const str = toString.call(thing); - return cache[str] || (cache[str] = str.slice(8, -1).toLowerCase()); +const kindOf = ((cache) => (thing) => { + const str = toString.call(thing); + return cache[str] || (cache[str] = str.slice(8, -1).toLowerCase()); })(Object.create(null)); const kindOfTest = (type) => { type = type.toLowerCase(); - return (thing) => kindOf(thing) === type -} + return (thing) => kindOf(thing) === type; +}; -const typeOfTest = type => thing => typeof thing === type; +const typeOfTest = (type) => (thing) => typeof thing === type; /** * Determine if a value is a non-null object @@ -27,7 +27,7 @@ const typeOfTest = type => thing => typeof thing === type; * * @returns {boolean} True if value is an Array, otherwise false */ -const {isArray} = Array; +const { isArray } = Array; /** * Determine if a value is undefined @@ -36,7 +36,7 @@ const {isArray} = Array; * * @returns {boolean} True if the value is undefined, otherwise false */ -const isUndefined = typeOfTest('undefined'); +const isUndefined = typeOfTest("undefined"); /** * Determine if a value is a Buffer @@ -46,8 +46,14 @@ const isUndefined = typeOfTest('undefined'); * @returns {boolean} True if value is a Buffer, otherwise false */ function isBuffer(val) { - return val !== null && !isUndefined(val) && val.constructor !== null && !isUndefined(val.constructor) - && isFunction(val.constructor.isBuffer) && val.constructor.isBuffer(val); + return ( + val !== null && + !isUndefined(val) && + val.constructor !== null && + !isUndefined(val.constructor) && + isFunction(val.constructor.isBuffer) && + val.constructor.isBuffer(val) + ); } /** @@ -57,8 +63,7 @@ function isBuffer(val) { * * @returns {boolean} True if value is an ArrayBuffer, otherwise false */ -const isArrayBuffer = kindOfTest('ArrayBuffer'); - +const isArrayBuffer = kindOfTest("ArrayBuffer"); /** * Determine if a value is a view on an ArrayBuffer @@ -69,10 +74,10 @@ const isArrayBuffer = kindOfTest('ArrayBuffer'); */ function isArrayBufferView(val) { let result; - if ((typeof ArrayBuffer !== 'undefined') && (ArrayBuffer.isView)) { + if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView) { result = ArrayBuffer.isView(val); } else { - result = (val) && (val.buffer) && (isArrayBuffer(val.buffer)); + result = val && val.buffer && isArrayBuffer(val.buffer); } return result; } @@ -84,7 +89,7 @@ function isArrayBufferView(val) { * * @returns {boolean} True if value is a String, otherwise false */ -const isString = typeOfTest('string'); +const isString = typeOfTest("string"); /** * Determine if a value is a Function @@ -92,7 +97,7 @@ const isString = typeOfTest('string'); * @param {*} val The value to test * @returns {boolean} True if value is a Function, otherwise false */ -const isFunction = typeOfTest('function'); +const isFunction = typeOfTest("function"); /** * Determine if a value is a Number @@ -101,7 +106,7 @@ const isFunction = typeOfTest('function'); * * @returns {boolean} True if value is a Number, otherwise false */ -const isNumber = typeOfTest('number'); +const isNumber = typeOfTest("number"); /** * Determine if a value is an Object @@ -110,7 +115,7 @@ const isNumber = typeOfTest('number'); * * @returns {boolean} True if value is an Object, otherwise false */ -const isObject = (thing) => thing !== null && typeof thing === 'object'; +const isObject = (thing) => thing !== null && typeof thing === "object"; /** * Determine if a value is a Boolean @@ -118,7 +123,7 @@ const isObject = (thing) => thing !== null && typeof thing === 'object'; * @param {*} thing The value to test * @returns {boolean} True if value is a Boolean, otherwise false */ -const isBoolean = thing => thing === true || thing === false; +const isBoolean = (thing) => thing === true || thing === false; /** * Determine if a value is a plain Object @@ -128,13 +133,19 @@ const isBoolean = thing => thing === true || thing === false; * @returns {boolean} True if value is a plain Object, otherwise false */ const isPlainObject = (val) => { - if (kindOf(val) !== 'object') { + if (kindOf(val) !== "object") { return false; } const prototype = getPrototypeOf(val); - return (prototype === null || prototype === Object.prototype || Object.getPrototypeOf(prototype) === null) && !(toStringTag in val) && !(iterator in val); -} + return ( + (prototype === null || + prototype === Object.prototype || + Object.getPrototypeOf(prototype) === null) && + !(toStringTag in val) && + !(iterator in val) + ); +}; /** * Determine if a value is an empty object (safely handles Buffers) @@ -150,12 +161,15 @@ const isEmptyObject = (val) => { } try { - return Object.keys(val).length === 0 && Object.getPrototypeOf(val) === Object.prototype; + return ( + Object.keys(val).length === 0 && + Object.getPrototypeOf(val) === Object.prototype + ); } catch (e) { // Fallback for any other objects that might cause RangeError with Object.keys() return false; } -} +}; /** * Determine if a value is a Date @@ -164,7 +178,7 @@ const isEmptyObject = (val) => { * * @returns {boolean} True if value is a Date, otherwise false */ -const isDate = kindOfTest('Date'); +const isDate = kindOfTest("Date"); /** * Determine if a value is a File @@ -173,7 +187,7 @@ const isDate = kindOfTest('Date'); * * @returns {boolean} True if value is a File, otherwise false */ -const isFile = kindOfTest('File'); +const isFile = kindOfTest("File"); /** * Determine if a value is a Blob @@ -182,7 +196,7 @@ const isFile = kindOfTest('File'); * * @returns {boolean} True if value is a Blob, otherwise false */ -const isBlob = kindOfTest('Blob'); +const isBlob = kindOfTest("Blob"); /** * Determine if a value is a FileList @@ -191,7 +205,7 @@ const isBlob = kindOfTest('Blob'); * * @returns {boolean} True if value is a File, otherwise false */ -const isFileList = kindOfTest('FileList'); +const isFileList = kindOfTest("FileList"); /** * Determine if a value is a Stream @@ -211,16 +225,17 @@ const isStream = (val) => isObject(val) && isFunction(val.pipe); */ const isFormData = (thing) => { let kind; - return thing && ( - (typeof FormData === 'function' && thing instanceof FormData) || ( - isFunction(thing.append) && ( - (kind = kindOf(thing)) === 'formdata' || - // detect form-data instance - (kind === 'object' && isFunction(thing.toString) && thing.toString() === '[object FormData]') - ) - ) - ) -} + return ( + thing && + ((typeof FormData === "function" && thing instanceof FormData) || + (isFunction(thing.append) && + ((kind = kindOf(thing)) === "formdata" || + // detect form-data instance + (kind === "object" && + isFunction(thing.toString) && + thing.toString() === "[object FormData]")))) + ); +}; /** * Determine if a value is a URLSearchParams object @@ -229,9 +244,14 @@ const isFormData = (thing) => { * * @returns {boolean} True if value is a URLSearchParams object, otherwise false */ -const isURLSearchParams = kindOfTest('URLSearchParams'); +const isURLSearchParams = kindOfTest("URLSearchParams"); -const [isReadableStream, isRequest, isResponse, isHeaders] = ['ReadableStream', 'Request', 'Response', 'Headers'].map(kindOfTest); +const [isReadableStream, isRequest, isResponse, isHeaders] = [ + "ReadableStream", + "Request", + "Response", + "Headers", +].map(kindOfTest); /** * Trim excess whitespace off the beginning and end of a string @@ -240,8 +260,8 @@ const [isReadableStream, isRequest, isResponse, isHeaders] = ['ReadableStream', * * @returns {String} The String freed of excess whitespace */ -const trim = (str) => str.trim ? - str.trim() : str.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, ''); +const trim = (str) => + str.trim ? str.trim() : str.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, ""); /** * Iterate over an Array or an Object invoking a function for each item. @@ -259,9 +279,9 @@ const trim = (str) => str.trim ? * @param {Boolean} [options.allOwnKeys = false] * @returns {any} */ -function forEach(obj, fn, {allOwnKeys = false} = {}) { +function forEach(obj, fn, { allOwnKeys = false } = {}) { // Don't bother if no value provided - if (obj === null || typeof obj === 'undefined') { + if (obj === null || typeof obj === "undefined") { return; } @@ -269,7 +289,7 @@ function forEach(obj, fn, {allOwnKeys = false} = {}) { let l; // Force an array if not already something iterable - if (typeof obj !== 'object') { + if (typeof obj !== "object") { /*eslint no-param-reassign:0*/ obj = [obj]; } @@ -286,7 +306,9 @@ function forEach(obj, fn, {allOwnKeys = false} = {}) { } // Iterate over object keys - const keys = allOwnKeys ? Object.getOwnPropertyNames(obj) : Object.keys(obj); + const keys = allOwnKeys + ? Object.getOwnPropertyNames(obj) + : Object.keys(obj); const len = keys.length; let key; @@ -298,7 +320,7 @@ function forEach(obj, fn, {allOwnKeys = false} = {}) { } function findKey(obj, key) { - if (isBuffer(obj)){ + if (isBuffer(obj)) { return null; } @@ -318,10 +340,15 @@ function findKey(obj, key) { const _global = (() => { /*eslint no-undef:0*/ if (typeof globalThis !== "undefined") return globalThis; - return typeof self !== "undefined" ? self : (typeof window !== 'undefined' ? window : global) + return typeof self !== "undefined" + ? self + : typeof window !== "undefined" + ? window + : global; })(); -const isContextDefined = (context) => !isUndefined(context) && context !== _global; +const isContextDefined = (context) => + !isUndefined(context) && context !== _global; /** * Accepts varargs expecting each argument to be an object, then @@ -342,10 +369,15 @@ const isContextDefined = (context) => !isUndefined(context) && context !== _glob * @returns {Object} Result of all merge properties */ function merge(/* obj1, obj2, obj3, ... */) { - const {caseless, skipUndefined} = isContextDefined(this) && this || {}; + const { caseless, skipUndefined } = (isContextDefined(this) && this) || {}; const result = {}; const assignValue = (val, key) => { - const targetKey = caseless && findKey(result, key) || key; + // Skip dangerous property names to prevent prototype pollution + if (key === "__proto__" || key === "constructor" || key === "prototype") { + return; + } + + const targetKey = (caseless && findKey(result, key)) || key; if (isPlainObject(result[targetKey]) && isPlainObject(val)) { result[targetKey] = merge(result[targetKey], val); } else if (isPlainObject(val)) { @@ -355,7 +387,7 @@ function merge(/* obj1, obj2, obj3, ... */) { } else if (!skipUndefined || !isUndefined(val)) { result[targetKey] = val; } - } + }; for (let i = 0, l = arguments.length; i < l; i++) { arguments[i] && forEach(arguments[i], assignValue); @@ -374,26 +406,30 @@ function merge(/* obj1, obj2, obj3, ... */) { * @param {Boolean} [options.allOwnKeys] * @returns {Object} The resulting value of object a */ -const extend = (a, b, thisArg, {allOwnKeys}= {}) => { - forEach(b, (val, key) => { - if (thisArg && isFunction(val)) { - Object.defineProperty(a, key, { - value: bind(val, thisArg), - writable: true, - enumerable: true, - configurable: true - }); - } else { - Object.defineProperty(a, key, { - value: val, - writable: true, - enumerable: true, - configurable: true - }); - } - }, {allOwnKeys}); +const extend = (a, b, thisArg, { allOwnKeys } = {}) => { + forEach( + b, + (val, key) => { + if (thisArg && isFunction(val)) { + Object.defineProperty(a, key, { + value: bind(val, thisArg), + writable: true, + enumerable: true, + configurable: true, + }); + } else { + Object.defineProperty(a, key, { + value: val, + writable: true, + enumerable: true, + configurable: true, + }); + } + }, + { allOwnKeys }, + ); return a; -} +}; /** * Remove byte order marker. This catches EF BB BF (the UTF-8 BOM) @@ -403,11 +439,11 @@ const extend = (a, b, thisArg, {allOwnKeys}= {}) => { * @returns {string} content value without BOM */ const stripBOM = (content) => { - if (content.charCodeAt(0) === 0xFEFF) { + if (content.charCodeAt(0) === 0xfeff) { content = content.slice(1); } return content; -} +}; /** * Inherit the prototype methods from one constructor into another @@ -419,18 +455,21 @@ const stripBOM = (content) => { * @returns {void} */ const inherits = (constructor, superConstructor, props, descriptors) => { - constructor.prototype = Object.create(superConstructor.prototype, descriptors); - Object.defineProperty(constructor.prototype, 'constructor', { + constructor.prototype = Object.create( + superConstructor.prototype, + descriptors, + ); + Object.defineProperty(constructor.prototype, "constructor", { value: constructor, writable: true, enumerable: false, - configurable: true + configurable: true, }); - Object.defineProperty(constructor, 'super', { - value: superConstructor.prototype + Object.defineProperty(constructor, "super", { + value: superConstructor.prototype, }); props && Object.assign(constructor.prototype, props); -} +}; /** * Resolve object with deep prototype chain to a flat object @@ -456,16 +495,23 @@ const toFlatObject = (sourceObj, destObj, filter, propFilter) => { i = props.length; while (i-- > 0) { prop = props[i]; - if ((!propFilter || propFilter(prop, sourceObj, destObj)) && !merged[prop]) { + if ( + (!propFilter || propFilter(prop, sourceObj, destObj)) && + !merged[prop] + ) { destObj[prop] = sourceObj[prop]; merged[prop] = true; } } sourceObj = filter !== false && getPrototypeOf(sourceObj); - } while (sourceObj && (!filter || filter(sourceObj, destObj)) && sourceObj !== Object.prototype); + } while ( + sourceObj && + (!filter || filter(sourceObj, destObj)) && + sourceObj !== Object.prototype + ); return destObj; -} +}; /** * Determines whether a string ends with the characters of a specified string @@ -484,8 +530,7 @@ const endsWith = (str, searchString, position) => { position -= searchString.length; const lastIndex = str.indexOf(searchString, position); return lastIndex !== -1 && lastIndex === position; -} - +}; /** * Returns new array from array like object or null if failed @@ -504,7 +549,7 @@ const toArray = (thing) => { arr[i] = thing[i]; } return arr; -} +}; /** * Checking if the Uint8Array exists and if it does, it returns a function that checks if the @@ -515,12 +560,12 @@ const toArray = (thing) => { * @returns {Array} */ // eslint-disable-next-line func-names -const isTypedArray = (TypedArray => { +const isTypedArray = ((TypedArray) => { // eslint-disable-next-line func-names - return thing => { + return (thing) => { return TypedArray && thing instanceof TypedArray; }; -})(typeof Uint8Array !== 'undefined' && getPrototypeOf(Uint8Array)); +})(typeof Uint8Array !== "undefined" && getPrototypeOf(Uint8Array)); /** * For each entry in the object, call the function with the key and value. @@ -541,7 +586,7 @@ const forEachEntry = (obj, fn) => { const pair = result.value; fn.call(obj, pair[0], pair[1]); } -} +}; /** * It takes a regular expression and a string, and returns an array of all the matches @@ -560,21 +605,25 @@ const matchAll = (regExp, str) => { } return arr; -} +}; /* Checking if the kindOfTest function returns true when passed an HTMLFormElement. */ -const isHTMLForm = kindOfTest('HTMLFormElement'); +const isHTMLForm = kindOfTest("HTMLFormElement"); -const toCamelCase = str => { - return str.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g, - function replacer(m, p1, p2) { +const toCamelCase = (str) => { + return str + .toLowerCase() + .replace(/[-_\s]([a-z\d])(\w*)/g, function replacer(m, p1, p2) { return p1.toUpperCase() + p2; - } - ); + }); }; /* Creating a function that will check if an object has a property. */ -const hasOwnProperty = (({hasOwnProperty}) => (obj, prop) => hasOwnProperty.call(obj, prop))(Object.prototype); +const hasOwnProperty = ( + ({ hasOwnProperty }) => + (obj, prop) => + hasOwnProperty.call(obj, prop) +)(Object.prototype); /** * Determine if a value is a RegExp object @@ -583,7 +632,7 @@ const hasOwnProperty = (({hasOwnProperty}) => (obj, prop) => hasOwnProperty.call * * @returns {boolean} True if value is a RegExp object, otherwise false */ -const isRegExp = kindOfTest('RegExp'); +const isRegExp = kindOfTest("RegExp"); const reduceDescriptors = (obj, reducer) => { const descriptors = Object.getOwnPropertyDescriptors(obj); @@ -597,7 +646,7 @@ const reduceDescriptors = (obj, reducer) => { }); Object.defineProperties(obj, reducedDescriptors); -} +}; /** * Makes all methods read-only @@ -607,7 +656,10 @@ const reduceDescriptors = (obj, reducer) => { const freezeMethods = (obj) => { reduceDescriptors(obj, (descriptor, name) => { // skip restricted props in strict mode - if (isFunction(obj) && ['arguments', 'caller', 'callee'].indexOf(name) !== -1) { + if ( + isFunction(obj) && + ["arguments", "caller", "callee"].indexOf(name) !== -1 + ) { return false; } @@ -617,40 +669,42 @@ const freezeMethods = (obj) => { descriptor.enumerable = false; - if ('writable' in descriptor) { + if ("writable" in descriptor) { descriptor.writable = false; return; } if (!descriptor.set) { descriptor.set = () => { - throw Error('Can not rewrite read-only method \'' + name + '\''); + throw Error("Can not rewrite read-only method '" + name + "'"); }; } }); -} +}; const toObjectSet = (arrayOrString, delimiter) => { const obj = {}; const define = (arr) => { - arr.forEach(value => { + arr.forEach((value) => { obj[value] = true; }); - } + }; - isArray(arrayOrString) ? define(arrayOrString) : define(String(arrayOrString).split(delimiter)); + isArray(arrayOrString) + ? define(arrayOrString) + : define(String(arrayOrString).split(delimiter)); return obj; -} +}; -const noop = () => {} +const noop = () => {}; const toFiniteNumber = (value, defaultValue) => { - return value != null && Number.isFinite(value = +value) ? value : defaultValue; -} - - + return value != null && Number.isFinite((value = +value)) + ? value + : defaultValue; +}; /** * If the thing is a FormData object, return true, otherwise return false. @@ -660,14 +714,18 @@ const toFiniteNumber = (value, defaultValue) => { * @returns {boolean} */ function isSpecCompliantForm(thing) { - return !!(thing && isFunction(thing.append) && thing[toStringTag] === 'FormData' && thing[iterator]); + return !!( + thing && + isFunction(thing.append) && + thing[toStringTag] === "FormData" && + thing[iterator] + ); } const toJSONObject = (obj) => { const stack = new Array(10); const visit = (source, i) => { - if (isObject(source)) { if (stack.indexOf(source) >= 0) { return; @@ -678,7 +736,7 @@ const toJSONObject = (obj) => { return source; } - if(!('toJSON' in source)) { + if (!("toJSON" in source)) { stack[i] = source; const target = isArray(source) ? [] : {}; @@ -694,15 +752,18 @@ const toJSONObject = (obj) => { } return source; - } + }; return visit(obj, 0); -} +}; -const isAsyncFn = kindOfTest('AsyncFunction'); +const isAsyncFn = kindOfTest("AsyncFunction"); const isThenable = (thing) => - thing && (isObject(thing) || isFunction(thing)) && isFunction(thing.then) && isFunction(thing.catch); + thing && + (isObject(thing) || isFunction(thing)) && + isFunction(thing.then) && + isFunction(thing.catch); // original code // https://github.com/DigitalBrainJS/AxiosPromise/blob/16deab13710ec09779922131f3fa5954320f83ab/lib/utils.js#L11-L34 @@ -712,32 +773,35 @@ const _setImmediate = ((setImmediateSupported, postMessageSupported) => { return setImmediate; } - return postMessageSupported ? ((token, callbacks) => { - _global.addEventListener("message", ({source, data}) => { - if (source === _global && data === token) { - callbacks.length && callbacks.shift()(); - } - }, false); + return postMessageSupported + ? ((token, callbacks) => { + _global.addEventListener( + "message", + ({ source, data }) => { + if (source === _global && data === token) { + callbacks.length && callbacks.shift()(); + } + }, + false, + ); - return (cb) => { - callbacks.push(cb); - _global.postMessage(token, "*"); - } - })(`axios@${Math.random()}`, []) : (cb) => setTimeout(cb); -})( - typeof setImmediate === 'function', - isFunction(_global.postMessage) -); + return (cb) => { + callbacks.push(cb); + _global.postMessage(token, "*"); + }; + })(`axios@${Math.random()}`, []) + : (cb) => setTimeout(cb); +})(typeof setImmediate === "function", isFunction(_global.postMessage)); -const asap = typeof queueMicrotask !== 'undefined' ? - queueMicrotask.bind(_global) : ( typeof process !== 'undefined' && process.nextTick || _setImmediate); +const asap = + typeof queueMicrotask !== "undefined" + ? queueMicrotask.bind(_global) + : (typeof process !== "undefined" && process.nextTick) || _setImmediate; // ********************* - const isIterable = (thing) => thing != null && isFunction(thing[iterator]); - export default { isArray, isArrayBuffer, @@ -795,5 +859,5 @@ export default { isThenable, setImmediate: _setImmediate, asap, - isIterable + isIterable, }; diff --git a/test/unit/core/prototypePollution.js b/test/unit/core/prototypePollution.js new file mode 100644 index 0000000..28aa913 --- /dev/null +++ b/test/unit/core/prototypePollution.js @@ -0,0 +1,226 @@ +"use strict"; + +import assert from "assert"; +import utils from "../../../lib/utils.js"; +import mergeConfig from "../../../lib/core/mergeConfig.js"; + +describe("Prototype Pollution Protection", function () { + afterEach(function () { + // Clean up any pollution that might have occurred + delete Object.prototype.polluted; + }); + + describe("utils.merge", function () { + it("should filter __proto__ key at top level", function () { + const result = utils.merge( + {}, + { __proto__: { polluted: "yes" }, safe: "value" }, + ); + + assert.strictEqual(Object.prototype.polluted, undefined); + assert.strictEqual(result.safe, "value"); + assert.strictEqual(result.hasOwnProperty("__proto__"), false); + }); + + it("should filter constructor key at top level", function () { + const result = utils.merge( + {}, + { constructor: { polluted: "yes" }, safe: "value" }, + ); + + assert.strictEqual(result.safe, "value"); + assert.strictEqual(result.hasOwnProperty("constructor"), false); + }); + + it("should filter prototype key at top level", function () { + const result = utils.merge( + {}, + { prototype: { polluted: "yes" }, safe: "value" }, + ); + + assert.strictEqual(result.safe, "value"); + assert.strictEqual(result.hasOwnProperty("prototype"), false); + }); + + it("should filter __proto__ key in nested objects", function () { + const result = utils.merge( + {}, + { + headers: { + __proto__: { polluted: "nested" }, + "Content-Type": "application/json", + }, + }, + ); + + assert.strictEqual(Object.prototype.polluted, undefined); + assert.strictEqual(result.headers["Content-Type"], "application/json"); + assert.strictEqual(result.headers.hasOwnProperty("__proto__"), false); + }); + + it("should filter constructor key in nested objects", function () { + const result = utils.merge( + {}, + { + headers: { + constructor: { prototype: { polluted: "nested" } }, + "Content-Type": "application/json", + }, + }, + ); + + assert.strictEqual(Object.prototype.polluted, undefined); + assert.strictEqual(result.headers["Content-Type"], "application/json"); + assert.strictEqual(result.headers.hasOwnProperty("constructor"), false); + }); + + it("should filter prototype key in nested objects", function () { + const result = utils.merge( + {}, + { + headers: { + prototype: { polluted: "nested" }, + "Content-Type": "application/json", + }, + }, + ); + + assert.strictEqual(result.headers["Content-Type"], "application/json"); + assert.strictEqual(result.headers.hasOwnProperty("prototype"), false); + }); + + it("should filter dangerous keys in deeply nested objects", function () { + const result = utils.merge( + {}, + { + level1: { + level2: { + __proto__: { polluted: "deep" }, + prototype: { polluted: "deep" }, + safe: "value", + }, + }, + }, + ); + + assert.strictEqual(Object.prototype.polluted, undefined); + assert.strictEqual(result.level1.level2.safe, "value"); + assert.strictEqual( + result.level1.level2.hasOwnProperty("__proto__"), + false, + ); + }); + + it("should still merge regular properties correctly", function () { + const result = utils.merge({ a: 1, b: { c: 2 } }, { b: { d: 3 }, e: 4 }); + + assert.strictEqual(result.a, 1); + assert.strictEqual(result.b.c, 2); + assert.strictEqual(result.b.d, 3); + assert.strictEqual(result.e, 4); + }); + + it("should handle JSON.parse payloads safely", function () { + const malicious = JSON.parse('{"__proto__": {"polluted": "yes"}}'); + const result = utils.merge({}, malicious); + + assert.strictEqual(Object.prototype.polluted, undefined); + assert.strictEqual(result.hasOwnProperty("__proto__"), false); + }); + + it("should handle nested JSON.parse payloads safely", function () { + const malicious = JSON.parse( + '{"headers": {"constructor": {"prototype": {"polluted": "yes"}}}}', + ); + const result = utils.merge({}, malicious); + + assert.strictEqual(Object.prototype.polluted, undefined); + assert.strictEqual(result.headers.hasOwnProperty("constructor"), false); + }); + }); + + describe("mergeConfig", function () { + it("should filter dangerous keys at top level", function () { + const result = mergeConfig( + {}, + { + __proto__: { polluted: "yes" }, + constructor: { polluted: "yes" }, + prototype: { polluted: "yes" }, + url: "/api/test", + }, + ); + + assert.strictEqual(Object.prototype.polluted, undefined); + assert.strictEqual(result.url, "/api/test"); + assert.strictEqual(result.hasOwnProperty("__proto__"), false); + assert.strictEqual(result.hasOwnProperty("constructor"), false); + assert.strictEqual(result.hasOwnProperty("prototype"), false); + }); + + it("should filter dangerous keys in headers", function () { + const result = mergeConfig( + {}, + { + headers: { + __proto__: { polluted: "yes" }, + "Content-Type": "application/json", + }, + }, + ); + + assert.strictEqual(Object.prototype.polluted, undefined); + assert.strictEqual(result.headers["Content-Type"], "application/json"); + assert.strictEqual(result.headers.hasOwnProperty("__proto__"), false); + }); + + it("should filter dangerous keys in custom config properties", function () { + const result = mergeConfig( + {}, + { + customProp: { + __proto__: { polluted: "yes" }, + safe: "value", + }, + }, + ); + + assert.strictEqual(Object.prototype.polluted, undefined); + assert.strictEqual(result.customProp.safe, "value"); + assert.strictEqual(result.customProp.hasOwnProperty("__proto__"), false); + }); + + it("should still merge configs correctly", function () { + const config1 = { + baseURL: "https://api.example.com", + timeout: 1000, + headers: { + common: { + Accept: "application/json", + }, + }, + }; + + const config2 = { + url: "/users", + timeout: 5000, + headers: { + common: { + "Content-Type": "application/json", + }, + }, + }; + + const result = mergeConfig(config1, config2); + + assert.strictEqual(result.baseURL, "https://api.example.com"); + assert.strictEqual(result.url, "/users"); + assert.strictEqual(result.timeout, 5000); + assert.strictEqual(result.headers.common.Accept, "application/json"); + assert.strictEqual( + result.headers.common["Content-Type"], + "application/json", + ); + }); + }); +});