From e23c08b5f45aed46798a799cb7702e2a2b4d25e5 Mon Sep 17 00:00:00 2001 From: Clawborn Date: Sun, 22 Feb 2026 05:42:22 +0800 Subject: [PATCH] Fix prototype pollution in applyMergePatch via blocked key filter applyMergePatch in merge-patch.ts iterates Object.entries(patch) without filtering dangerous keys. When a caller passes a JSON-parsed object with a "__proto__" key, the loop assigns result["__proto__"] = value, which replaces the prototype of result and pollutes Object.prototype for the entire process. Add a BLOCKED_KEYS set ({"__proto__", "constructor", "prototype"}) and skip those keys during iteration, matching the guard already present in deepMerge (includes.ts) via isBlockedObjectKey. Adds four tests covering __proto__, constructor, prototype, and nested __proto__ injection. Co-authored-by: Clawborn --- .../merge-patch.proto-pollution.test.ts | 40 +++++++++++++++++++ src/config/merge-patch.ts | 6 +++ 2 files changed, 46 insertions(+) create mode 100644 src/config/merge-patch.proto-pollution.test.ts diff --git a/src/config/merge-patch.proto-pollution.test.ts b/src/config/merge-patch.proto-pollution.test.ts new file mode 100644 index 000000000..65a079822 --- /dev/null +++ b/src/config/merge-patch.proto-pollution.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { applyMergePatch } from "./merge-patch.js"; + +describe("applyMergePatch prototype pollution guard", () => { + it("ignores __proto__ keys in patch", () => { + const base = { a: 1 }; + const patch = JSON.parse('{"__proto__": {"polluted": true}, "b": 2}'); + const result = applyMergePatch(base, patch) as Record; + expect(result.b).toBe(2); + expect(result.a).toBe(1); + expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe(false); + expect(({} as Record).polluted).toBeUndefined(); + }); + + it("ignores constructor key in patch", () => { + const base = { a: 1 }; + const patch = { constructor: { polluted: true }, b: 2 }; + const result = applyMergePatch(base, patch) as Record; + expect(result.b).toBe(2); + expect(Object.prototype.hasOwnProperty.call(result, "constructor")).toBe(false); + }); + + it("ignores prototype key in patch", () => { + const base = { a: 1 }; + const patch = { prototype: { polluted: true }, b: 2 }; + const result = applyMergePatch(base, patch) as Record; + expect(result.b).toBe(2); + expect(Object.prototype.hasOwnProperty.call(result, "prototype")).toBe(false); + }); + + it("ignores __proto__ in nested patches", () => { + const base = { nested: { x: 1 } }; + const patch = JSON.parse('{"nested": {"__proto__": {"polluted": true}, "y": 2}}'); + const result = applyMergePatch(base, patch) as { nested: Record }; + expect(result.nested.y).toBe(2); + expect(result.nested.x).toBe(1); + expect(Object.prototype.hasOwnProperty.call(result.nested, "__proto__")).toBe(false); + expect(({} as Record).polluted).toBeUndefined(); + }); +}); diff --git a/src/config/merge-patch.ts b/src/config/merge-patch.ts index 2afb4d62a..3d06635ae 100644 --- a/src/config/merge-patch.ts +++ b/src/config/merge-patch.ts @@ -2,6 +2,9 @@ import { isPlainObject } from "../utils.js"; type PlainObject = Record; +/** Keys that must never be merged to prevent prototype-pollution attacks. */ +const BLOCKED_KEYS = new Set(["__proto__", "constructor", "prototype"]); + type MergePatchOptions = { mergeObjectArraysById?: boolean; }; @@ -70,6 +73,9 @@ export function applyMergePatch( const result: PlainObject = isPlainObject(base) ? { ...base } : {}; for (const [key, value] of Object.entries(patch)) { + if (BLOCKED_KEYS.has(key)) { + continue; + } if (value === null) { delete result[key]; continue;