From fe609c0c770afecf63adf3be3eb95e8e12d5948d Mon Sep 17 00:00:00 2001 From: "SleuthCo.AI" Date: Sat, 21 Feb 2026 03:01:03 -0500 Subject: [PATCH] security(hooks): block prototype-chain traversal in webhook template getByPath (#22213) * security(hooks): block prototype-chain traversal in webhook template getByPath The getByPath() function in hooks-mapping.ts traverses attacker-controlled webhook payload data using arbitrary property path expressions, but does not filter dangerous property names (__proto__, constructor, prototype). The config-paths module (config-paths.ts) already blocks these exact keys for config path traversal via a BLOCKED_KEYS set, but the hooks template system was not protected with the same guard. Add a BLOCKED_PATH_KEYS set mirroring config-paths.ts and reject traversal into __proto__, prototype, or constructor in getByPath(). Add three test cases covering all three blocked keys. Signed-off-by: Alan Ross * test(gateway): narrow hook action type in prototype-pollution tests * changelog: credit hooks prototype-path guard in PR 22213 * changelog: move hooks prototype-path fix into security section --------- Signed-off-by: Alan Ross Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + src/gateway/hooks-mapping.test.ts | 74 +++++++++++++++++++++++++++++++ src/gateway/hooks-mapping.ts | 8 ++++ 3 files changed, 83 insertions(+) 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; }