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

@@ -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;
}