diff --git a/docs/cli/security.md b/docs/cli/security.md index 964e33824e2..e8b76c8e3e7 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -28,6 +28,7 @@ This is for cooperative/shared inbox hardening. A single Gateway shared by mutua 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 `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy. +It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records). It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`. It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`. It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 6d720b7226d..f5e46dce43c 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -117,31 +117,33 @@ When the audit prints findings, treat this as a priority order: High-signal `checkId` values you will most likely see in real deployments (not exhaustive): -| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix | -| -------------------------------------------------- | ------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------- | -| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes | -| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes | -| `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.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no | -| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no | -| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no | -| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no | -| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no | -| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no | -| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no | -| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no | -| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes | -| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no | -| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no | -| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no | -| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no | -| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no | -| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no | -| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no | +| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix | +| -------------------------------------------------- | ------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------- | +| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes | +| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes | +| `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.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no | +| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no | +| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no | +| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no | +| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no | +| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no | +| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no | +| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no | +| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no | +| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no | +| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes | +| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no | +| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no | +| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no | +| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no | +| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no | +| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no | +| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no | ## Control UI over HTTP diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 5eb4651f7f5..0edb5d63500 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -973,6 +973,102 @@ describe("security audit", () => { expect(finding?.detail).toContain("tools.exec.applyPatch.workspaceOnly=false"); }); + it("scores X-Real-IP fallback risk by gateway exposure", async () => { + const cases: Array<{ + name: string; + cfg: OpenClawConfig; + expectedSeverity: "warn" | "critical"; + }> = [ + { + name: "loopback gateway", + cfg: { + gateway: { + bind: "loopback", + allowRealIpFallback: true, + trustedProxies: ["127.0.0.1"], + auth: { + mode: "token", + token: "very-long-token-1234567890", + }, + }, + }, + expectedSeverity: "warn", + }, + { + name: "lan gateway", + cfg: { + gateway: { + bind: "lan", + allowRealIpFallback: true, + trustedProxies: ["10.0.0.1"], + auth: { + mode: "token", + token: "very-long-token-1234567890", + }, + }, + }, + expectedSeverity: "critical", + }, + ]; + + for (const testCase of cases) { + const res = await audit(testCase.cfg); + expect( + hasFinding(res, "gateway.real_ip_fallback_enabled", testCase.expectedSeverity), + testCase.name, + ).toBe(true); + } + }); + + it("scores mDNS full mode risk by gateway bind mode", async () => { + const cases: Array<{ + name: string; + cfg: OpenClawConfig; + expectedSeverity: "warn" | "critical"; + }> = [ + { + name: "loopback gateway with full mDNS", + cfg: { + gateway: { + bind: "loopback", + auth: { + mode: "token", + token: "very-long-token-1234567890", + }, + }, + discovery: { + mdns: { mode: "full" }, + }, + }, + expectedSeverity: "warn", + }, + { + name: "lan gateway with full mDNS", + cfg: { + gateway: { + bind: "lan", + auth: { + mode: "token", + token: "very-long-token-1234567890", + }, + }, + discovery: { + mdns: { mode: "full" }, + }, + }, + expectedSeverity: "critical", + }, + ]; + + for (const testCase of cases) { + const res = await audit(testCase.cfg); + expect( + hasFinding(res, "discovery.mdns_full_mode", testCase.expectedSeverity), + testCase.name, + ).toBe(true); + } + }); + it("evaluates trusted-proxy auth guardrails", async () => { const cases: Array<{ name: string; diff --git a/src/security/audit.ts b/src/security/audit.ts index a1a95df601d..d47f3ef23f4 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -270,6 +270,8 @@ function collectGatewayConfigFindings( (auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword); const hasTailscaleAuth = auth.allowTailscale && tailscaleMode === "serve"; const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth; + const allowRealIpFallback = cfg.gateway?.allowRealIpFallback === true; + const mdnsMode = cfg.discovery?.mdns?.mode ?? "minimal"; // 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. @@ -334,6 +336,35 @@ function collectGatewayConfigFindings( }); } + if (allowRealIpFallback) { + const exposed = bind !== "loopback" || auth.mode === "trusted-proxy"; + findings.push({ + checkId: "gateway.real_ip_fallback_enabled", + severity: exposed ? "critical" : "warn", + title: "X-Real-IP fallback is enabled", + detail: + "gateway.allowRealIpFallback=true trusts X-Real-IP when trusted proxies omit X-Forwarded-For. " + + "Misconfigured proxies that forward client-supplied X-Real-IP can spoof source IP and local-client checks.", + remediation: + "Keep gateway.allowRealIpFallback=false (default). Only enable this when your trusted proxy " + + "always overwrites X-Real-IP and cannot provide X-Forwarded-For.", + }); + } + + if (mdnsMode === "full") { + const exposed = bind !== "loopback"; + findings.push({ + checkId: "discovery.mdns_full_mode", + severity: exposed ? "critical" : "warn", + title: "mDNS full mode can leak host metadata", + detail: + 'discovery.mdns.mode="full" publishes cliPath/sshPort in local-network TXT records. ' + + "This can reveal usernames, filesystem layout, and management ports.", + remediation: + 'Prefer discovery.mdns.mode="minimal" (recommended) or "off", especially when gateway.bind is not loopback.', + }); + } + if (tailscaleMode === "funnel") { findings.push({ checkId: "gateway.tailscale_funnel",