mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 21:37:28 +00:00
fix: harden hook session key routing defaults
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user