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.
This commit is contained in:
David Rudduck
2026-02-19 21:47:48 +10:00
committed by GitHub
parent 043b2f5e7a
commit e0aaf2d399
2 changed files with 31 additions and 0 deletions

View File

@@ -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<string, unknown>).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<string, unknown>).polluted).toBeUndefined();
expect(result).toEqual({ nested: { a: 1 } });
});
});
describe("edge cases", () => {
it("rejects null bytes in path", () => {
const obj = { $include: "./file\x00.json" };

View File

@@ -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<string, unknown> = { ...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;