diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index e63d90ca39..a040ff4a19 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -153,6 +153,53 @@ describe("security audit", () => { ).toBe(true); }); + it("warns when gateway.tools.allow re-enables dangerous HTTP /tools/invoke tools (loopback)", async () => { + const cfg: OpenClawConfig = { + gateway: { + bind: "loopback", + auth: { token: "secret" }, + tools: { allow: ["sessions_spawn"] }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect( + res.findings.some( + (f) => f.checkId === "gateway.tools_invoke_http.dangerous_allow" && f.severity === "warn", + ), + ).toBe(true); + }); + + it("flags dangerous gateway.tools.allow over HTTP as critical when gateway binds beyond loopback", async () => { + const cfg: OpenClawConfig = { + gateway: { + bind: "lan", + auth: { token: "secret" }, + tools: { allow: ["sessions_spawn", "gateway"] }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect( + res.findings.some( + (f) => + f.checkId === "gateway.tools_invoke_http.dangerous_allow" && f.severity === "critical", + ), + ).toBe(true); + }); + it("does not warn for auth rate limiting when configured", async () => { const cfg: OpenClawConfig = { gateway: { diff --git a/src/security/audit.ts b/src/security/audit.ts index 0e761dd3ec..0f61f03079 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -258,6 +258,33 @@ function collectGatewayConfigFindings( (auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword); const hasTailscaleAuth = auth.allowTailscale && tailscaleMode === "serve"; const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth; + + // HTTP /tools/invoke is intended for narrow automation, not session orchestration/admin operations. + // If operators opt-in to re-enabling these tools over HTTP, warn loudly so the choice is explicit. + const gatewayToolsAllowRaw = Array.isArray(cfg.gateway?.tools?.allow) + ? cfg.gateway?.tools?.allow + : []; + const gatewayToolsAllow = new Set( + gatewayToolsAllowRaw + .map((v) => (typeof v === "string" ? v.trim().toLowerCase() : "")) + .filter(Boolean), + ); + const defaultHttpDeniedTools = ["sessions_spawn", "sessions_send", "gateway", "whatsapp_login"]; + const reenabledOverHttp = defaultHttpDeniedTools.filter((name) => gatewayToolsAllow.has(name)); + if (reenabledOverHttp.length > 0) { + const extraRisk = bind !== "loopback" || tailscaleMode === "funnel"; + findings.push({ + checkId: "gateway.tools_invoke_http.dangerous_allow", + severity: extraRisk ? "critical" : "warn", + title: "Gateway HTTP /tools/invoke re-enables dangerous tools", + detail: + `gateway.tools.allow includes ${reenabledOverHttp.join(", ")} which removes them from the default HTTP deny list. ` + + "This can allow remote session spawning / control-plane actions via HTTP and increases RCE blast radius if the gateway is reachable.", + remediation: + "Remove these entries from gateway.tools.allow (recommended). " + + "If you keep them enabled, keep gateway.bind loopback-only (or tailnet-only), restrict network exposure, and treat the gateway token/password as full-admin.", + }); + } if (bind !== "loopback" && !hasSharedSecret && auth.mode !== "trusted-proxy") { findings.push({ checkId: "gateway.bind_no_auth",