From 4151f95a19629d087959d2ce7ccba95e824c76db Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 21 Feb 2026 02:48:26 -0500 Subject: [PATCH] security(gateway): block prototype traversal in hook template paths --- src/gateway/hooks-mapping.test.ts | 65 +++++++++++++++++++++++++++++++ src/gateway/hooks-mapping.ts | 5 +++ 2 files changed, 70 insertions(+) diff --git a/src/gateway/hooks-mapping.test.ts b/src/gateway/hooks-mapping.test.ts index bb3b4080b63..d1f16103c4c 100644 --- a/src/gateway/hooks-mapping.test.ts +++ b/src/gateway/hooks-mapping.test.ts @@ -372,4 +372,69 @@ 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 && result.action && result.action.kind === "agent") { + expect(result.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 && result.action && result.action.kind === "agent") { + expect(result.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 && result.action && result.action.kind === "agent") { + expect(result.action.message).toBe("val: "); + } + }); + }); }); diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 7b28dd88ccd..b3578ca3d9a 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -80,6 +80,8 @@ const hookPresetMappings: Record = { const transformCache = new Map(); +const BLOCKED_PATH_KEYS = new Set(["__proto__", "prototype", "constructor"]); + type HookTransformResult = Partial<{ kind: HookAction["kind"]; text: string; @@ -465,6 +467,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; }