diff --git a/CHANGELOG.md b/CHANGELOG.md index 439b213cc..0a6043de9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Gateway/Hooks: block `__proto__`, `constructor`, and `prototype` traversal in webhook template path resolution to prevent prototype-chain payload data leakage in `messageTemplate` rendering. (#22213) Thanks @SleuthCo. - Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman. - Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg. - Auto-reply/Tools: forward `senderIsOwner` through embedded queued/followup runner params so owner-only tools remain available for authorized senders. (#22296) thanks @hcoj. diff --git a/src/gateway/hooks-mapping.test.ts b/src/gateway/hooks-mapping.test.ts index bb3b4080b..05554d7ca 100644 --- a/src/gateway/hooks-mapping.test.ts +++ b/src/gateway/hooks-mapping.test.ts @@ -372,4 +372,78 @@ describe("hooks mapping", () => { }); expect(result?.ok).toBe(false); }); + + describe("prototype pollution protection", () => { + it("blocks __proto__ traversal in webhook payload", async () => { + const mappings = resolveHookMappings({ + mappings: [ + createGmailAgentMapping({ + id: "proto-test", + messageTemplate: "value: {{__proto__}}", + }), + ], + }); + const result = await applyHookMappings(mappings, { + payload: { __proto__: { polluted: true } } as Record, + headers: {}, + url: baseUrl, + path: "gmail", + }); + expect(result?.ok).toBe(true); + if (result?.ok) { + const action = result.action; + if (action?.kind === "agent") { + expect(action.message).toBe("value: "); + } + } + }); + + it("blocks constructor traversal in webhook payload", async () => { + const mappings = resolveHookMappings({ + mappings: [ + createGmailAgentMapping({ + id: "constructor-test", + messageTemplate: "type: {{constructor.name}}", + }), + ], + }); + const result = await applyHookMappings(mappings, { + payload: { constructor: { name: "INJECTED" } } as Record, + headers: {}, + url: baseUrl, + path: "gmail", + }); + expect(result?.ok).toBe(true); + if (result?.ok) { + const action = result.action; + if (action?.kind === "agent") { + expect(action.message).toBe("type: "); + } + } + }); + + it("blocks prototype traversal in webhook payload", async () => { + const mappings = resolveHookMappings({ + mappings: [ + createGmailAgentMapping({ + id: "prototype-test", + messageTemplate: "val: {{prototype}}", + }), + ], + }); + const result = await applyHookMappings(mappings, { + payload: { prototype: "leaked" } as Record, + headers: {}, + url: baseUrl, + path: "gmail", + }); + expect(result?.ok).toBe(true); + if (result?.ok) { + const action = result.action; + if (action?.kind === "agent") { + expect(action.message).toBe("val: "); + } + } + }); + }); }); diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 7b28dd88c..f9ede3504 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -438,6 +438,11 @@ function resolveTemplateExpr(expr: string, ctx: HookMappingContext) { return getByPath(ctx.payload, expr); } +// Block traversal into prototype-chain properties on attacker-controlled +// webhook payloads. Mirrors the same blocklist used by config-paths.ts +// for config path traversal. +const BLOCKED_PATH_KEYS = new Set(["__proto__", "prototype", "constructor"]); + function getByPath(input: Record, pathExpr: string): unknown { if (!pathExpr) { return undefined; @@ -465,6 +470,9 @@ function getByPath(input: Record, pathExpr: string): unknown { current = current[part] as unknown; continue; } + if (BLOCKED_PATH_KEYS.has(part)) { + return undefined; + } if (typeof current !== "object") { return undefined; }