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:
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user