diff --git a/src/gateway/auth-resolve.ts b/src/gateway/auth-resolve.ts new file mode 100644 index 00000000000..d1b2b911712 --- /dev/null +++ b/src/gateway/auth-resolve.ts @@ -0,0 +1,127 @@ +import type { + GatewayAuthConfig, + GatewayTailscaleMode, + GatewayTrustedProxyConfig, +} from "../config/config.js"; +import { resolveSecretInputRef } from "../config/types.secrets.js"; +import { resolveGatewayCredentialsFromValues } from "./credentials.js"; + +export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; +export type ResolvedGatewayAuthModeSource = + | "override" + | "config" + | "password" + | "token" + | "default"; + +export type ResolvedGatewayAuth = { + mode: ResolvedGatewayAuthMode; + modeSource?: ResolvedGatewayAuthModeSource; + token?: string; + password?: string; + allowTailscale: boolean; + trustedProxy?: GatewayTrustedProxyConfig; +}; + +export type EffectiveSharedGatewayAuth = { + mode: "token" | "password"; + secret: string | undefined; +}; + +export function resolveGatewayAuth(params: { + authConfig?: GatewayAuthConfig | null; + authOverride?: GatewayAuthConfig | null; + env?: NodeJS.ProcessEnv; + tailscaleMode?: GatewayTailscaleMode; +}): ResolvedGatewayAuth { + const baseAuthConfig = params.authConfig ?? {}; + const authOverride = params.authOverride ?? undefined; + const authConfig: GatewayAuthConfig = { ...baseAuthConfig }; + if (authOverride) { + if (authOverride.mode !== undefined) { + authConfig.mode = authOverride.mode; + } + if (authOverride.token !== undefined) { + authConfig.token = authOverride.token; + } + if (authOverride.password !== undefined) { + authConfig.password = authOverride.password; + } + if (authOverride.allowTailscale !== undefined) { + authConfig.allowTailscale = authOverride.allowTailscale; + } + if (authOverride.rateLimit !== undefined) { + authConfig.rateLimit = authOverride.rateLimit; + } + if (authOverride.trustedProxy !== undefined) { + authConfig.trustedProxy = authOverride.trustedProxy; + } + } + const env = params.env ?? process.env; + const tokenRef = resolveSecretInputRef({ value: authConfig.token }).ref; + const passwordRef = resolveSecretInputRef({ value: authConfig.password }).ref; + const resolvedCredentials = resolveGatewayCredentialsFromValues({ + configToken: tokenRef ? undefined : authConfig.token, + configPassword: passwordRef ? undefined : authConfig.password, + env, + tokenPrecedence: "config-first", + passwordPrecedence: "config-first", // pragma: allowlist secret + }); + const token = resolvedCredentials.token; + const password = resolvedCredentials.password; + const trustedProxy = authConfig.trustedProxy; + + let mode: ResolvedGatewayAuth["mode"]; + let modeSource: ResolvedGatewayAuth["modeSource"]; + if (authOverride?.mode !== undefined) { + mode = authOverride.mode; + modeSource = "override"; + } else if (authConfig.mode) { + mode = authConfig.mode; + modeSource = "config"; + } else if (password) { + mode = "password"; + modeSource = "password"; + } else if (token) { + mode = "token"; + modeSource = "token"; + } else { + mode = "token"; + modeSource = "default"; + } + + const allowTailscale = + authConfig.allowTailscale ?? + (params.tailscaleMode === "serve" && mode !== "password" && mode !== "trusted-proxy"); + + return { + mode, + modeSource, + token, + password, + allowTailscale, + trustedProxy, + }; +} + +export function resolveEffectiveSharedGatewayAuth(params: { + authConfig?: GatewayAuthConfig | null; + authOverride?: GatewayAuthConfig | null; + env?: NodeJS.ProcessEnv; + tailscaleMode?: GatewayTailscaleMode; +}): EffectiveSharedGatewayAuth | null { + const resolvedAuth = resolveGatewayAuth(params); + if (resolvedAuth.mode === "token") { + return { + mode: "token", + secret: resolvedAuth.token, + }; + } + if (resolvedAuth.mode === "password") { + return { + mode: "password", + secret: resolvedAuth.password, + }; + } + return null; +} diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 7a6c1013492..7c4e28d919e 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -1,10 +1,5 @@ import type { IncomingMessage } from "node:http"; -import type { - GatewayAuthConfig, - GatewayTailscaleMode, - GatewayTrustedProxyConfig, -} from "../config/config.js"; -import { resolveSecretInputRef } from "../config/types.secrets.js"; +import type { GatewayAuthConfig, GatewayTrustedProxyConfig } from "../config/config.js"; import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { @@ -16,7 +11,7 @@ import { type AuthRateLimiter, type RateLimitCheckResult, } from "./auth-rate-limit.js"; -import { resolveGatewayCredentialsFromValues } from "./credentials.js"; +import { type ResolvedGatewayAuth } from "./auth-resolve.js"; import { isLoopbackAddress, resolveRequestClientIp, @@ -25,28 +20,14 @@ import { } from "./net.js"; import { checkBrowserOrigin } from "./origin-check.js"; import { withSerializedRateLimitAttempt } from "./rate-limit-attempt-serialization.js"; - -export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; -export type ResolvedGatewayAuthModeSource = - | "override" - | "config" - | "password" - | "token" - | "default"; - -export type ResolvedGatewayAuth = { - mode: ResolvedGatewayAuthMode; - modeSource?: ResolvedGatewayAuthModeSource; - token?: string; - password?: string; - allowTailscale: boolean; - trustedProxy?: GatewayTrustedProxyConfig; -}; - -export type EffectiveSharedGatewayAuth = { - mode: "token" | "password"; - secret: string | undefined; -}; +export { + resolveEffectiveSharedGatewayAuth, + resolveGatewayAuth, + type EffectiveSharedGatewayAuth, + type ResolvedGatewayAuth, + type ResolvedGatewayAuthMode, + type ResolvedGatewayAuthModeSource, +} from "./auth-resolve.js"; export type GatewayAuthResult = { ok: boolean; @@ -228,104 +209,6 @@ async function resolveVerifiedTailscaleUser(params: { }; } -export function resolveGatewayAuth(params: { - authConfig?: GatewayAuthConfig | null; - authOverride?: GatewayAuthConfig | null; - env?: NodeJS.ProcessEnv; - tailscaleMode?: GatewayTailscaleMode; -}): ResolvedGatewayAuth { - const baseAuthConfig = params.authConfig ?? {}; - const authOverride = params.authOverride ?? undefined; - const authConfig: GatewayAuthConfig = { ...baseAuthConfig }; - if (authOverride) { - if (authOverride.mode !== undefined) { - authConfig.mode = authOverride.mode; - } - if (authOverride.token !== undefined) { - authConfig.token = authOverride.token; - } - if (authOverride.password !== undefined) { - authConfig.password = authOverride.password; - } - if (authOverride.allowTailscale !== undefined) { - authConfig.allowTailscale = authOverride.allowTailscale; - } - if (authOverride.rateLimit !== undefined) { - authConfig.rateLimit = authOverride.rateLimit; - } - if (authOverride.trustedProxy !== undefined) { - authConfig.trustedProxy = authOverride.trustedProxy; - } - } - const env = params.env ?? process.env; - const tokenRef = resolveSecretInputRef({ value: authConfig.token }).ref; - const passwordRef = resolveSecretInputRef({ value: authConfig.password }).ref; - const resolvedCredentials = resolveGatewayCredentialsFromValues({ - configToken: tokenRef ? undefined : authConfig.token, - configPassword: passwordRef ? undefined : authConfig.password, - env, - tokenPrecedence: "config-first", - passwordPrecedence: "config-first", // pragma: allowlist secret - }); - const token = resolvedCredentials.token; - const password = resolvedCredentials.password; - const trustedProxy = authConfig.trustedProxy; - - let mode: ResolvedGatewayAuth["mode"]; - let modeSource: ResolvedGatewayAuth["modeSource"]; - if (authOverride?.mode !== undefined) { - mode = authOverride.mode; - modeSource = "override"; - } else if (authConfig.mode) { - mode = authConfig.mode; - modeSource = "config"; - } else if (password) { - mode = "password"; - modeSource = "password"; - } else if (token) { - mode = "token"; - modeSource = "token"; - } else { - mode = "token"; - modeSource = "default"; - } - - const allowTailscale = - authConfig.allowTailscale ?? - (params.tailscaleMode === "serve" && mode !== "password" && mode !== "trusted-proxy"); - - return { - mode, - modeSource, - token, - password, - allowTailscale, - trustedProxy, - }; -} - -export function resolveEffectiveSharedGatewayAuth(params: { - authConfig?: GatewayAuthConfig | null; - authOverride?: GatewayAuthConfig | null; - env?: NodeJS.ProcessEnv; - tailscaleMode?: GatewayTailscaleMode; -}): EffectiveSharedGatewayAuth | null { - const resolvedAuth = resolveGatewayAuth(params); - if (resolvedAuth.mode === "token") { - return { - mode: "token", - secret: resolvedAuth.token, - }; - } - if (resolvedAuth.mode === "password") { - return { - mode: "password", - secret: resolvedAuth.password, - }; - } - return null; -} - export function assertGatewayAuthConfigured( auth: ResolvedGatewayAuth, rawAuthConfig?: GatewayAuthConfig | null, diff --git a/src/security/audit-gateway-config.ts b/src/security/audit-gateway-config.ts new file mode 100644 index 00000000000..ba77235266d --- /dev/null +++ b/src/security/audit-gateway-config.ts @@ -0,0 +1,413 @@ +import { isIP } from "node:net"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { hasConfiguredSecretInput } from "../config/types.secrets.js"; +import { resolveGatewayAuth } from "../gateway/auth-resolve.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "../shared/string-coerce.js"; +import type { SecurityAuditFinding } from "./audit.types.js"; +import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "./dangerous-tools.js"; + +type CollectDangerousConfigFlags = (cfg: OpenClawConfig) => string[]; + +export type CollectGatewayConfigFindingsOptions = { + collectDangerousConfigFlags?: CollectDangerousConfigFlags; +}; + +function hasNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function collectCoreInsecureOrDangerousFlags(cfg: OpenClawConfig): string[] { + const enabledFlags: string[] = []; + if (cfg.gateway?.controlUi?.allowInsecureAuth === true) { + enabledFlags.push("gateway.controlUi.allowInsecureAuth=true"); + } + if (cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true) { + enabledFlags.push("gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true"); + } + if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) { + enabledFlags.push("gateway.controlUi.dangerouslyDisableDeviceAuth=true"); + } + if (cfg.hooks?.gmail?.allowUnsafeExternalContent === true) { + enabledFlags.push("hooks.gmail.allowUnsafeExternalContent=true"); + } + if (Array.isArray(cfg.hooks?.mappings)) { + for (const [index, mapping] of cfg.hooks.mappings.entries()) { + if (mapping?.allowUnsafeExternalContent === true) { + enabledFlags.push(`hooks.mappings[${index}].allowUnsafeExternalContent=true`); + } + } + } + if (cfg.tools?.exec?.applyPatch?.workspaceOnly === false) { + enabledFlags.push("tools.exec.applyPatch.workspaceOnly=false"); + } + return enabledFlags; +} + +export function collectGatewayConfigFindings( + cfg: OpenClawConfig, + sourceConfig: OpenClawConfig, + env: NodeJS.ProcessEnv, + options: CollectGatewayConfigFindingsOptions = {}, +): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + + const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback"; + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; + const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env }); + const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false; + const controlUiAllowedOrigins = (cfg.gateway?.controlUi?.allowedOrigins ?? []) + .map((value) => value.trim()) + .filter(Boolean); + const dangerouslyAllowHostHeaderOriginFallback = + cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true; + const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies) + ? cfg.gateway.trustedProxies + : []; + const hasToken = typeof auth.token === "string" && auth.token.trim().length > 0; + const hasPassword = typeof auth.password === "string" && auth.password.trim().length > 0; + const envTokenConfigured = hasNonEmptyString(env.OPENCLAW_GATEWAY_TOKEN); + const envPasswordConfigured = hasNonEmptyString(env.OPENCLAW_GATEWAY_PASSWORD); + const tokenConfiguredFromConfig = hasConfiguredSecretInput( + sourceConfig.gateway?.auth?.token, + sourceConfig.secrets?.defaults, + ); + const passwordConfiguredFromConfig = hasConfiguredSecretInput( + sourceConfig.gateway?.auth?.password, + sourceConfig.secrets?.defaults, + ); + const remoteTokenConfigured = hasConfiguredSecretInput( + sourceConfig.gateway?.remote?.token, + sourceConfig.secrets?.defaults, + ); + const explicitAuthMode = sourceConfig.gateway?.auth?.mode; + const tokenCanWin = + hasToken || envTokenConfigured || tokenConfiguredFromConfig || remoteTokenConfigured; + const passwordCanWin = + explicitAuthMode === "password" || + (explicitAuthMode !== "token" && + explicitAuthMode !== "none" && + explicitAuthMode !== "trusted-proxy" && + !tokenCanWin); + const tokenConfigured = tokenCanWin; + const passwordConfigured = + hasPassword || (passwordCanWin && (envPasswordConfigured || passwordConfiguredFromConfig)); + const hasSharedSecret = + explicitAuthMode === "token" + ? tokenConfigured + : explicitAuthMode === "password" + ? passwordConfigured + : explicitAuthMode === "none" || explicitAuthMode === "trusted-proxy" + ? false + : tokenConfigured || passwordConfigured; + 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. + const gatewayToolsAllowRaw = Array.isArray(cfg.gateway?.tools?.allow) + ? cfg.gateway?.tools?.allow + : []; + const gatewayToolsAllow = new Set( + gatewayToolsAllowRaw.map((v) => normalizeOptionalLowercaseString(v) ?? "").filter(Boolean), + ); + const reenabledOverHttp = DEFAULT_GATEWAY_HTTP_TOOL_DENY.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", + severity: "critical", + title: "Gateway binds beyond loopback without auth", + detail: `gateway.bind="${bind}" but no gateway.auth token/password is configured.`, + remediation: `Set gateway.auth (token recommended) or bind to loopback.`, + }); + } + + if (bind === "loopback" && controlUiEnabled && trustedProxies.length === 0) { + findings.push({ + checkId: "gateway.trusted_proxies_missing", + severity: "warn", + title: "Reverse proxy headers are not trusted", + detail: + "gateway.bind is loopback and gateway.trustedProxies is empty. " + + "If you expose the Control UI through a reverse proxy, configure trusted proxies " + + "so local-client checks cannot be spoofed.", + remediation: + "Set gateway.trustedProxies to your proxy IPs or keep the Control UI local-only.", + }); + } + + if (bind === "loopback" && controlUiEnabled && !hasGatewayAuth) { + findings.push({ + checkId: "gateway.loopback_no_auth", + severity: "critical", + title: "Gateway auth missing on loopback", + detail: + "gateway.bind is loopback but no gateway auth secret is configured. " + + "If the Control UI is exposed through a reverse proxy, unauthenticated access is possible.", + remediation: "Set gateway.auth (token recommended) or keep the Control UI local-only.", + }); + } + if ( + bind !== "loopback" && + controlUiEnabled && + controlUiAllowedOrigins.length === 0 && + !dangerouslyAllowHostHeaderOriginFallback + ) { + findings.push({ + checkId: "gateway.control_ui.allowed_origins_required", + severity: "critical", + title: "Non-loopback Control UI missing explicit allowed origins", + detail: + "Control UI is enabled on a non-loopback bind but gateway.controlUi.allowedOrigins is empty. " + + "Strict origin policy requires explicit allowed origins for non-loopback deployments.", + remediation: + "Set gateway.controlUi.allowedOrigins to full trusted origins (for example https://control.example.com). " + + "If your deployment intentionally relies on Host-header origin fallback, set gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true.", + }); + } + if (controlUiAllowedOrigins.includes("*")) { + const exposed = bind !== "loopback"; + findings.push({ + checkId: "gateway.control_ui.allowed_origins_wildcard", + severity: exposed ? "critical" : "warn", + title: "Control UI allowed origins contains wildcard", + detail: + 'gateway.controlUi.allowedOrigins includes "*" which means allow any browser origin for Control UI/WebChat requests. This disables origin allowlisting and should be treated as an intentional allow-all policy.', + remediation: + 'Replace wildcard origins with explicit trusted origins (for example https://control.example.com). Do not use "*" outside tightly controlled local testing.', + }); + } + if (dangerouslyAllowHostHeaderOriginFallback) { + const exposed = bind !== "loopback"; + findings.push({ + checkId: "gateway.control_ui.host_header_origin_fallback", + severity: exposed ? "critical" : "warn", + title: "DANGEROUS: Host-header origin fallback enabled", + detail: + "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true enables Host-header origin fallback " + + "for Control UI/WebChat websocket checks and weakens DNS rebinding protections.", + remediation: + "Disable gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback and configure explicit gateway.controlUi.allowedOrigins.", + }); + } + + if (allowRealIpFallback) { + const hasNonLoopbackTrustedProxy = trustedProxies.some( + (proxy) => !isStrictLoopbackTrustedProxyEntry(proxy), + ); + const exposed = + bind !== "loopback" || (auth.mode === "trusted-proxy" && hasNonLoopbackTrustedProxy); + 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", + severity: "critical", + title: "Tailscale Funnel exposure enabled", + detail: `gateway.tailscale.mode="funnel" exposes the Gateway publicly; keep auth strict and treat it as internet-facing.`, + remediation: `Prefer tailscale.mode="serve" (tailnet-only) or set tailscale.mode="off".`, + }); + } else if (tailscaleMode === "serve") { + findings.push({ + checkId: "gateway.tailscale_serve", + severity: "info", + title: "Tailscale Serve exposure enabled", + detail: `gateway.tailscale.mode="serve" exposes the Gateway to your tailnet (loopback behind Tailscale).`, + }); + } + + if (cfg.gateway?.controlUi?.allowInsecureAuth === true) { + findings.push({ + checkId: "gateway.control_ui.insecure_auth", + severity: "warn", + title: "Control UI insecure auth toggle enabled", + detail: + "gateway.controlUi.allowInsecureAuth=true does not bypass secure context or device identity checks; only dangerouslyDisableDeviceAuth disables Control UI device identity checks.", + remediation: "Disable it or switch to HTTPS (Tailscale Serve) or localhost.", + }); + } + + if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) { + findings.push({ + checkId: "gateway.control_ui.device_auth_disabled", + severity: "critical", + title: "DANGEROUS: Control UI device auth disabled", + detail: + "gateway.controlUi.dangerouslyDisableDeviceAuth=true disables device identity checks for the Control UI.", + remediation: "Disable it unless you are in a short-lived break-glass scenario.", + }); + } + + const enabledDangerousFlags = ( + options.collectDangerousConfigFlags ?? collectCoreInsecureOrDangerousFlags + )(cfg); + if (enabledDangerousFlags.length > 0) { + findings.push({ + checkId: "config.insecure_or_dangerous_flags", + severity: "warn", + title: "Insecure or dangerous config flags enabled", + detail: `Detected ${enabledDangerousFlags.length} enabled flag(s): ${enabledDangerousFlags.join(", ")}.`, + remediation: + "Disable these flags when not actively debugging, or keep deployment scoped to trusted/local-only networks.", + }); + } + + const token = + typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null; + if (auth.mode === "token" && token && token.length < 24) { + findings.push({ + checkId: "gateway.token_too_short", + severity: "warn", + title: "Gateway token looks short", + detail: `gateway auth token is ${token.length} chars; prefer a long random token.`, + }); + } + + if (auth.mode === "trusted-proxy") { + const trustedProxies = cfg.gateway?.trustedProxies ?? []; + const trustedProxyConfig = cfg.gateway?.auth?.trustedProxy; + + findings.push({ + checkId: "gateway.trusted_proxy_auth", + severity: "critical", + title: "Trusted-proxy auth mode enabled", + detail: + 'gateway.auth.mode="trusted-proxy" delegates authentication to a reverse proxy. ' + + "Ensure your proxy (Pomerium, Caddy, nginx) handles auth correctly and that gateway.trustedProxies " + + "only contains IPs of your actual proxy servers.", + remediation: + "Verify: (1) Your proxy terminates TLS and authenticates users. " + + "(2) gateway.trustedProxies is restricted to proxy IPs only. " + + "(3) Direct access to the Gateway port is blocked by firewall. " + + "See /gateway/trusted-proxy-auth for setup guidance.", + }); + + if (trustedProxies.length === 0) { + findings.push({ + checkId: "gateway.trusted_proxy_no_proxies", + severity: "critical", + title: "Trusted-proxy auth enabled but no trusted proxies configured", + detail: + 'gateway.auth.mode="trusted-proxy" but gateway.trustedProxies is empty. ' + + "All requests will be rejected.", + remediation: "Set gateway.trustedProxies to the IP(s) of your reverse proxy.", + }); + } + + if (!trustedProxyConfig?.userHeader) { + findings.push({ + checkId: "gateway.trusted_proxy_no_user_header", + severity: "critical", + title: "Trusted-proxy auth missing userHeader config", + detail: + 'gateway.auth.mode="trusted-proxy" but gateway.auth.trustedProxy.userHeader is not configured.', + remediation: + "Set gateway.auth.trustedProxy.userHeader to the header name your proxy uses " + + '(e.g., "x-forwarded-user", "x-pomerium-claim-email").', + }); + } + + const allowUsers = trustedProxyConfig?.allowUsers ?? []; + if (allowUsers.length === 0) { + findings.push({ + checkId: "gateway.trusted_proxy_no_allowlist", + severity: "warn", + title: "Trusted-proxy auth allows all authenticated users", + detail: + "gateway.auth.trustedProxy.allowUsers is empty, so any user authenticated by your proxy can access the Gateway.", + remediation: + "Consider setting gateway.auth.trustedProxy.allowUsers to restrict access to specific users " + + '(e.g., ["nick@example.com"]).', + }); + } + } + + if (bind !== "loopback" && auth.mode !== "trusted-proxy" && !cfg.gateway?.auth?.rateLimit) { + findings.push({ + checkId: "gateway.auth_no_rate_limit", + severity: "warn", + title: "No auth rate limiting configured", + detail: + "gateway.bind is not loopback but no gateway.auth.rateLimit is configured. " + + "Without rate limiting, brute-force auth attacks are not mitigated.", + remediation: + "Set gateway.auth.rateLimit (e.g. { maxAttempts: 10, windowMs: 60000, lockoutMs: 300000 }).", + }); + } + + return findings; +} + +// Keep this stricter than isLoopbackAddress on purpose: this check is for +// trust boundaries, so only explicit localhost proxy hops are treated as local. +function isStrictLoopbackTrustedProxyEntry(entry: string): boolean { + const candidate = entry.trim(); + if (!candidate) { + return false; + } + if (!candidate.includes("/")) { + return candidate === "127.0.0.1" || candidate.toLowerCase() === "::1"; + } + + const [rawIp, rawPrefix] = candidate.split("/", 2); + if (!rawIp || !rawPrefix) { + return false; + } + const ipVersion = isIP(rawIp.trim()); + const prefix = Number.parseInt(rawPrefix.trim(), 10); + if (!Number.isInteger(prefix)) { + return false; + } + if (ipVersion === 4) { + return rawIp.trim() === "127.0.0.1" && prefix === 32; + } + if (ipVersion === 6) { + return prefix === 128 && normalizeLowercaseStringOrEmpty(rawIp) === "::1"; + } + return false; +} diff --git a/src/security/audit-gateway-exposure.test.ts b/src/security/audit-gateway-exposure.test.ts index be3779562b1..823c631ea41 100644 --- a/src/security/audit-gateway-exposure.test.ts +++ b/src/security/audit-gateway-exposure.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { collectGatewayConfigFindings } from "./audit.js"; +import { collectGatewayConfigFindings } from "./audit-gateway-config.js"; function hasFinding( checkId: string, @@ -60,22 +60,6 @@ describe("security audit gateway exposure findings", () => { "tools.exec.applyPatch.workspaceOnly=false", ], }, - { - name: "acpx approve-all is treated as a dangerous break-glass flag", - cfg: { - plugins: { - entries: { - acpx: { - enabled: true, - config: { - permissionMode: "approve-all", - }, - }, - }, - }, - } satisfies OpenClawConfig, - expectedDangerousDetails: ["plugins.entries.acpx.config.permissionMode=approve-all"], - }, ] as const; for (const testCase of cases) { diff --git a/src/security/audit-gateway-tools-http.test.ts b/src/security/audit-gateway-tools-http.test.ts index b65cc70dddc..ba96be72966 100644 --- a/src/security/audit-gateway-tools-http.test.ts +++ b/src/security/audit-gateway-tools-http.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { collectGatewayConfigFindings } from "./audit.js"; +import { collectGatewayConfigFindings } from "./audit-gateway-config.js"; function hasFinding( findings: ReturnType, diff --git a/src/security/audit-gateway.test.ts b/src/security/audit-gateway.test.ts index 17b26569317..664fc6e4409 100644 --- a/src/security/audit-gateway.test.ts +++ b/src/security/audit-gateway.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withEnvAsync } from "../test-utils/env.js"; -import { collectGatewayConfigFindings } from "./audit.js"; +import { collectGatewayConfigFindings } from "./audit-gateway-config.js"; function hasFinding(checkId: string, findings: ReturnType) { return findings.some((finding) => finding.checkId === checkId); diff --git a/src/security/audit.ts b/src/security/audit.ts index 6ecee83ff49..c0241aceb70 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1,4 +1,3 @@ -import { isIP } from "node:net"; import path from "node:path"; import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; @@ -6,8 +5,6 @@ import type { listChannelPlugins } from "../channels/plugins/index.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; -import { hasConfiguredSecretInput } from "../config/types.secrets.js"; -import { resolveGatewayAuth } from "../gateway/auth.js"; import { type ExecApprovalsFile, loadExecApprovals } from "../infra/exec-approvals.js"; import { isInterpreterLikeAllowlistPattern } from "../infra/exec-inline-eval.js"; import { @@ -16,14 +13,10 @@ import { } from "../infra/exec-safe-bin-runtime-policy.js"; import { listRiskyConfiguredSafeBins } from "../infra/exec-safe-bin-semantics.js"; import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; -import { hasNonEmptyString } from "../infra/outbound/channel-target.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { asNullableRecord } from "../shared/record-coerce.js"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, -} from "../shared/string-coerce.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { collectDeepCodeSafetyFindings } from "./audit-deep-code-safety.js"; import { collectDeepProbeFindings } from "./audit-deep-probe-findings.js"; import { @@ -31,6 +24,7 @@ import { formatPermissionRemediation, inspectPathPermissions, } from "./audit-fs.js"; +import { collectGatewayConfigFindings as collectGatewayConfigFindingsBase } from "./audit-gateway-config.js"; import type { SecurityAuditFinding, SecurityAuditReport, @@ -38,7 +32,6 @@ import type { SecurityAuditSummary, } from "./audit.types.js"; import { collectEnabledInsecureOrDangerousFlags } from "./dangerous-config-flags.js"; -import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "./dangerous-tools.js"; import type { ExecFn } from "./windows-acl.js"; type ExecDockerRawFn = typeof import("../agents/sandbox/docker.js").execDockerRaw; @@ -320,362 +313,9 @@ export function collectGatewayConfigFindings( sourceConfig: OpenClawConfig, env: NodeJS.ProcessEnv, ): SecurityAuditFinding[] { - const findings: SecurityAuditFinding[] = []; - - const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback"; - const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; - const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env }); - const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false; - const controlUiAllowedOrigins = (cfg.gateway?.controlUi?.allowedOrigins ?? []) - .map((value) => value.trim()) - .filter(Boolean); - const dangerouslyAllowHostHeaderOriginFallback = - cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true; - const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies) - ? cfg.gateway.trustedProxies - : []; - const hasToken = typeof auth.token === "string" && auth.token.trim().length > 0; - const hasPassword = typeof auth.password === "string" && auth.password.trim().length > 0; - const envTokenConfigured = hasNonEmptyString(env.OPENCLAW_GATEWAY_TOKEN); - const envPasswordConfigured = hasNonEmptyString(env.OPENCLAW_GATEWAY_PASSWORD); - const tokenConfiguredFromConfig = hasConfiguredSecretInput( - sourceConfig.gateway?.auth?.token, - sourceConfig.secrets?.defaults, - ); - const passwordConfiguredFromConfig = hasConfiguredSecretInput( - sourceConfig.gateway?.auth?.password, - sourceConfig.secrets?.defaults, - ); - const remoteTokenConfigured = hasConfiguredSecretInput( - sourceConfig.gateway?.remote?.token, - sourceConfig.secrets?.defaults, - ); - const explicitAuthMode = sourceConfig.gateway?.auth?.mode; - const tokenCanWin = - hasToken || envTokenConfigured || tokenConfiguredFromConfig || remoteTokenConfigured; - const passwordCanWin = - explicitAuthMode === "password" || - (explicitAuthMode !== "token" && - explicitAuthMode !== "none" && - explicitAuthMode !== "trusted-proxy" && - !tokenCanWin); - const tokenConfigured = tokenCanWin; - const passwordConfigured = - hasPassword || (passwordCanWin && (envPasswordConfigured || passwordConfiguredFromConfig)); - const hasSharedSecret = - explicitAuthMode === "token" - ? tokenConfigured - : explicitAuthMode === "password" - ? passwordConfigured - : explicitAuthMode === "none" || explicitAuthMode === "trusted-proxy" - ? false - : tokenConfigured || passwordConfigured; - 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. - const gatewayToolsAllowRaw = Array.isArray(cfg.gateway?.tools?.allow) - ? cfg.gateway?.tools?.allow - : []; - const gatewayToolsAllow = new Set( - gatewayToolsAllowRaw.map((v) => normalizeOptionalLowercaseString(v) ?? "").filter(Boolean), - ); - const reenabledOverHttp = DEFAULT_GATEWAY_HTTP_TOOL_DENY.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", - severity: "critical", - title: "Gateway binds beyond loopback without auth", - detail: `gateway.bind="${bind}" but no gateway.auth token/password is configured.`, - remediation: `Set gateway.auth (token recommended) or bind to loopback.`, - }); - } - - if (bind === "loopback" && controlUiEnabled && trustedProxies.length === 0) { - findings.push({ - checkId: "gateway.trusted_proxies_missing", - severity: "warn", - title: "Reverse proxy headers are not trusted", - detail: - "gateway.bind is loopback and gateway.trustedProxies is empty. " + - "If you expose the Control UI through a reverse proxy, configure trusted proxies " + - "so local-client checks cannot be spoofed.", - remediation: - "Set gateway.trustedProxies to your proxy IPs or keep the Control UI local-only.", - }); - } - - if (bind === "loopback" && controlUiEnabled && !hasGatewayAuth) { - findings.push({ - checkId: "gateway.loopback_no_auth", - severity: "critical", - title: "Gateway auth missing on loopback", - detail: - "gateway.bind is loopback but no gateway auth secret is configured. " + - "If the Control UI is exposed through a reverse proxy, unauthenticated access is possible.", - remediation: "Set gateway.auth (token recommended) or keep the Control UI local-only.", - }); - } - if ( - bind !== "loopback" && - controlUiEnabled && - controlUiAllowedOrigins.length === 0 && - !dangerouslyAllowHostHeaderOriginFallback - ) { - findings.push({ - checkId: "gateway.control_ui.allowed_origins_required", - severity: "critical", - title: "Non-loopback Control UI missing explicit allowed origins", - detail: - "Control UI is enabled on a non-loopback bind but gateway.controlUi.allowedOrigins is empty. " + - "Strict origin policy requires explicit allowed origins for non-loopback deployments.", - remediation: - "Set gateway.controlUi.allowedOrigins to full trusted origins (for example https://control.example.com). " + - "If your deployment intentionally relies on Host-header origin fallback, set gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true.", - }); - } - if (controlUiAllowedOrigins.includes("*")) { - const exposed = bind !== "loopback"; - findings.push({ - checkId: "gateway.control_ui.allowed_origins_wildcard", - severity: exposed ? "critical" : "warn", - title: "Control UI allowed origins contains wildcard", - detail: - 'gateway.controlUi.allowedOrigins includes "*" which means allow any browser origin for Control UI/WebChat requests. This disables origin allowlisting and should be treated as an intentional allow-all policy.', - remediation: - 'Replace wildcard origins with explicit trusted origins (for example https://control.example.com). Do not use "*" outside tightly controlled local testing.', - }); - } - if (dangerouslyAllowHostHeaderOriginFallback) { - const exposed = bind !== "loopback"; - findings.push({ - checkId: "gateway.control_ui.host_header_origin_fallback", - severity: exposed ? "critical" : "warn", - title: "DANGEROUS: Host-header origin fallback enabled", - detail: - "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true enables Host-header origin fallback " + - "for Control UI/WebChat websocket checks and weakens DNS rebinding protections.", - remediation: - "Disable gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback and configure explicit gateway.controlUi.allowedOrigins.", - }); - } - - if (allowRealIpFallback) { - const hasNonLoopbackTrustedProxy = trustedProxies.some( - (proxy) => !isStrictLoopbackTrustedProxyEntry(proxy), - ); - const exposed = - bind !== "loopback" || (auth.mode === "trusted-proxy" && hasNonLoopbackTrustedProxy); - 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", - severity: "critical", - title: "Tailscale Funnel exposure enabled", - detail: `gateway.tailscale.mode="funnel" exposes the Gateway publicly; keep auth strict and treat it as internet-facing.`, - remediation: `Prefer tailscale.mode="serve" (tailnet-only) or set tailscale.mode="off".`, - }); - } else if (tailscaleMode === "serve") { - findings.push({ - checkId: "gateway.tailscale_serve", - severity: "info", - title: "Tailscale Serve exposure enabled", - detail: `gateway.tailscale.mode="serve" exposes the Gateway to your tailnet (loopback behind Tailscale).`, - }); - } - - if (cfg.gateway?.controlUi?.allowInsecureAuth === true) { - findings.push({ - checkId: "gateway.control_ui.insecure_auth", - severity: "warn", - title: "Control UI insecure auth toggle enabled", - detail: - "gateway.controlUi.allowInsecureAuth=true does not bypass secure context or device identity checks; only dangerouslyDisableDeviceAuth disables Control UI device identity checks.", - remediation: "Disable it or switch to HTTPS (Tailscale Serve) or localhost.", - }); - } - - if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) { - findings.push({ - checkId: "gateway.control_ui.device_auth_disabled", - severity: "critical", - title: "DANGEROUS: Control UI device auth disabled", - detail: - "gateway.controlUi.dangerouslyDisableDeviceAuth=true disables device identity checks for the Control UI.", - remediation: "Disable it unless you are in a short-lived break-glass scenario.", - }); - } - - const enabledDangerousFlags = collectEnabledInsecureOrDangerousFlags(cfg); - if (enabledDangerousFlags.length > 0) { - findings.push({ - checkId: "config.insecure_or_dangerous_flags", - severity: "warn", - title: "Insecure or dangerous config flags enabled", - detail: `Detected ${enabledDangerousFlags.length} enabled flag(s): ${enabledDangerousFlags.join(", ")}.`, - remediation: - "Disable these flags when not actively debugging, or keep deployment scoped to trusted/local-only networks.", - }); - } - - const token = - typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null; - if (auth.mode === "token" && token && token.length < 24) { - findings.push({ - checkId: "gateway.token_too_short", - severity: "warn", - title: "Gateway token looks short", - detail: `gateway auth token is ${token.length} chars; prefer a long random token.`, - }); - } - - if (auth.mode === "trusted-proxy") { - const trustedProxies = cfg.gateway?.trustedProxies ?? []; - const trustedProxyConfig = cfg.gateway?.auth?.trustedProxy; - - findings.push({ - checkId: "gateway.trusted_proxy_auth", - severity: "critical", - title: "Trusted-proxy auth mode enabled", - detail: - 'gateway.auth.mode="trusted-proxy" delegates authentication to a reverse proxy. ' + - "Ensure your proxy (Pomerium, Caddy, nginx) handles auth correctly and that gateway.trustedProxies " + - "only contains IPs of your actual proxy servers.", - remediation: - "Verify: (1) Your proxy terminates TLS and authenticates users. " + - "(2) gateway.trustedProxies is restricted to proxy IPs only. " + - "(3) Direct access to the Gateway port is blocked by firewall. " + - "See /gateway/trusted-proxy-auth for setup guidance.", - }); - - if (trustedProxies.length === 0) { - findings.push({ - checkId: "gateway.trusted_proxy_no_proxies", - severity: "critical", - title: "Trusted-proxy auth enabled but no trusted proxies configured", - detail: - 'gateway.auth.mode="trusted-proxy" but gateway.trustedProxies is empty. ' + - "All requests will be rejected.", - remediation: "Set gateway.trustedProxies to the IP(s) of your reverse proxy.", - }); - } - - if (!trustedProxyConfig?.userHeader) { - findings.push({ - checkId: "gateway.trusted_proxy_no_user_header", - severity: "critical", - title: "Trusted-proxy auth missing userHeader config", - detail: - 'gateway.auth.mode="trusted-proxy" but gateway.auth.trustedProxy.userHeader is not configured.', - remediation: - "Set gateway.auth.trustedProxy.userHeader to the header name your proxy uses " + - '(e.g., "x-forwarded-user", "x-pomerium-claim-email").', - }); - } - - const allowUsers = trustedProxyConfig?.allowUsers ?? []; - if (allowUsers.length === 0) { - findings.push({ - checkId: "gateway.trusted_proxy_no_allowlist", - severity: "warn", - title: "Trusted-proxy auth allows all authenticated users", - detail: - "gateway.auth.trustedProxy.allowUsers is empty, so any user authenticated by your proxy can access the Gateway.", - remediation: - "Consider setting gateway.auth.trustedProxy.allowUsers to restrict access to specific users " + - '(e.g., ["nick@example.com"]).', - }); - } - } - - if (bind !== "loopback" && auth.mode !== "trusted-proxy" && !cfg.gateway?.auth?.rateLimit) { - findings.push({ - checkId: "gateway.auth_no_rate_limit", - severity: "warn", - title: "No auth rate limiting configured", - detail: - "gateway.bind is not loopback but no gateway.auth.rateLimit is configured. " + - "Without rate limiting, brute-force auth attacks are not mitigated.", - remediation: - "Set gateway.auth.rateLimit (e.g. { maxAttempts: 10, windowMs: 60000, lockoutMs: 300000 }).", - }); - } - - return findings; -} - -// Keep this stricter than isLoopbackAddress on purpose: this check is for -// trust boundaries, so only explicit localhost proxy hops are treated as local. -function isStrictLoopbackTrustedProxyEntry(entry: string): boolean { - const candidate = entry.trim(); - if (!candidate) { - return false; - } - if (!candidate.includes("/")) { - return candidate === "127.0.0.1" || candidate.toLowerCase() === "::1"; - } - - const [rawIp, rawPrefix] = candidate.split("/", 2); - if (!rawIp || !rawPrefix) { - return false; - } - const ipVersion = isIP(rawIp.trim()); - const prefix = Number.parseInt(rawPrefix.trim(), 10); - if (!Number.isInteger(prefix)) { - return false; - } - if (ipVersion === 4) { - return rawIp.trim() === "127.0.0.1" && prefix === 32; - } - if (ipVersion === 6) { - return prefix === 128 && normalizeLowercaseStringOrEmpty(rawIp) === "::1"; - } - return false; + return collectGatewayConfigFindingsBase(cfg, sourceConfig, env, { + collectDangerousConfigFlags: collectEnabledInsecureOrDangerousFlags, + }); } async function collectPluginSecurityAuditFindings(