fix: harden hook session key routing defaults

This commit is contained in:
Peter Steinberger
2026-02-13 02:09:01 +01:00
parent 0a7201fa84
commit 3421b2ec1e
15 changed files with 603 additions and 32 deletions

View File

@@ -10,11 +10,16 @@ Docs: https://docs.openclaw.ai
- Telegram: render blockquotes as native `<blockquote>` tags instead of stripping them. (#14608)
- Config: avoid redacting `maxTokens`-like fields during config snapshot redaction, preventing round-trip validation failures in `/config`. (#14006) Thanks @constansino.
### Breaking
- Hooks: `POST /hooks/agent` now rejects payload `sessionKey` overrides by default. To keep fixed hook context, set `hooks.defaultSessionKey` (recommended with `hooks.allowedSessionKeyPrefixes: ["hook:"]`). If you need legacy behavior, explicitly set `hooks.allowRequestSessionKey: true`. Thanks @alpernae for reporting.
### Fixes
- Gateway/OpenResponses: harden URL-based `input_file`/`input_image` handling with explicit SSRF deny policy, hostname allowlists (`files.urlAllowlist` / `images.urlAllowlist`), per-request URL input caps (`maxUrlParts`), blocked-fetch audit logging, and regression coverage/docs updates.
- Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek.
- Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc.
- Security/Audit: add hook session-routing hardening checks (`hooks.defaultSessionKey`, `hooks.allowRequestSessionKey`, and prefix allowlists), and warn when HTTP API endpoints allow explicit session-key routing.
- Security/Sandbox: confine mirrored skill sync destinations to the sandbox `skills/` root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal.
- Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip `toolResult.details` from model-facing transcript/compaction inputs to reduce prompt-injection replay risk.
- Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (`429` + `Retry-After`). Thanks @akhmittra.

View File

@@ -37,7 +37,7 @@ Every request must include the hook token. Prefer headers:
- `Authorization: Bearer <token>` (recommended)
- `x-openclaw-token: <token>`
- `?token=<token>` (deprecated; logs a warning and will be removed in a future major release)
- Query-string tokens are rejected (`?token=...` returns `400`).
## Endpoints
@@ -80,7 +80,7 @@ Payload:
- `message` **required** (string): The prompt or message for the agent to process.
- `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries.
- `agentId` optional (string): Route this hook to a specific agent. Unknown IDs fall back to the default agent. When set, the hook runs using the resolved agent's workspace and configuration.
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
- `sessionKey` optional (string): The key used to identify the agent's session. By default this field is rejected unless `hooks.allowRequestSessionKey=true`.
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`.
@@ -95,6 +95,40 @@ Effect:
- Always posts a summary into the **main** session
- If `wakeMode=now`, triggers an immediate heartbeat
## Session key policy (breaking change)
`/hooks/agent` payload `sessionKey` overrides are disabled by default.
- Recommended: set a fixed `hooks.defaultSessionKey` and keep request overrides off.
- Optional: allow request overrides only when needed, and restrict prefixes.
Recommended config:
```json5
{
hooks: {
enabled: true,
token: "${OPENCLAW_HOOKS_TOKEN}",
defaultSessionKey: "hook:ingress",
allowRequestSessionKey: false,
allowedSessionKeyPrefixes: ["hook:"],
},
}
```
Compatibility config (legacy behavior):
```json5
{
hooks: {
enabled: true,
token: "${OPENCLAW_HOOKS_TOKEN}",
allowRequestSessionKey: true,
allowedSessionKeyPrefixes: ["hook:"], // strongly recommended
},
}
```
### `POST /hooks/<name>` (mapped)
Custom hook names are resolved via `hooks.mappings` (see configuration). A mapping can
@@ -112,6 +146,9 @@ Mapping options (summary):
(`channel` defaults to `last` and falls back to WhatsApp).
- `agentId` routes the hook to a specific agent; unknown IDs fall back to the default agent.
- `hooks.allowedAgentIds` restricts explicit `agentId` routing. Omit it (or include `*`) to allow any agent. Set `[]` to deny explicit `agentId` routing.
- `hooks.defaultSessionKey` sets the default session for hook agent runs when no explicit key is provided.
- `hooks.allowRequestSessionKey` controls whether `/hooks/agent` payloads may set `sessionKey` (default: `false`).
- `hooks.allowedSessionKeyPrefixes` optionally restricts explicit `sessionKey` values from request payloads and mappings.
- `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook
(dangerous; only for trusted internal sources).
- `openclaw webhooks gmail setup` writes `hooks.gmail` config for `openclaw webhooks gmail run`.
@@ -168,6 +205,8 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \
- Use a dedicated hook token; do not reuse gateway auth tokens.
- Repeated auth failures are rate-limited per client address to slow brute-force attempts.
- If you use multi-agent routing, set `hooks.allowedAgentIds` to limit explicit `agentId` selection.
- Keep `hooks.allowRequestSessionKey=false` unless you require caller-selected sessions.
- If you enable request `sessionKey`, restrict `hooks.allowedSessionKeyPrefixes` (for example, `["hook:"]`).
- Avoid including sensitive raw payloads in webhook logs.
- Hook payloads are treated as untrusted and wrapped with safety boundaries by default.
If you must disable this for a specific hook, set `allowUnsafeExternalContent: true`

View File

@@ -24,3 +24,4 @@ openclaw security audit --fix
The audit warns when multiple DM senders share the main session and recommends **secure DM mode**: `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`.

View File

@@ -1964,6 +1964,9 @@ See [Multiple Gateways](/gateway/multiple-gateways).
token: "shared-secret",
path: "/hooks",
maxBodyBytes: 262144,
defaultSessionKey: "hook:ingress",
allowRequestSessionKey: false,
allowedSessionKeyPrefixes: ["hook:"],
allowedAgentIds: ["hooks", "main"],
presets: ["gmail"],
transformsDir: "~/.openclaw/hooks",
@@ -1991,6 +1994,7 @@ Auth: `Authorization: Bearer <token>` or `x-openclaw-token: <token>`.
- `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`
<Accordion title="Mapping details">
@@ -2001,6 +2005,9 @@ Auth: `Authorization: Bearer <token>` or `x-openclaw-token: <token>`.
- `transform` can point to a JS/TS module returning a hook action.
- `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:"]`.
- `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).

View File

@@ -262,6 +262,9 @@ When validation fails:
enabled: true,
token: "shared-secret",
path: "/hooks",
defaultSessionKey: "hook:ingress",
allowRequestSessionKey: false,
allowedSessionKeyPrefixes: ["hook:"],
mappings: [
{
match: { path: "gmail" },

View File

@@ -117,6 +117,21 @@ export type HooksConfig = {
enabled?: boolean;
path?: string;
token?: string;
/**
* Default session key used for hook agent runs when no request/mapping session key is used.
* If omitted, OpenClaw generates `hook:<uuid>` per request.
*/
defaultSessionKey?: string;
/**
* Allow `sessionKey` from external `/hooks/agent` request payloads.
* Default: false.
*/
allowRequestSessionKey?: boolean;
/**
* Optional allowlist for explicit session keys (request + mapping). Example: ["hook:"].
* Empty/omitted means no prefix restriction.
*/
allowedSessionKeyPrefixes?: string[];
/**
* Restrict explicit hook `agentId` routing to these agent ids.
* Omit or include `*` to allow any agent. Set `[]` to deny all explicit `agentId` routing.

View File

@@ -302,6 +302,9 @@ export const OpenClawSchema = z
enabled: z.boolean().optional(),
path: z.string().optional(),
token: z.string().optional(),
defaultSessionKey: z.string().optional(),
allowRequestSessionKey: z.boolean().optional(),
allowedSessionKeyPrefixes: z.array(z.string()).optional(),
allowedAgentIds: z.array(z.string()).optional(),
maxBodyBytes: z.number().int().positive().optional(),
presets: z.array(z.string()).optional(),

View File

@@ -7,6 +7,7 @@ import { createIMessageTestPlugin, createTestRegistry } from "../test-utils/chan
import {
extractHookToken,
isHookAgentAllowed,
resolveHookSessionKey,
resolveHookTargetAgentId,
normalizeAgentPayload,
normalizeWakePayload,
@@ -32,6 +33,7 @@ describe("gateway hooks helpers", () => {
const resolved = resolveHooksConfig(base);
expect(resolved?.basePath).toBe("/hooks");
expect(resolved?.token).toBe("secret");
expect(resolved?.sessionPolicy.allowRequestSessionKey).toBe(false);
});
test("resolveHooksConfig rejects root path", () => {
@@ -71,19 +73,16 @@ describe("gateway hooks helpers", () => {
});
test("normalizeAgentPayload defaults + validates channel", () => {
const ok = normalizeAgentPayload({ message: "hello" }, { idFactory: () => "fixed" });
const ok = normalizeAgentPayload({ message: "hello" });
expect(ok.ok).toBe(true);
if (ok.ok) {
expect(ok.value.sessionKey).toBe("hook:fixed");
expect(ok.value.sessionKey).toBeUndefined();
expect(ok.value.channel).toBe("last");
expect(ok.value.name).toBe("Hook");
expect(ok.value.deliver).toBe(true);
}
const explicitNoDeliver = normalizeAgentPayload(
{ message: "hello", deliver: false },
{ idFactory: () => "fixed" },
);
const explicitNoDeliver = normalizeAgentPayload({ message: "hello", deliver: false });
expect(explicitNoDeliver.ok).toBe(true);
if (explicitNoDeliver.ok) {
expect(explicitNoDeliver.value.deliver).toBe(false);
@@ -98,10 +97,7 @@ describe("gateway hooks helpers", () => {
},
]),
);
const imsg = normalizeAgentPayload(
{ message: "yo", channel: "imsg" },
{ idFactory: () => "x" },
);
const imsg = normalizeAgentPayload({ message: "yo", channel: "imsg" });
expect(imsg.ok).toBe(true);
if (imsg.ok) {
expect(imsg.value.channel).toBe("imessage");
@@ -116,10 +112,7 @@ describe("gateway hooks helpers", () => {
},
]),
);
const teams = normalizeAgentPayload(
{ message: "yo", channel: "teams" },
{ idFactory: () => "x" },
);
const teams = normalizeAgentPayload({ message: "yo", channel: "teams" });
expect(teams.ok).toBe(true);
if (teams.ok) {
expect(teams.value.channel).toBe("msteams");
@@ -130,16 +123,13 @@ describe("gateway hooks helpers", () => {
});
test("normalizeAgentPayload passes agentId", () => {
const ok = normalizeAgentPayload(
{ message: "hello", agentId: "hooks" },
{ idFactory: () => "fixed" },
);
const ok = normalizeAgentPayload({ message: "hello", agentId: "hooks" });
expect(ok.ok).toBe(true);
if (ok.ok) {
expect(ok.value.agentId).toBe("hooks");
}
const noAgent = normalizeAgentPayload({ message: "hello" }, { idFactory: () => "fixed" });
const noAgent = normalizeAgentPayload({ message: "hello" });
expect(noAgent.ok).toBe(true);
if (noAgent.ok) {
expect(noAgent.value.agentId).toBeUndefined();
@@ -225,6 +215,116 @@ describe("gateway hooks helpers", () => {
expect(isHookAgentAllowed(resolved, "hooks")).toBe(true);
expect(isHookAgentAllowed(resolved, "missing-agent")).toBe(true);
});
test("resolveHookSessionKey disables request sessionKey by default", () => {
const cfg = {
hooks: { enabled: true, token: "secret" },
} as OpenClawConfig;
const resolved = resolveHooksConfig(cfg);
expect(resolved).not.toBeNull();
if (!resolved) {
return;
}
const denied = resolveHookSessionKey({
hooksConfig: resolved,
source: "request",
sessionKey: "agent:main:dm:u99999",
});
expect(denied.ok).toBe(false);
});
test("resolveHookSessionKey allows request sessionKey when explicitly enabled", () => {
const cfg = {
hooks: { enabled: true, token: "secret", allowRequestSessionKey: true },
} as OpenClawConfig;
const resolved = resolveHooksConfig(cfg);
expect(resolved).not.toBeNull();
if (!resolved) {
return;
}
const allowed = resolveHookSessionKey({
hooksConfig: resolved,
source: "request",
sessionKey: "hook:manual",
});
expect(allowed).toEqual({ ok: true, value: "hook:manual" });
});
test("resolveHookSessionKey enforces allowed prefixes", () => {
const cfg = {
hooks: {
enabled: true,
token: "secret",
allowRequestSessionKey: true,
allowedSessionKeyPrefixes: ["hook:"],
},
} as OpenClawConfig;
const resolved = resolveHooksConfig(cfg);
expect(resolved).not.toBeNull();
if (!resolved) {
return;
}
const blocked = resolveHookSessionKey({
hooksConfig: resolved,
source: "request",
sessionKey: "agent:main:main",
});
expect(blocked.ok).toBe(false);
const allowed = resolveHookSessionKey({
hooksConfig: resolved,
source: "mapping",
sessionKey: "hook:gmail:1",
});
expect(allowed).toEqual({ ok: true, value: "hook:gmail:1" });
});
test("resolveHookSessionKey uses defaultSessionKey when request key is absent", () => {
const cfg = {
hooks: {
enabled: true,
token: "secret",
defaultSessionKey: "hook:ingress",
},
} as OpenClawConfig;
const resolved = resolveHooksConfig(cfg);
expect(resolved).not.toBeNull();
if (!resolved) {
return;
}
const resolvedKey = resolveHookSessionKey({
hooksConfig: resolved,
source: "request",
});
expect(resolvedKey).toEqual({ ok: true, value: "hook:ingress" });
});
test("resolveHooksConfig validates defaultSessionKey and generated fallback against prefixes", () => {
expect(() =>
resolveHooksConfig({
hooks: {
enabled: true,
token: "secret",
defaultSessionKey: "agent:main:main",
allowedSessionKeyPrefixes: ["hook:"],
},
} as OpenClawConfig),
).toThrow("hooks.defaultSessionKey must match hooks.allowedSessionKeyPrefixes");
expect(() =>
resolveHooksConfig({
hooks: {
enabled: true,
token: "secret",
allowedSessionKeyPrefixes: ["agent:"],
},
} as OpenClawConfig),
).toThrow(
"hooks.allowedSessionKeyPrefixes must include 'hook:' when hooks.defaultSessionKey is unset",
);
});
});
const emptyRegistry = createTestRegistry([]);

View File

@@ -17,6 +17,7 @@ export type HooksConfigResolved = {
maxBodyBytes: number;
mappings: HookMappingResolved[];
agentPolicy: HookAgentPolicyResolved;
sessionPolicy: HookSessionPolicyResolved;
};
export type HookAgentPolicyResolved = {
@@ -25,6 +26,12 @@ export type HookAgentPolicyResolved = {
allowedAgentIds?: Set<string>;
};
export type HookSessionPolicyResolved = {
defaultSessionKey?: string;
allowRequestSessionKey: boolean;
allowedSessionKeyPrefixes?: string[];
};
export function resolveHooksConfig(cfg: OpenClawConfig): HooksConfigResolved | null {
if (cfg.hooks?.enabled !== true) {
return null;
@@ -47,6 +54,26 @@ export function resolveHooksConfig(cfg: OpenClawConfig): HooksConfigResolved | n
const defaultAgentId = resolveDefaultAgentId(cfg);
const knownAgentIds = resolveKnownAgentIds(cfg, defaultAgentId);
const allowedAgentIds = resolveAllowedAgentIds(cfg.hooks?.allowedAgentIds);
const defaultSessionKey = resolveSessionKey(cfg.hooks?.defaultSessionKey);
const allowedSessionKeyPrefixes = resolveAllowedSessionKeyPrefixes(
cfg.hooks?.allowedSessionKeyPrefixes,
);
if (
defaultSessionKey &&
allowedSessionKeyPrefixes &&
!isSessionKeyAllowedByPrefix(defaultSessionKey, allowedSessionKeyPrefixes)
) {
throw new Error("hooks.defaultSessionKey must match hooks.allowedSessionKeyPrefixes");
}
if (
!defaultSessionKey &&
allowedSessionKeyPrefixes &&
!isSessionKeyAllowedByPrefix("hook:example", allowedSessionKeyPrefixes)
) {
throw new Error(
"hooks.allowedSessionKeyPrefixes must include 'hook:' when hooks.defaultSessionKey is unset",
);
}
return {
basePath: trimmed,
token,
@@ -57,6 +84,11 @@ export function resolveHooksConfig(cfg: OpenClawConfig): HooksConfigResolved | n
knownAgentIds,
allowedAgentIds,
},
sessionPolicy: {
defaultSessionKey,
allowRequestSessionKey: cfg.hooks?.allowRequestSessionKey === true,
allowedSessionKeyPrefixes,
},
};
}
@@ -89,6 +121,39 @@ function resolveAllowedAgentIds(raw: string[] | undefined): Set<string> | undefi
return allowed;
}
function resolveSessionKey(raw: string | undefined): string | undefined {
const value = raw?.trim();
return value ? value : undefined;
}
function normalizeSessionKeyPrefix(raw: string): string | undefined {
const value = raw.trim().toLowerCase();
return value ? value : undefined;
}
function resolveAllowedSessionKeyPrefixes(raw: string[] | undefined): string[] | undefined {
if (!Array.isArray(raw)) {
return undefined;
}
const set = new Set<string>();
for (const prefix of raw) {
const normalized = normalizeSessionKeyPrefix(prefix);
if (!normalized) {
continue;
}
set.add(normalized);
}
return set.size > 0 ? Array.from(set) : undefined;
}
function isSessionKeyAllowedByPrefix(sessionKey: string, prefixes: string[]): boolean {
const normalized = sessionKey.trim().toLowerCase();
if (!normalized) {
return false;
}
return prefixes.some((prefix) => normalized.startsWith(prefix));
}
export function extractHookToken(req: IncomingMessage): string | undefined {
const auth =
typeof req.headers.authorization === "string" ? req.headers.authorization.trim() : "";
@@ -186,7 +251,7 @@ export type HookAgentPayload = {
name: string;
agentId?: string;
wakeMode: "now" | "next-heartbeat";
sessionKey: string;
sessionKey?: string;
deliver: boolean;
channel: HookMessageChannel;
to?: string;
@@ -253,11 +318,43 @@ 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";
export const getHookSessionKeyPrefixError = (prefixes: string[]) =>
`sessionKey must start with one of: ${prefixes.join(", ")}`;
export function normalizeAgentPayload(
payload: Record<string, unknown>,
opts?: { idFactory?: () => string },
):
export function resolveHookSessionKey(params: {
hooksConfig: HooksConfigResolved;
source: "request" | "mapping";
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) {
return { ok: false, error: getHookSessionKeyRequestPolicyError() };
}
const allowedPrefixes = params.hooksConfig.sessionPolicy.allowedSessionKeyPrefixes;
if (allowedPrefixes && !isSessionKeyAllowedByPrefix(requested, allowedPrefixes)) {
return { ok: false, error: getHookSessionKeyPrefixError(allowedPrefixes) };
}
return { ok: true, value: requested };
}
const defaultSessionKey = params.hooksConfig.sessionPolicy.defaultSessionKey;
if (defaultSessionKey) {
return { ok: true, value: defaultSessionKey };
}
const generated = `hook:${(params.idFactory ?? randomUUID)()}`;
const allowedPrefixes = params.hooksConfig.sessionPolicy.allowedSessionKeyPrefixes;
if (allowedPrefixes && !isSessionKeyAllowedByPrefix(generated, allowedPrefixes)) {
return { ok: false, error: getHookSessionKeyPrefixError(allowedPrefixes) };
}
return { ok: true, value: generated };
}
export function normalizeAgentPayload(payload: Record<string, unknown>):
| {
ok: true;
value: HookAgentPayload;
@@ -274,11 +371,8 @@ export function normalizeAgentPayload(
typeof agentIdRaw === "string" && agentIdRaw.trim() ? agentIdRaw.trim() : undefined;
const wakeMode = payload.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now";
const sessionKeyRaw = payload.sessionKey;
const idFactory = opts?.idFactory ?? randomUUID;
const sessionKey =
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim()
? sessionKeyRaw.trim()
: `hook:${idFactory()}`;
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim() ? sessionKeyRaw.trim() : undefined;
const channel = resolveHookChannel(payload.channel);
if (!channel) {
return { ok: false, error: getHookChannelError() };

View File

@@ -38,6 +38,7 @@ import {
normalizeHookHeaders,
normalizeWakePayload,
readJsonBody,
resolveHookSessionKey,
resolveHookTargetAgentId,
resolveHookChannel,
resolveHookDeliver,
@@ -266,8 +267,18 @@ export function createHooksRequestHandler(
sendJson(res, 400, { ok: false, error: getHookAgentPolicyError() });
return true;
}
const sessionKey = resolveHookSessionKey({
hooksConfig,
source: "request",
sessionKey: normalized.value.sessionKey,
});
if (!sessionKey.ok) {
sendJson(res, 400, { ok: false, error: sessionKey.error });
return true;
}
const runId = dispatchAgentHook({
...normalized.value,
sessionKey: sessionKey.value,
agentId: resolveHookTargetAgentId(hooksConfig, normalized.value.agentId),
});
sendJson(res, 202, { ok: true, runId });
@@ -309,12 +320,21 @@ export function createHooksRequestHandler(
sendJson(res, 400, { ok: false, error: getHookAgentPolicyError() });
return true;
}
const sessionKey = resolveHookSessionKey({
hooksConfig,
source: "mapping",
sessionKey: mapped.action.sessionKey,
});
if (!sessionKey.ok) {
sendJson(res, 400, { ok: false, error: sessionKey.error });
return true;
}
const runId = dispatchAgentHook({
message: mapped.action.message,
name: mapped.action.name ?? "Hook",
agentId: resolveHookTargetAgentId(hooksConfig, mapped.action.agentId),
wakeMode: mapped.action.wakeMode,
sessionKey: mapped.action.sessionKey ?? "",
sessionKey: sessionKey.value,
deliver: resolveHookDeliver(mapped.action.deliver),
channel,
to: mapped.action.to,

View File

@@ -199,6 +199,115 @@ describe("gateway server hooks", () => {
}
});
test("rejects request sessionKey unless hooks.allowRequestSessionKey is enabled", async () => {
testState.hooksConfig = { enabled: true, token: "hook-secret" };
const port = await getFreePort();
const server = await startGatewayServer(port);
try {
const denied = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer hook-secret",
},
body: JSON.stringify({
message: "Do it",
sessionKey: "agent:main:dm:u99999",
}),
});
expect(denied.status).toBe(400);
const deniedBody = (await denied.json()) as { error?: string };
expect(deniedBody.error).toContain("hooks.allowRequestSessionKey");
} finally {
await server.close();
}
});
test("respects hooks session policy for request + mapping session keys", async () => {
testState.hooksConfig = {
enabled: true,
token: "hook-secret",
allowRequestSessionKey: true,
allowedSessionKeyPrefixes: ["hook:"],
defaultSessionKey: "hook:ingress",
mappings: [
{
match: { path: "mapped-ok" },
action: "agent",
messageTemplate: "Mapped: {{payload.subject}}",
sessionKey: "hook:mapped:{{payload.id}}",
},
{
match: { path: "mapped-bad" },
action: "agent",
messageTemplate: "Mapped: {{payload.subject}}",
sessionKey: "agent:main:main",
},
],
};
const port = await getFreePort();
const server = await startGatewayServer(port);
try {
cronIsolatedRun.mockReset();
cronIsolatedRun.mockResolvedValue({ status: "ok", summary: "done" });
const defaultRoute = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer hook-secret",
},
body: JSON.stringify({ message: "No key" }),
});
expect(defaultRoute.status).toBe(202);
await waitForSystemEvent();
const defaultCall = cronIsolatedRun.mock.calls[0]?.[0] as { sessionKey?: string } | undefined;
expect(defaultCall?.sessionKey).toBe("hook:ingress");
drainSystemEvents(resolveMainKey());
cronIsolatedRun.mockReset();
cronIsolatedRun.mockResolvedValue({ status: "ok", summary: "done" });
const mappedOk = await fetch(`http://127.0.0.1:${port}/hooks/mapped-ok`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer hook-secret",
},
body: JSON.stringify({ subject: "hello", id: "42" }),
});
expect(mappedOk.status).toBe(202);
await waitForSystemEvent();
const mappedCall = cronIsolatedRun.mock.calls[0]?.[0] as { sessionKey?: string } | undefined;
expect(mappedCall?.sessionKey).toBe("hook:mapped:42");
drainSystemEvents(resolveMainKey());
const requestBadPrefix = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer hook-secret",
},
body: JSON.stringify({
message: "Bad key",
sessionKey: "agent:main:main",
}),
});
expect(requestBadPrefix.status).toBe(400);
const mappedBadPrefix = await fetch(`http://127.0.0.1:${port}/hooks/mapped-bad`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer hook-secret",
},
body: JSON.stringify({ subject: "hello" }),
});
expect(mappedBadPrefix.status).toBe(400);
} finally {
await server.close();
}
});
test("enforces hooks.allowedAgentIds for explicit agent routing", async () => {
testState.hooksConfig = {
enabled: true,

View File

@@ -43,7 +43,7 @@ export function createGatewayHooksRequestHandler(params: {
timeoutSeconds?: number;
allowUnsafeExternalContent?: boolean;
}) => {
const sessionKey = value.sessionKey.trim() ? value.sessionKey.trim() : `hook:${randomUUID()}`;
const sessionKey = value.sessionKey.trim();
const mainSessionKey = resolveMainSessionKeyFromConfig();
const jobId = randomUUID();
const now = Date.now();

View File

@@ -75,6 +75,15 @@ function looksLikeEnvRef(value: string): boolean {
return v.startsWith("${") && v.endsWith("}");
}
function isGatewayRemotelyExposed(cfg: OpenClawConfig): boolean {
const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback";
if (bind !== "loopback") {
return true;
}
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
return tailscaleMode === "serve" || tailscaleMode === "funnel";
}
type ModelRef = { id: string; source: string };
function addModel(models: ModelRef[], raw: unknown, source: string) {
@@ -411,6 +420,51 @@ export function collectHooksHardeningFindings(cfg: OpenClawConfig): SecurityAudi
});
}
const allowRequestSessionKey = cfg.hooks?.allowRequestSessionKey === true;
const defaultSessionKey =
typeof cfg.hooks?.defaultSessionKey === "string" ? cfg.hooks.defaultSessionKey.trim() : "";
const allowedPrefixes = Array.isArray(cfg.hooks?.allowedSessionKeyPrefixes)
? cfg.hooks.allowedSessionKeyPrefixes
.map((prefix) => prefix.trim())
.filter((prefix) => prefix.length > 0)
: [];
const remoteExposure = isGatewayRemotelyExposed(cfg);
if (!defaultSessionKey) {
findings.push({
checkId: "hooks.default_session_key_unset",
severity: "warn",
title: "hooks.defaultSessionKey is not configured",
detail:
"Hook agent runs without explicit sessionKey use generated per-request keys. Set hooks.defaultSessionKey to keep hook ingress scoped to a known session.",
remediation: 'Set hooks.defaultSessionKey (for example, "hook:ingress").',
});
}
if (allowRequestSessionKey) {
findings.push({
checkId: "hooks.request_session_key_enabled",
severity: remoteExposure ? "critical" : "warn",
title: "External hook payloads may override sessionKey",
detail:
"hooks.allowRequestSessionKey=true allows `/hooks/agent` callers to choose the session key. Treat hook token holders as full-trust unless you also restrict prefixes.",
remediation:
"Set hooks.allowRequestSessionKey=false (recommended) or constrain hooks.allowedSessionKeyPrefixes.",
});
}
if (allowRequestSessionKey && allowedPrefixes.length === 0) {
findings.push({
checkId: "hooks.request_session_key_prefixes_missing",
severity: remoteExposure ? "critical" : "warn",
title: "Request sessionKey override is enabled without prefix restrictions",
detail:
"hooks.allowRequestSessionKey=true and hooks.allowedSessionKeyPrefixes is unset/empty, so request payloads can target arbitrary session key shapes.",
remediation:
'Set hooks.allowedSessionKeyPrefixes (for example, ["hook:"]) or disable request overrides.',
});
}
return findings;
}

View File

@@ -870,6 +870,106 @@ describe("security audit", () => {
}
});
it("warns when hooks.defaultSessionKey is unset", async () => {
const cfg: OpenClawConfig = {
hooks: { enabled: true, token: "shared-gateway-token-1234567890" },
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "hooks.default_session_key_unset", severity: "warn" }),
]),
);
});
it("flags hooks request sessionKey override when enabled", async () => {
const cfg: OpenClawConfig = {
hooks: {
enabled: true,
token: "shared-gateway-token-1234567890",
defaultSessionKey: "hook:ingress",
allowRequestSessionKey: true,
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "hooks.request_session_key_enabled", severity: "warn" }),
expect.objectContaining({
checkId: "hooks.request_session_key_prefixes_missing",
severity: "warn",
}),
]),
);
});
it("escalates hooks request sessionKey override when gateway is remotely exposed", async () => {
const cfg: OpenClawConfig = {
gateway: { bind: "lan" },
hooks: {
enabled: true,
token: "shared-gateway-token-1234567890",
defaultSessionKey: "hook:ingress",
allowRequestSessionKey: true,
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "hooks.request_session_key_enabled",
severity: "critical",
}),
]),
);
});
it("reports HTTP API session-key override surfaces when enabled", async () => {
const cfg: OpenClawConfig = {
gateway: {
http: {
endpoints: {
chatCompletions: { enabled: true },
responses: { enabled: true },
},
},
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "gateway.http.session_key_override_enabled",
severity: "info",
}),
]),
);
});
it("warns when state/config look like a synced folder", async () => {
const cfg: OpenClawConfig = {};

View File

@@ -275,6 +275,8 @@ function collectGatewayConfigFindings(
(auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword);
const hasTailscaleAuth = auth.allowTailscale && tailscaleMode === "serve";
const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth;
const remotelyExposed =
bind !== "loopback" || tailscaleMode === "serve" || tailscaleMode === "funnel";
if (bind !== "loopback" && !hasSharedSecret) {
findings.push({
@@ -362,6 +364,25 @@ function collectGatewayConfigFindings(
});
}
const chatCompletionsEnabled = cfg.gateway?.http?.endpoints?.chatCompletions?.enabled === true;
const responsesEnabled = cfg.gateway?.http?.endpoints?.responses?.enabled === true;
if (chatCompletionsEnabled || responsesEnabled) {
const enabledEndpoints = [
chatCompletionsEnabled ? "/v1/chat/completions" : null,
responsesEnabled ? "/v1/responses" : null,
].filter((value): value is string => Boolean(value));
findings.push({
checkId: "gateway.http.session_key_override_enabled",
severity: remotelyExposed ? "warn" : "info",
title: "HTTP APIs accept explicit session key override headers",
detail:
`${enabledEndpoints.join(", ")} support x-openclaw-session-key. ` +
"Any authenticated caller can route requests into arbitrary sessions.",
remediation:
"Treat HTTP API credentials as full-trust, disable unused endpoints, and avoid sharing tokens across tenants.",
});
}
return findings;
}