mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 04:41:25 +00:00
fix: harden hook session key routing defaults
This commit is contained in:
@@ -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