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;