From e0aaf2d399753e0a80d73409f30567394ee4a8b5 Mon Sep 17 00:00:00 2001 From: David Rudduck <47308254+davidrudduck@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:47:48 +1000 Subject: [PATCH] fix(security): block prototype-polluting keys in deepMerge (#20853) Reject __proto__, prototype, and constructor keys during deep-merge to prevent prototype pollution when merging untrusted config objects. --- src/config/includes.test.ts | 26 ++++++++++++++++++++++++++ src/config/includes.ts | 5 +++++ 2 files changed, 31 insertions(+) diff --git a/src/config/includes.test.ts b/src/config/includes.test.ts index 35facb8b3..25ae27e65 100644 --- a/src/config/includes.test.ts +++ b/src/config/includes.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { CircularIncludeError, ConfigIncludeError, + deepMerge, type IncludeResolver, resolveConfigIncludes, } from "./includes.js"; @@ -521,6 +522,31 @@ describe("security: path traversal protection (CWE-22)", () => { }); }); + describe("prototype pollution protection", () => { + it("blocks __proto__ keys from polluting Object.prototype", () => { + const result = deepMerge({}, JSON.parse('{"__proto__":{"polluted":true}}')); + expect((Object.prototype as Record).polluted).toBeUndefined(); + expect(result).toEqual({}); + }); + + it("blocks prototype and constructor keys", () => { + const result = deepMerge( + { safe: 1 }, + { prototype: { x: 1 }, constructor: { y: 2 }, normal: 3 }, + ); + expect(result).toEqual({ safe: 1, normal: 3 }); + }); + + it("blocks __proto__ in nested merges", () => { + const result = deepMerge( + { nested: { a: 1 } }, + { nested: JSON.parse('{"__proto__":{"polluted":true}}') }, + ); + expect((Object.prototype as Record).polluted).toBeUndefined(); + expect(result).toEqual({ nested: { a: 1 } }); + }); + }); + describe("edge cases", () => { it("rejects null bytes in path", () => { const obj = { $include: "./file\x00.json" }; diff --git a/src/config/includes.ts b/src/config/includes.ts index e265186bf..ce0545669 100644 --- a/src/config/includes.ts +++ b/src/config/includes.ts @@ -54,6 +54,8 @@ export class CircularIncludeError extends ConfigIncludeError { // Utilities // ============================================================================ +const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]); + /** Deep merge: arrays concatenate, objects merge recursively, primitives: source wins */ export function deepMerge(target: unknown, source: unknown): unknown { if (Array.isArray(target) && Array.isArray(source)) { @@ -62,6 +64,9 @@ export function deepMerge(target: unknown, source: unknown): unknown { if (isPlainObject(target) && isPlainObject(source)) { const result: Record = { ...target }; for (const key of Object.keys(source)) { + if (BLOCKED_MERGE_KEYS.has(key)) { + continue; + } result[key] = key in result ? deepMerge(result[key], source[key]) : source[key]; } return result;