From e3e0ffd801386eac21bb48cb6bdc1ea1d7fc8baf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:25:45 +0100 Subject: [PATCH] feat(security): audit gateway HTTP no-auth exposure --- CHANGELOG.md | 1 + docs/cli/security.md | 1 + docs/gateway/security/index.md | 1 + src/security/audit-extra.sync.ts | 48 ++++++++++++++++--- src/security/audit-extra.ts | 1 + src/security/audit.test.ts | 81 ++++++++++++++++++++++++++++++++ src/security/audit.ts | 6 ++- 7 files changed, 130 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 752415c2d7..42587728ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky. - Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky. - Skills: harden coding-agent skill guidance by removing shell-command examples that interpolate untrusted issue text directly into command strings. +- Security/Audit: add `gateway.http.no_auth` findings when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable, with loopback warning and remote-exposure critical severity, plus regression coverage and docs updates. ### Fixes diff --git a/docs/cli/security.md b/docs/cli/security.md index b0c0c8e0d5..aed7d3c190 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -27,6 +27,7 @@ The audit warns when multiple DM senders share the main session and recommends * 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`. It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when global `tools.profile="minimal"` is overridden by agent tool profiles, and when installed extension plugin tools may be reachable under permissive tool policy. +It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint). ## JSON output diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 6a0ba212ab..fe39abf4d9 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -114,6 +114,7 @@ High-signal `checkId` values you will most likely see in real deployments (not e | `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes | | `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no | | `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no | +| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no | | `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no | | `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no | | `gateway.control_ui.insecure_auth` | critical | Token-only over HTTP, no device identity | `gateway.controlUi.allowInsecureAuth` | no | diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 9741865d2f..0491c59ce3 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -1,20 +1,20 @@ -import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js"; -import { - resolveSandboxConfigForAgent, - resolveSandboxToolPolicyForAgent, -} from "../agents/sandbox.js"; /** * Synchronous security audit collector functions. * * These functions analyze config-based security properties without I/O. */ import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { AgentToolsConfig } from "../config/types.tools.js"; +import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js"; +import { + resolveSandboxConfigForAgent, + resolveSandboxToolPolicyForAgent, +} from "../agents/sandbox.js"; import { getBlockedBindReason } from "../agents/sandbox/validate-sandbox-security.js"; import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { resolveBrowserConfig } from "../browser/config.js"; import { formatCliCommand } from "../cli/command-format.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { AgentToolsConfig } from "../config/types.tools.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { resolveNodeCommandAllowlist } from "../gateway/node-command-policy.js"; import { inferParamBFromIdOrName } from "../shared/model-param-b.js"; @@ -535,6 +535,40 @@ export function collectGatewayHttpSessionKeyOverrideFindings( return findings; } +export function collectGatewayHttpNoAuthFindings( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; + const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env }); + if (auth.mode !== "none") { + return findings; + } + + const chatCompletionsEnabled = cfg.gateway?.http?.endpoints?.chatCompletions?.enabled === true; + const responsesEnabled = cfg.gateway?.http?.endpoints?.responses?.enabled === true; + const enabledEndpoints = [ + "/tools/invoke", + chatCompletionsEnabled ? "/v1/chat/completions" : null, + responsesEnabled ? "/v1/responses" : null, + ].filter((entry): entry is string => Boolean(entry)); + + const remoteExposure = isGatewayRemotelyExposed(cfg); + findings.push({ + checkId: "gateway.http.no_auth", + severity: remoteExposure ? "critical" : "warn", + title: "Gateway HTTP APIs are reachable without auth", + detail: + `gateway.auth.mode="none" leaves ${enabledEndpoints.join(", ")} callable without a shared secret. ` + + "Treat this as trusted-local only and avoid exposing the gateway beyond loopback.", + remediation: + "Set gateway.auth.mode to token/password (recommended). If you intentionally keep mode=none, keep gateway.bind=loopback and disable optional HTTP endpoints.", + }); + + return findings; +} + export function collectSandboxDockerNoopFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const configuredPaths: string[] = []; diff --git a/src/security/audit-extra.ts b/src/security/audit-extra.ts index abd9efa097..4e101ba65c 100644 --- a/src/security/audit-extra.ts +++ b/src/security/audit-extra.ts @@ -11,6 +11,7 @@ export { collectAttackSurfaceSummaryFindings, collectExposureMatrixFindings, + collectGatewayHttpNoAuthFindings, collectGatewayHttpSessionKeyOverrideFindings, collectHooksHardeningFindings, collectMinimalProfileOverrideFindings, diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index be46c87aab..ba5490a180 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1262,6 +1262,87 @@ describe("security audit", () => { ); }); + it("warns when gateway HTTP APIs run with auth.mode=none on loopback", async () => { + const cfg: OpenClawConfig = { + gateway: { + bind: "loopback", + auth: { mode: "none" }, + http: { + endpoints: { + chatCompletions: { enabled: true }, + }, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "gateway.http.no_auth", severity: "warn" }), + ]), + ); + const finding = res.findings.find((entry) => entry.checkId === "gateway.http.no_auth"); + expect(finding?.detail).toContain("/tools/invoke"); + expect(finding?.detail).toContain("/v1/chat/completions"); + }); + + it("flags gateway HTTP APIs with auth.mode=none as critical when remotely exposed", async () => { + const cfg: OpenClawConfig = { + gateway: { + bind: "lan", + auth: { mode: "none" }, + http: { + endpoints: { + responses: { enabled: true }, + }, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "gateway.http.no_auth", severity: "critical" }), + ]), + ); + }); + + it("does not report gateway.http.no_auth when auth mode is token", async () => { + const cfg: OpenClawConfig = { + gateway: { + bind: "loopback", + auth: { mode: "token", token: "secret" }, + http: { + endpoints: { + chatCompletions: { enabled: true }, + responses: { enabled: true }, + }, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings.some((entry) => entry.checkId === "gateway.http.no_auth")).toBe(false); + }); + it("reports HTTP API session-key override surfaces when enabled", async () => { const cfg: OpenClawConfig = { gateway: { diff --git a/src/security/audit.ts b/src/security/audit.ts index 57ffa1aab9..4e7b96b56c 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1,8 +1,9 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { ExecFn } from "./windows-acl.js"; import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; import { resolveBrowserControlAuth } from "../browser/control-auth.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import { formatCliCommand } from "../cli/command-format.js"; -import type { OpenClawConfig } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; @@ -12,6 +13,7 @@ import { collectChannelSecurityFindings } from "./audit-channel.js"; import { collectAttackSurfaceSummaryFindings, collectExposureMatrixFindings, + collectGatewayHttpNoAuthFindings, collectGatewayHttpSessionKeyOverrideFindings, collectHooksHardeningFindings, collectIncludeFilePermFindings, @@ -35,7 +37,6 @@ import { inspectPathPermissions, } from "./audit-fs.js"; import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "./dangerous-tools.js"; -import type { ExecFn } from "./windows-acl.js"; export type SecurityAuditSeverity = "info" | "warn" | "critical"; @@ -621,6 +622,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise