VYPR
Medium severity5.3NVD Advisory· Published May 11, 2026· Updated May 13, 2026

CVE-2026-45002

CVE-2026-45002

Description

OpenClaw before 2026.4.20 contains a hook session-key bypass vulnerability that allows attackers to circumvent the hooks.allowRequestSessionKey opt-in restriction. Attackers can render externally influenced session keys through templated hook mappings to bypass webhook routing isolation controls.

Affected products

1

Patches

1
5275d008ed33

fix(gateway): enforce allowRequestSessionKey gate on template-rendered mapping sessionKeys (#69381)

https://github.com/openclaw/openclawPavan Kumar GondhiApr 21, 2026via nvd-ref
8 files changed · +588 12
  • CHANGELOG.md+1 0 modified
    @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
     
     ### Fixes
     
    +- fix(gateway): enforce allowRequestSessionKey gate on template-rendered mapping sessionKeys. (#69381) Thanks @pgondhi987.
     - OpenAI/Responses: resolve `/think` levels against each GPT model's supported reasoning efforts so `/think off` no longer becomes high reasoning or sends unsupported `reasoning.effort: "none"` payloads.
     - Lobster/TaskFlow: allow managed approval resumes to use `approvalId` without a resume token, and persist that id in approval wait state. (#69559) Thanks @kirkluokun.
     - Plugins/startup: install bundled runtime dependencies into each plugin's own runtime directory, reuse source-checkout repair caches after rebuilds, and log only packages that were actually installed so repeated Gateway starts stay quiet once deps are present.
    
  • docs/gateway/configuration-reference.md+10 4 modified
    @@ -3193,8 +3193,8 @@ See [Multiple Gateways](/gateway/multiple-gateways).
         path: "/hooks",
         maxBodyBytes: 262144,
         defaultSessionKey: "hook:ingress",
    -    allowRequestSessionKey: false,
    -    allowedSessionKeyPrefixes: ["hook:"],
    +    allowRequestSessionKey: true,
    +    allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"],
         allowedAgentIds: ["hooks", "main"],
         presets: ["gmail"],
         transformsDir: "~/.openclaw/hooks/transforms",
    @@ -3225,13 +3225,15 @@ Validation and safety notes:
     - `hooks.token` must be **distinct** from `gateway.auth.token`; reusing the Gateway token is rejected.
     - `hooks.path` cannot be `/`; use a dedicated subpath such as `/hooks`.
     - If `hooks.allowRequestSessionKey=true`, constrain `hooks.allowedSessionKeyPrefixes` (for example `["hook:"]`).
    +- If a mapping or preset uses a templated `sessionKey`, set `hooks.allowedSessionKeyPrefixes` and `hooks.allowRequestSessionKey=true`. Static mapping keys do not require that opt-in.
     
     **Endpoints:**
     
     - `POST /hooks/wake` → `{ text, mode?: "now"|"next-heartbeat" }`
     - `POST /hooks/agent` → `{ message, name?, agentId?, sessionKey?, wakeMode?, deliver?, channel?, to?, model?, thinking?, timeoutSeconds? }`
       - `sessionKey` from request payload is accepted only when `hooks.allowRequestSessionKey=true` (default: `false`).
     - `POST /hooks/<name>` → resolved via `hooks.mappings`
    +  - Template-rendered mapping `sessionKey` values are treated as externally supplied and also require `hooks.allowRequestSessionKey=true`.
     
     <Accordion title="Mapping details">
     
    @@ -3243,15 +3245,19 @@ Validation and safety notes:
     - `agentId` routes to a specific agent; unknown IDs fall back to default.
     - `allowedAgentIds`: restricts explicit routing (`*` or omitted = allow all, `[]` = deny all).
     - `defaultSessionKey`: optional fixed session key for hook agent runs without explicit `sessionKey`.
    -- `allowRequestSessionKey`: allow `/hooks/agent` callers to set `sessionKey` (default: `false`).
    -- `allowedSessionKeyPrefixes`: optional prefix allowlist for explicit `sessionKey` values (request + mapping), e.g. `["hook:"]`.
    +- `allowRequestSessionKey`: allow `/hooks/agent` callers and template-driven mapping session keys to set `sessionKey` (default: `false`).
    +- `allowedSessionKeyPrefixes`: optional prefix allowlist for explicit `sessionKey` values (request + mapping), e.g. `["hook:"]`. It becomes required when any mapping or preset uses a templated `sessionKey`.
     - `deliver: true` sends final reply to a channel; `channel` defaults to `last`.
     - `model` overrides LLM for this hook run (must be allowed if model catalog is set).
     
     </Accordion>
     
     ### Gmail integration
     
    +- The built-in Gmail preset uses `sessionKey: "hook:gmail:{{messages[0].id}}"`.
    +- If you keep that per-message routing, set `hooks.allowRequestSessionKey: true` and constrain `hooks.allowedSessionKeyPrefixes` to match the Gmail namespace, for example `["hook:", "hook:gmail:"]`.
    +- If you need `hooks.allowRequestSessionKey: false`, override the preset with a static `sessionKey` instead of the templated default.
    +
     ```json5
     {
       hooks: {
    
  • src/gateway/hooks-mapping.test.ts+216 0 modified
    @@ -144,6 +144,44 @@ describe("hooks mapping", () => {
         }
       });
     
    +  it("marks template-derived session keys as templated", async () => {
    +    const result = await applyGmailMappings({
    +      mappings: [
    +        {
    +          id: "templated-session-key",
    +          match: { path: "gmail" },
    +          action: "agent",
    +          messageTemplate: "Subject: {{messages[0].subject}}",
    +          sessionKey: "hook:gmail:{{messages[0].subject}}",
    +        },
    +      ],
    +    });
    +    expect(result?.ok).toBe(true);
    +    if (result?.ok && result.action?.kind === "agent") {
    +      expect(result.action.sessionKey).toBe("hook:gmail:Hello");
    +      expect(result.action.sessionKeySource).toBe("templated");
    +    }
    +  });
    +
    +  it("marks literal session keys as static", async () => {
    +    const result = await applyGmailMappings({
    +      mappings: [
    +        {
    +          id: "static-session-key",
    +          match: { path: "gmail" },
    +          action: "agent",
    +          messageTemplate: "Subject: {{messages[0].subject}}",
    +          sessionKey: "hook:gmail:static",
    +        },
    +      ],
    +    });
    +    expect(result?.ok).toBe(true);
    +    if (result?.ok && result.action?.kind === "agent") {
    +      expect(result.action.sessionKey).toBe("hook:gmail:static");
    +      expect(result.action.sessionKeySource).toBe("static");
    +    }
    +  });
    +
       it("runs transform module", async () => {
         const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-"));
         const transformsRoot = path.join(configDir, "hooks", "transforms");
    @@ -182,6 +220,184 @@ describe("hooks mapping", () => {
         }
       });
     
    +  it("treats transform-provided session keys as templated by default", async () => {
    +    const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-sessionkey-xform-"));
    +    const transformsRoot = path.join(configDir, "hooks", "transforms");
    +    fs.mkdirSync(transformsRoot, { recursive: true });
    +    fs.writeFileSync(
    +      path.join(transformsRoot, "transform.mjs"),
    +      [
    +        "export default ({ payload }) => ({",
    +        '  kind: "agent",',
    +        '  message: "Transformed",',
    +        "  sessionKey: `hook:gmail:${payload.subject}`,",
    +        "});",
    +      ].join("\n"),
    +    );
    +
    +    const mappings = resolveHookMappings(
    +      {
    +        mappings: [
    +          {
    +            match: { path: "gmail" },
    +            action: "agent",
    +            messageTemplate: "Subject: {{messages[0].subject}}",
    +            sessionKey: "hook:gmail:static",
    +            transform: { module: "transform.mjs" },
    +          },
    +        ],
    +      },
    +      { configDir },
    +    );
    +
    +    const result = await applyHookMappings(mappings, {
    +      payload: { subject: "external" },
    +      headers: {},
    +      url: baseUrl,
    +      path: "gmail",
    +    });
    +
    +    expect(result?.ok).toBe(true);
    +    if (result?.ok && result.action?.kind === "agent") {
    +      expect(result.action.sessionKey).toBe("hook:gmail:external");
    +      expect(result.action.sessionKeySource).toBe("templated");
    +    }
    +  });
    +
    +  it("uses transform-provided static session key source metadata", async () => {
    +    const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-sessionkey-static-"));
    +    const transformsRoot = path.join(configDir, "hooks", "transforms");
    +    fs.mkdirSync(transformsRoot, { recursive: true });
    +    fs.writeFileSync(
    +      path.join(transformsRoot, "transform.mjs"),
    +      [
    +        "export default () => ({",
    +        '  kind: "agent",',
    +        '  message: "Transformed",',
    +        '  sessionKey: "hook:gmail:fixed",',
    +        '  sessionKeySource: "static",',
    +        "});",
    +      ].join("\n"),
    +    );
    +
    +    const mappings = resolveHookMappings(
    +      {
    +        mappings: [
    +          {
    +            match: { path: "gmail" },
    +            action: "agent",
    +            messageTemplate: "Subject: {{messages[0].subject}}",
    +            sessionKey: "hook:gmail:{{messages[0].subject}}",
    +            transform: { module: "transform.mjs" },
    +          },
    +        ],
    +      },
    +      { configDir },
    +    );
    +
    +    const result = await applyHookMappings(mappings, {
    +      payload: gmailPayload,
    +      headers: {},
    +      url: baseUrl,
    +      path: "gmail",
    +    });
    +
    +    expect(result?.ok).toBe(true);
    +    if (result?.ok && result.action?.kind === "agent") {
    +      expect(result.action.sessionKey).toBe("hook:gmail:fixed");
    +      expect(result.action.sessionKeySource).toBe("static");
    +    }
    +  });
    +
    +  it("treats empty transform session keys as absent for source tracking", async () => {
    +    const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-sessionkey-empty-"));
    +    const transformsRoot = path.join(configDir, "hooks", "transforms");
    +    fs.mkdirSync(transformsRoot, { recursive: true });
    +    fs.writeFileSync(
    +      path.join(transformsRoot, "transform.mjs"),
    +      [
    +        "export default () => ({",
    +        '  kind: "agent",',
    +        '  message: "Transformed",',
    +        '  sessionKey: "",',
    +        '  sessionKeySource: "templated",',
    +        "});",
    +      ].join("\n"),
    +    );
    +
    +    const mappings = resolveHookMappings(
    +      {
    +        mappings: [
    +          {
    +            match: { path: "gmail" },
    +            action: "agent",
    +            messageTemplate: "Subject: {{messages[0].subject}}",
    +            sessionKey: "hook:gmail:{{messages[0].subject}}",
    +            transform: { module: "transform.mjs" },
    +          },
    +        ],
    +      },
    +      { configDir },
    +    );
    +
    +    const result = await applyHookMappings(mappings, {
    +      payload: gmailPayload,
    +      headers: {},
    +      url: baseUrl,
    +      path: "gmail",
    +    });
    +
    +    expect(result?.ok).toBe(true);
    +    if (result?.ok && result.action?.kind === "agent") {
    +      expect(result.action.sessionKey).toBe("");
    +      expect(result.action.sessionKeySource).toBeUndefined();
    +    }
    +  });
    +
    +  it("defaults invalid transform session key source metadata to templated", async () => {
    +    const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-sessionkey-invalid-"));
    +    const transformsRoot = path.join(configDir, "hooks", "transforms");
    +    fs.mkdirSync(transformsRoot, { recursive: true });
    +    fs.writeFileSync(
    +      path.join(transformsRoot, "transform.mjs"),
    +      [
    +        "export default () => ({",
    +        '  kind: "agent",',
    +        '  message: "Transformed",',
    +        '  sessionKey: "hook:gmail:from-transform",',
    +        '  sessionKeySource: "bogus",',
    +        "});",
    +      ].join("\n"),
    +    );
    +
    +    const mappings = resolveHookMappings(
    +      {
    +        mappings: [
    +          {
    +            match: { path: "gmail" },
    +            action: "agent",
    +            messageTemplate: "Subject: {{messages[0].subject}}",
    +            transform: { module: "transform.mjs" },
    +          },
    +        ],
    +      },
    +      { configDir },
    +    );
    +
    +    const result = await applyHookMappings(mappings, {
    +      payload: gmailPayload,
    +      headers: {},
    +      url: baseUrl,
    +      path: "gmail",
    +    });
    +
    +    expect(result?.ok).toBe(true);
    +    if (result?.ok && result.action?.kind === "agent") {
    +      expect(result.action.sessionKey).toBe("hook:gmail:from-transform");
    +      expect(result.action.sessionKeySource).toBe("templated");
    +    }
    +  });
    +
       it("rejects transform module traversal outside transformsDir", () => {
         const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-traversal-"));
         const transformsRoot = path.join(configDir, "hooks", "transforms");
    
  • src/gateway/hooks-mapping.ts+38 2 modified
    @@ -1,6 +1,6 @@
     import fs from "node:fs";
     import path from "node:path";
    -import { CONFIG_PATH } from "../config/paths.js";
    +import { resolveConfigPathCandidate } from "../config/paths.js";
     import type { HookMappingConfig, HooksConfig } from "../config/types.hooks.js";
     import { importFileModule, resolveFunctionModuleExport } from "../hooks/module-loader.js";
     import { normalizeOptionalString, readStringValue } from "../shared/string-coerce.js";
    @@ -52,6 +52,7 @@ export type HookAction =
           agentId?: string;
           wakeMode: "now" | "next-heartbeat";
           sessionKey?: string;
    +      sessionKeySource?: "static" | "templated";
           deliver?: boolean;
           allowUnsafeExternalContent?: boolean;
           channel?: HookMessageChannel;
    @@ -61,6 +62,8 @@ export type HookAction =
           timeoutSeconds?: number;
         };
     
    +export type HookSessionKeyTemplateSource = "static" | "templated";
    +
     export type HookMappingResult =
       | { ok: true; action: HookAction }
       | { ok: true; action: null; skipped: true }
    @@ -92,6 +95,7 @@ type HookTransformResult = Partial<{
       wakeMode: "now" | "next-heartbeat";
       name: string;
       sessionKey: string;
    +  sessionKeySource: HookSessionKeyTemplateSource;
       deliver: boolean;
       allowUnsafeExternalContent: boolean;
       channel: HookMessageChannel;
    @@ -135,7 +139,7 @@ export function resolveHookMappings(
         return [];
       }
     
    -  const configDir = path.resolve(opts?.configDir ?? path.dirname(CONFIG_PATH));
    +  const configDir = path.resolve(opts?.configDir ?? path.dirname(resolveConfigPathCandidate()));
       const transformsRootDir = path.join(configDir, "hooks", "transforms");
       const transformsDir = resolveOptionalContainedPath(
         transformsRootDir,
    @@ -263,6 +267,7 @@ function buildActionFromMapping(
           agentId: mapping.agentId,
           wakeMode: mapping.wakeMode ?? "now",
           sessionKey: renderOptional(mapping.sessionKey, ctx),
    +      sessionKeySource: getSessionKeyTemplateSource(mapping.sessionKey),
           deliver: mapping.deliver,
           allowUnsafeExternalContent: mapping.allowUnsafeExternalContent,
           channel: mapping.channel,
    @@ -301,6 +306,7 @@ function mergeAction(
         name: override.name ?? baseAgent?.name,
         agentId: override.agentId ?? baseAgent?.agentId,
         sessionKey: override.sessionKey ?? baseAgent?.sessionKey,
    +    sessionKeySource: resolveMergedSessionKeySource(baseAgent, override),
         deliver: typeof override.deliver === "boolean" ? override.deliver : baseAgent?.deliver,
         allowUnsafeExternalContent:
           typeof override.allowUnsafeExternalContent === "boolean"
    @@ -327,6 +333,36 @@ function validateAction(action: HookAction): HookMappingResult {
       return { ok: true, action };
     }
     
    +function getSessionKeyTemplateSource(
    +  sessionKeyTemplate: string | undefined,
    +): HookSessionKeyTemplateSource | undefined {
    +  const normalizedTemplate = normalizeOptionalString(sessionKeyTemplate);
    +  if (!normalizedTemplate) {
    +    return undefined;
    +  }
    +  return hasHookTemplateExpressions(normalizedTemplate) ? "templated" : "static";
    +}
    +
    +function resolveMergedSessionKeySource(
    +  baseAgent: Extract<HookAction, { kind: "agent" }> | undefined,
    +  override: Exclude<HookTransformResult, null>,
    +): HookSessionKeyTemplateSource | undefined {
    +  if (typeof override.sessionKey === "string") {
    +    const normalizedSessionKey = normalizeOptionalString(override.sessionKey);
    +    if (!normalizedSessionKey) {
    +      // Empty transform overrides behave like an absent sessionKey and fall
    +      // through to the default/generated key path later in hook dispatch.
    +      return undefined;
    +    }
    +    return override.sessionKeySource === "static" ? "static" : "templated";
    +  }
    +  return baseAgent?.sessionKeySource;
    +}
    +
    +export function hasHookTemplateExpressions(template: string): boolean {
    +  return /\{\{\s*[^}]+\s*\}\}/.test(template);
    +}
    +
     async function loadTransform(transform: HookMappingTransformResolved): Promise<HookTransformFn> {
       const cacheKey = `${transform.modulePath}::${transform.exportName ?? "default"}`;
       const cached = transformCache.get(cacheKey);
    
  • src/gateway/hooks.test.ts+181 1 modified
    @@ -277,12 +277,56 @@ describe("gateway hooks helpers", () => {
     
         const allowed = resolveHookSessionKey({
           hooksConfig: resolved,
    -      source: "mapping",
    +      source: "mapping-static",
           sessionKey: "hook:gmail:1",
         });
         expect(allowed).toEqual({ ok: true, value: "hook:gmail:1" });
       });
     
    +  test("resolveHookSessionKey blocks templated mapping sessionKey when request overrides are disabled", () => {
    +    const cfg = {
    +      hooks: {
    +        enabled: true,
    +        token: "secret",
    +        allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"],
    +      },
    +    } as OpenClawConfig;
    +    const resolved = resolveHooksConfig(cfg);
    +    expect(resolved).not.toBeNull();
    +    if (!resolved) {
    +      return;
    +    }
    +
    +    const denied = resolveHookSessionKey({
    +      hooksConfig: resolved,
    +      source: "mapping-templated",
    +      sessionKey: "hook:gmail:attacker",
    +    });
    +    expect(denied.ok).toBe(false);
    +  });
    +
    +  test("resolveHookSessionKey still allows static mapping sessionKey when request overrides are disabled", () => {
    +    const cfg = {
    +      hooks: {
    +        enabled: true,
    +        token: "secret",
    +        allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"],
    +      },
    +    } as OpenClawConfig;
    +    const resolved = resolveHooksConfig(cfg);
    +    expect(resolved).not.toBeNull();
    +    if (!resolved) {
    +      return;
    +    }
    +
    +    const allowed = resolveHookSessionKey({
    +      hooksConfig: resolved,
    +      source: "mapping-static",
    +      sessionKey: "hook:gmail:fixed",
    +    });
    +    expect(allowed).toEqual({ ok: true, value: "hook:gmail:fixed" });
    +  });
    +
       test("resolveHookSessionKey uses defaultSessionKey when request key is absent", () => {
         const cfg = {
           hooks: {
    @@ -346,6 +390,142 @@ describe("gateway hooks helpers", () => {
           "hooks.allowedSessionKeyPrefixes must include 'hook:' when hooks.defaultSessionKey is unset",
         );
       });
    +
    +  test("resolveHooksConfig requires prefixes for templated mapping session keys", () => {
    +    expect(() =>
    +      resolveHooksConfig({
    +        hooks: {
    +          enabled: true,
    +          token: "secret",
    +          allowRequestSessionKey: true,
    +          mappings: [
    +            {
    +              match: { path: "gmail" },
    +              action: "agent",
    +              messageTemplate: "Subject: {{messages[0].subject}}",
    +              sessionKey: "hook:gmail:{{messages[0].id}}",
    +            },
    +          ],
    +        },
    +      } as OpenClawConfig),
    +    ).toThrow(
    +      "hooks.allowedSessionKeyPrefixes is required when a hook mapping sessionKey uses templates, even if hooks.allowRequestSessionKey=true",
    +    );
    +  });
    +
    +  test("resolveHooksConfig allows a static explicit mapping to shadow the templated gmail preset", () => {
    +    expect(() =>
    +      resolveHooksConfig({
    +        hooks: {
    +          enabled: true,
    +          token: "secret",
    +          allowRequestSessionKey: false,
    +          presets: ["gmail"],
    +          mappings: [
    +            {
    +              match: { path: "gmail" },
    +              action: "agent",
    +              messageTemplate: "Subject: {{messages[0].subject}}",
    +              sessionKey: "hook:gmail:static",
    +            },
    +          ],
    +        },
    +      } as OpenClawConfig),
    +    ).not.toThrow();
    +  });
    +
    +  test("resolveHooksConfig allows a static catch-all mapping to shadow a later templated mapping", () => {
    +    expect(() =>
    +      resolveHooksConfig({
    +        hooks: {
    +          enabled: true,
    +          token: "secret",
    +          mappings: [
    +            {
    +              action: "agent",
    +              messageTemplate: "catch-all",
    +              sessionKey: "hook:static",
    +            },
    +            {
    +              match: { path: "gmail" },
    +              action: "agent",
    +              messageTemplate: "Subject: {{messages[0].subject}}",
    +              sessionKey: "hook:gmail:{{messages[0].id}}",
    +            },
    +          ],
    +        },
    +      } as OpenClawConfig),
    +    ).not.toThrow();
    +  });
    +
    +  test("resolveHooksConfig ignores templated session keys on wake mappings", () => {
    +    expect(() =>
    +      resolveHooksConfig({
    +        hooks: {
    +          enabled: true,
    +          token: "secret",
    +          mappings: [
    +            {
    +              match: { path: "wake" },
    +              action: "wake",
    +              textTemplate: "ping",
    +              sessionKey: "hook:wake:{{payload.id}}",
    +            },
    +          ],
    +        },
    +      } as OpenClawConfig),
    +    ).not.toThrow();
    +  });
    +
    +  test("resolveHooksConfig treats '/' match.path as a catch-all for shadowing", () => {
    +    expect(() =>
    +      resolveHooksConfig({
    +        hooks: {
    +          enabled: true,
    +          token: "secret",
    +          mappings: [
    +            {
    +              match: { path: "/" },
    +              action: "agent",
    +              messageTemplate: "catch-all",
    +              sessionKey: "hook:static",
    +            },
    +            {
    +              match: { path: "gmail" },
    +              action: "agent",
    +              messageTemplate: "Subject: {{messages[0].subject}}",
    +              sessionKey: "hook:gmail:{{messages[0].id}}",
    +            },
    +          ],
    +        },
    +      } as OpenClawConfig),
    +    ).not.toThrow();
    +  });
    +
    +  test("resolveHooksConfig treats empty match.source as a wildcard for shadowing", () => {
    +    expect(() =>
    +      resolveHooksConfig({
    +        hooks: {
    +          enabled: true,
    +          token: "secret",
    +          mappings: [
    +            {
    +              match: { path: "gmail", source: "" },
    +              action: "agent",
    +              messageTemplate: "catch-all source",
    +              sessionKey: "hook:static",
    +            },
    +            {
    +              match: { path: "gmail", source: "gmail" },
    +              action: "agent",
    +              messageTemplate: "Subject: {{messages[0].subject}}",
    +              sessionKey: "hook:gmail:{{messages[0].id}}",
    +            },
    +          ],
    +        },
    +      } as OpenClawConfig),
    +    ).not.toThrow();
    +  });
     });
     
     const emptyRegistry = createTestRegistry([]);
    
  • src/gateway/hooks.ts+48 4 modified
    @@ -11,7 +11,11 @@ import {
       normalizeOptionalString,
     } from "../shared/string-coerce.js";
     import { normalizeMessageChannel } from "../utils/message-channel-core.js";
    -import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js";
    +import {
    +  hasHookTemplateExpressions,
    +  type HookMappingResolved,
    +  resolveHookMappings,
    +} from "./hooks-mapping.js";
     import { resolveAllowedAgentIds } from "./hooks-policy.js";
     import type { HookMessageChannel } from "./hooks.types.js";
     
    @@ -40,6 +44,8 @@ export type HookSessionPolicyResolved = {
       allowedSessionKeyPrefixes?: string[];
     };
     
    +export type HookSessionKeySource = "request" | "mapping-static" | "mapping-templated";
    +
     export function resolveHooksConfig(cfg: OpenClawConfig): HooksConfigResolved | null {
       if (cfg.hooks?.enabled !== true) {
         return null;
    @@ -82,6 +88,11 @@ export function resolveHooksConfig(cfg: OpenClawConfig): HooksConfigResolved | n
           "hooks.allowedSessionKeyPrefixes must include 'hook:' when hooks.defaultSessionKey is unset",
         );
       }
    +  if (hasEffectiveTemplatedHookSessionKeyMapping(mappings) && !allowedSessionKeyPrefixes) {
    +    throw new Error(
    +      "hooks.allowedSessionKeyPrefixes is required when a hook mapping sessionKey uses templates, even if hooks.allowRequestSessionKey=true",
    +    );
    +  }
       return {
         basePath: trimmed,
         token,
    @@ -301,19 +312,22 @@ export function isHookAgentAllowed(
     
     export const getHookAgentPolicyError = () => "agentId is not allowed by hooks.allowedAgentIds";
     export const getHookSessionKeyRequestPolicyError = () =>
    -  "sessionKey is disabled for external /hooks/agent payloads; set hooks.allowRequestSessionKey=true to enable";
    +  "sessionKey is disabled for externally supplied hook payload values; set hooks.allowRequestSessionKey=true to enable";
     export const getHookSessionKeyPrefixError = (prefixes: string[]) =>
       `sessionKey must start with one of: ${prefixes.join(", ")}`;
     
     export function resolveHookSessionKey(params: {
       hooksConfig: HooksConfigResolved;
    -  source: "request" | "mapping";
    +  source: HookSessionKeySource;
       sessionKey?: string;
       idFactory?: () => string;
     }): { ok: true; value: string } | { ok: false; error: string } {
       const requested = resolveSessionKey(params.sessionKey);
       if (requested) {
    -    if (params.source === "request" && !params.hooksConfig.sessionPolicy.allowRequestSessionKey) {
    +    if (
    +      (params.source === "request" || params.source === "mapping-templated") &&
    +      !params.hooksConfig.sessionPolicy.allowRequestSessionKey
    +    ) {
           return { ok: false, error: getHookSessionKeyRequestPolicyError() };
         }
         const allowedPrefixes = params.hooksConfig.sessionPolicy.allowedSessionKeyPrefixes;
    @@ -336,6 +350,36 @@ export function resolveHookSessionKey(params: {
       return { ok: true, value: generated };
     }
     
    +function hasTemplatedHookSessionKey(sessionKey: string | undefined): boolean {
    +  return typeof sessionKey === "string" && hasHookTemplateExpressions(sessionKey);
    +}
    +
    +function hasEffectiveTemplatedHookSessionKeyMapping(mappings: HookMappingResolved[]): boolean {
    +  const effectiveMappings: HookMappingResolved[] = [];
    +  for (const mapping of mappings) {
    +    if (isHookMappingShadowed(mapping, effectiveMappings)) {
    +      continue;
    +    }
    +    effectiveMappings.push(mapping);
    +    if (mapping.action === "agent" && hasTemplatedHookSessionKey(mapping.sessionKey)) {
    +      return true;
    +    }
    +  }
    +  return false;
    +}
    +
    +function isHookMappingShadowed(
    +  mapping: HookMappingResolved,
    +  earlierMappings: HookMappingResolved[],
    +): boolean {
    +  return earlierMappings.some((earlier) => {
    +    if (earlier.matchPath && earlier.matchPath !== mapping.matchPath) {
    +      return false;
    +    }
    +    return !earlier.matchSource || earlier.matchSource === mapping.matchSource;
    +  });
    +}
    +
     export function normalizeHookDispatchSessionKey(params: {
       sessionKey: string;
       targetAgentId: string | undefined;
    
  • src/gateway/server.hooks.test.ts+92 0 modified
    @@ -1,4 +1,5 @@
     import fs from "node:fs/promises";
    +import path from "node:path";
     import { afterEach, describe, expect, test, vi } from "vitest";
     import { resolveMainSessionKeyFromConfig } from "../config/sessions.js";
     import {
    @@ -126,6 +127,14 @@ async function expectHookAgentSessionRouting(params: {
       drainSystemEvents(resolveMainKey());
     }
     
    +async function writeHookTransformModule(moduleName: string, source: string): Promise<void> {
    +  const configPath = process.env.OPENCLAW_CONFIG_PATH;
    +  expect(configPath).toBeTruthy();
    +  const transformsDir = path.join(path.dirname(configPath!), "hooks", "transforms");
    +  await fs.mkdir(transformsDir, { recursive: true });
    +  await fs.writeFile(path.join(transformsDir, moduleName), source, "utf-8");
    +}
    +
     describe("gateway server hooks", () => {
       test("handles auth, wake, and agent flows", async () => {
         testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
    @@ -396,6 +405,89 @@ describe("gateway server hooks", () => {
         });
       });
     
    +  test("enforces templated vs static mapping session keys on /hooks/<mapping>", async () => {
    +    testState.hooksConfig = {
    +      enabled: true,
    +      token: HOOK_TOKEN,
    +      allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"],
    +      mappings: [
    +        {
    +          match: { path: "mapped-templated" },
    +          action: "agent",
    +          messageTemplate: "Mapped: {{payload.subject}}",
    +          sessionKey: "hook:gmail:{{payload.id}}",
    +        },
    +        {
    +          match: { path: "mapped-static" },
    +          action: "agent",
    +          messageTemplate: "Mapped: {{payload.subject}}",
    +          sessionKey: "hook:gmail:fixed",
    +        },
    +      ],
    +    };
    +
    +    await withGatewayServer(async ({ port }) => {
    +      const templated = await postHook(port, "/hooks/mapped-templated", {
    +        subject: "hello",
    +        id: "42",
    +      });
    +      expect(templated.status).toBe(400);
    +      const templatedBody = (await templated.json()) as { error?: string };
    +      expect(templatedBody.error).toContain("hooks.allowRequestSessionKey");
    +      expect(cronIsolatedRun).not.toHaveBeenCalled();
    +
    +      mockIsolatedRunOkOnce();
    +      const staticMapped = await postHook(port, "/hooks/mapped-static", {
    +        subject: "hello",
    +      });
    +      expect(staticMapped.status).toBe(200);
    +      await waitForSystemEvent();
    +      const staticCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as
    +        | { sessionKey?: string }
    +        | undefined;
    +      expect(staticCall?.sessionKey).toBe("hook:gmail:fixed");
    +      drainSystemEvents(resolveMainKey());
    +    });
    +  });
    +
    +  test("treats malformed transform sessionKeySource as templated on /hooks/<mapping>", async () => {
    +    await writeHookTransformModule(
    +      "mapped-invalid-session-key-source.mjs",
    +      [
    +        "export default () => ({",
    +        '  kind: "agent",',
    +        '  message: "Mapped: from transform",',
    +        '  sessionKey: "hook:gmail:from-transform",',
    +        '  sessionKeySource: "bogus",',
    +        "});",
    +      ].join("\n"),
    +    );
    +
    +    testState.hooksConfig = {
    +      enabled: true,
    +      token: HOOK_TOKEN,
    +      allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"],
    +      mappings: [
    +        {
    +          match: { path: "mapped-invalid-session-key-source" },
    +          action: "agent",
    +          messageTemplate: "Mapped: {{payload.subject}}",
    +          transform: { module: "mapped-invalid-session-key-source.mjs" },
    +        },
    +      ],
    +    };
    +
    +    await withGatewayServer(async ({ port }) => {
    +      const response = await postHook(port, "/hooks/mapped-invalid-session-key-source", {
    +        subject: "hello",
    +      });
    +      expect(response.status).toBe(400);
    +      const body = (await response.json()) as { error?: string };
    +      expect(body.error).toContain("hooks.allowRequestSessionKey");
    +      expect(cronIsolatedRun).not.toHaveBeenCalled();
    +    });
    +  });
    +
       test("preserves target-agent prefixes before isolated dispatch", async () => {
         testState.hooksConfig = {
           enabled: true,
    
  • src/gateway/server-http.ts+2 1 modified
    @@ -757,7 +757,8 @@ export function createHooksRequestHandler(
               }
               const sessionKey = resolveHookSessionKey({
                 hooksConfig,
    -            source: "mapping",
    +            source:
    +              mapped.action.sessionKeySource === "static" ? "mapping-static" : "mapping-templated",
                 sessionKey: mapped.action.sessionKey,
               });
               if (!sessionKey.ok) {
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

3

News mentions

0

No linked articles in our index yet.