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 <alan@sleuthco.ai> * 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 <alan@sleuthco.ai> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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: ");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>, pathExpr: string): unknown {
|
||||
if (!pathExpr) {
|
||||
return undefined;
|
||||
@@ -465,6 +470,9 @@ function getByPath(input: Record<string, unknown>, pathExpr: string): unknown {
|
||||
current = current[part] as unknown;
|
||||
continue;
|
||||
}
|
||||
if (BLOCKED_PATH_KEYS.has(part)) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user