mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 17:14:33 +00:00
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 {
|
import {
|
||||||
CircularIncludeError,
|
CircularIncludeError,
|
||||||
ConfigIncludeError,
|
ConfigIncludeError,
|
||||||
|
deepMerge,
|
||||||
type IncludeResolver,
|
type IncludeResolver,
|
||||||
resolveConfigIncludes,
|
resolveConfigIncludes,
|
||||||
} from "./includes.js";
|
} 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", () => {
|
describe("edge cases", () => {
|
||||||
it("rejects null bytes in path", () => {
|
it("rejects null bytes in path", () => {
|
||||||
const obj = { $include: "./file\x00.json" };
|
const obj = { $include: "./file\x00.json" };
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export class CircularIncludeError extends ConfigIncludeError {
|
|||||||
// Utilities
|
// Utilities
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
||||||
|
|
||||||
/** Deep merge: arrays concatenate, objects merge recursively, primitives: source wins */
|
/** Deep merge: arrays concatenate, objects merge recursively, primitives: source wins */
|
||||||
export function deepMerge(target: unknown, source: unknown): unknown {
|
export function deepMerge(target: unknown, source: unknown): unknown {
|
||||||
if (Array.isArray(target) && Array.isArray(source)) {
|
if (Array.isArray(target) && Array.isArray(source)) {
|
||||||
@@ -62,6 +64,9 @@ export function deepMerge(target: unknown, source: unknown): unknown {
|
|||||||
if (isPlainObject(target) && isPlainObject(source)) {
|
if (isPlainObject(target) && isPlainObject(source)) {
|
||||||
const result: Record<string, unknown> = { ...target };
|
const result: Record<string, unknown> = { ...target };
|
||||||
for (const key of Object.keys(source)) {
|
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];
|
result[key] = key in result ? deepMerge(result[key], source[key]) : source[key];
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
Reference in New Issue
Block a user