From 1fb52b4d7bceae8f42d47c2a2027f01e802dffbb Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Sat, 14 Feb 2026 06:32:17 -0500 Subject: [PATCH] feat(gateway): add trusted-proxy auth mode (#15940) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 279d4b304f83186fda44dfe63a729406a835dafa Co-authored-by: nickytonline <833231+nickytonline@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Reviewed-by: @steipete --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 6 +- .../OpenClawProtocol/GatewayModels.swift | 6 +- docs/gateway/trusted-proxy-auth.md | 267 +++++++++++++++++ src/browser/control-auth.test.ts | 90 ++++++ src/browser/control-auth.ts | 7 + src/cli/gateway-cli/run.ts | 2 +- .../configure.gateway-auth.e2e.test.ts | 90 ++++++ src/commands/configure.gateway-auth.ts | 20 +- src/commands/configure.gateway.e2e.test.ts | 95 ++++++ src/commands/configure.gateway.ts | 104 ++++++- src/config/types.gateway.ts | 32 +- src/config/zod-schema.ts | 21 +- src/gateway/auth.test.ts | 281 +++++++++++++++++- src/gateway/auth.ts | 116 +++++++- src/gateway/net.test.ts | 98 ++++++ src/gateway/net.ts | 55 +++- src/gateway/protocol/schema/snapshot.ts | 8 + src/gateway/server-runtime-config.test.ts | 119 ++++++++ src/gateway/server-runtime-config.ts | 17 +- src/gateway/server/health-state.ts | 3 + src/security/audit-extra.sync.ts | 41 ++- src/security/audit-extra.ts | 1 + src/security/audit.test.ts | 170 ++++++++++- src/security/audit.ts | 77 +++-- ui/src/ui/app-gateway.node.test.ts | 146 +++++++++ ui/src/ui/app-gateway.ts | 24 +- ui/src/ui/views/overview.ts | 62 ++-- 28 files changed, 1867 insertions(+), 92 deletions(-) create mode 100644 docs/gateway/trusted-proxy-auth.md create mode 100644 src/browser/control-auth.test.ts create mode 100644 src/gateway/server-runtime-config.test.ts create mode 100644 ui/src/ui/app-gateway.node.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cecc386b9c..fcb9ef8727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline. - Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale. - BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y. - Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 241dc58fa0..a134b4fd5b 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -295,6 +295,7 @@ public struct Snapshot: Codable, Sendable { public let configpath: String? public let statedir: String? public let sessiondefaults: [String: AnyCodable]? + public let authmode: AnyCodable? public init( presence: [PresenceEntry], @@ -303,7 +304,8 @@ public struct Snapshot: Codable, Sendable { uptimems: Int, configpath: String?, statedir: String?, - sessiondefaults: [String: AnyCodable]? + sessiondefaults: [String: AnyCodable]?, + authmode: AnyCodable? ) { self.presence = presence self.health = health @@ -312,6 +314,7 @@ public struct Snapshot: Codable, Sendable { self.configpath = configpath self.statedir = statedir self.sessiondefaults = sessiondefaults + self.authmode = authmode } private enum CodingKeys: String, CodingKey { case presence @@ -321,6 +324,7 @@ public struct Snapshot: Codable, Sendable { case configpath = "configPath" case statedir = "stateDir" case sessiondefaults = "sessionDefaults" + case authmode = "authMode" } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 241dc58fa0..a134b4fd5b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -295,6 +295,7 @@ public struct Snapshot: Codable, Sendable { public let configpath: String? public let statedir: String? public let sessiondefaults: [String: AnyCodable]? + public let authmode: AnyCodable? public init( presence: [PresenceEntry], @@ -303,7 +304,8 @@ public struct Snapshot: Codable, Sendable { uptimems: Int, configpath: String?, statedir: String?, - sessiondefaults: [String: AnyCodable]? + sessiondefaults: [String: AnyCodable]?, + authmode: AnyCodable? ) { self.presence = presence self.health = health @@ -312,6 +314,7 @@ public struct Snapshot: Codable, Sendable { self.configpath = configpath self.statedir = statedir self.sessiondefaults = sessiondefaults + self.authmode = authmode } private enum CodingKeys: String, CodingKey { case presence @@ -321,6 +324,7 @@ public struct Snapshot: Codable, Sendable { case configpath = "configPath" case statedir = "stateDir" case sessiondefaults = "sessionDefaults" + case authmode = "authMode" } } diff --git a/docs/gateway/trusted-proxy-auth.md b/docs/gateway/trusted-proxy-auth.md new file mode 100644 index 0000000000..018af75974 --- /dev/null +++ b/docs/gateway/trusted-proxy-auth.md @@ -0,0 +1,267 @@ +--- +summary: "Delegate gateway authentication to a trusted reverse proxy (Pomerium, Caddy, nginx + OAuth)" +read_when: + - Running OpenClaw behind an identity-aware proxy + - Setting up Pomerium, Caddy, or nginx with OAuth in front of OpenClaw + - Fixing WebSocket 1008 unauthorized errors with reverse proxy setups +--- + +# Trusted Proxy Auth + +> ⚠️ **Security-sensitive feature.** This mode delegates authentication entirely to your reverse proxy. Misconfiguration can expose your Gateway to unauthorized access. Read this page carefully before enabling. + +## When to Use + +Use `trusted-proxy` auth mode when: + +- You run OpenClaw behind an **identity-aware proxy** (Pomerium, Caddy + OAuth, nginx + oauth2-proxy, Traefik + forward auth) +- Your proxy handles all authentication and passes user identity via headers +- You're in a Kubernetes or container environment where the proxy is the only path to the Gateway +- You're hitting WebSocket `1008 unauthorized` errors because browsers can't pass tokens in WS payloads + +## When NOT to Use + +- If your proxy doesn't authenticate users (just a TLS terminator or load balancer) +- If there's any path to the Gateway that bypasses the proxy (firewall holes, internal network access) +- If you're unsure whether your proxy correctly strips/overwrites forwarded headers +- If you only need personal single-user access (consider Tailscale Serve + loopback for simpler setup) + +## How It Works + +1. Your reverse proxy authenticates users (OAuth, OIDC, SAML, etc.) +2. Proxy adds a header with the authenticated user identity (e.g., `x-forwarded-user: nick@example.com`) +3. OpenClaw checks that the request came from a **trusted proxy IP** (configured in `gateway.trustedProxies`) +4. OpenClaw extracts the user identity from the configured header +5. If everything checks out, the request is authorized + +## Configuration + +```json5 +{ + gateway: { + // Must bind to network interface (not loopback) + bind: "lan", + + // CRITICAL: Only add your proxy's IP(s) here + trustedProxies: ["10.0.0.1", "172.17.0.1"], + + auth: { + mode: "trusted-proxy", + trustedProxy: { + // Header containing authenticated user identity (required) + userHeader: "x-forwarded-user", + + // Optional: headers that MUST be present (proxy verification) + requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"], + + // Optional: restrict to specific users (empty = allow all) + allowUsers: ["nick@example.com", "admin@company.org"], + }, + }, + }, +} +``` + +### Configuration Reference + +| Field | Required | Description | +| ------------------------------------------- | -------- | --------------------------------------------------------------------------- | +| `gateway.trustedProxies` | Yes | Array of proxy IP addresses to trust. Requests from other IPs are rejected. | +| `gateway.auth.mode` | Yes | Must be `"trusted-proxy"` | +| `gateway.auth.trustedProxy.userHeader` | Yes | Header name containing the authenticated user identity | +| `gateway.auth.trustedProxy.requiredHeaders` | No | Additional headers that must be present for the request to be trusted | +| `gateway.auth.trustedProxy.allowUsers` | No | Allowlist of user identities. Empty means allow all authenticated users. | + +## Proxy Setup Examples + +### Pomerium + +Pomerium passes identity in `x-pomerium-claim-email` (or other claim headers) and a JWT in `x-pomerium-jwt-assertion`. + +```json5 +{ + gateway: { + bind: "lan", + trustedProxies: ["10.0.0.1"], // Pomerium's IP + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-pomerium-claim-email", + requiredHeaders: ["x-pomerium-jwt-assertion"], + }, + }, + }, +} +``` + +Pomerium config snippet: + +```yaml +routes: + - from: https://openclaw.example.com + to: http://openclaw-gateway:18789 + policy: + - allow: + or: + - email: + is: nick@example.com + pass_identity_headers: true +``` + +### Caddy with OAuth + +Caddy with the `caddy-security` plugin can authenticate users and pass identity headers. + +```json5 +{ + gateway: { + bind: "lan", + trustedProxies: ["127.0.0.1"], // Caddy's IP (if on same host) + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + }, +} +``` + +Caddyfile snippet: + +``` +openclaw.example.com { + authenticate with oauth2_provider + authorize with policy1 + + reverse_proxy openclaw:18789 { + header_up X-Forwarded-User {http.auth.user.email} + } +} +``` + +### nginx + oauth2-proxy + +oauth2-proxy authenticates users and passes identity in `x-auth-request-email`. + +```json5 +{ + gateway: { + bind: "lan", + trustedProxies: ["10.0.0.1"], // nginx/oauth2-proxy IP + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-auth-request-email", + }, + }, + }, +} +``` + +nginx config snippet: + +```nginx +location / { + auth_request /oauth2/auth; + auth_request_set $user $upstream_http_x_auth_request_email; + + proxy_pass http://openclaw:18789; + proxy_set_header X-Auth-Request-Email $user; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; +} +``` + +### Traefik with Forward Auth + +```json5 +{ + gateway: { + bind: "lan", + trustedProxies: ["172.17.0.1"], // Traefik container IP + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + }, +} +``` + +## Security Checklist + +Before enabling trusted-proxy auth, verify: + +- [ ] **Proxy is the only path**: The Gateway port is firewalled from everything except your proxy +- [ ] **trustedProxies is minimal**: Only your actual proxy IPs, not entire subnets +- [ ] **Proxy strips headers**: Your proxy overwrites (not appends) `x-forwarded-*` headers from clients +- [ ] **TLS termination**: Your proxy handles TLS; users connect via HTTPS +- [ ] **allowUsers is set** (recommended): Restrict to known users rather than allowing anyone authenticated + +## Security Audit + +`openclaw security audit` will flag trusted-proxy auth with a **critical** severity finding. This is intentional — it's a reminder that you're delegating security to your proxy setup. + +The audit checks for: + +- Missing `trustedProxies` configuration +- Missing `userHeader` configuration +- Empty `allowUsers` (allows any authenticated user) + +## Troubleshooting + +### "trusted_proxy_untrusted_source" + +The request didn't come from an IP in `gateway.trustedProxies`. Check: + +- Is the proxy IP correct? (Docker container IPs can change) +- Is there a load balancer in front of your proxy? +- Use `docker inspect` or `kubectl get pods -o wide` to find actual IPs + +### "trusted_proxy_user_missing" + +The user header was empty or missing. Check: + +- Is your proxy configured to pass identity headers? +- Is the header name correct? (case-insensitive, but spelling matters) +- Is the user actually authenticated at the proxy? + +### "trusted*proxy_missing_header*\*" + +A required header wasn't present. Check: + +- Your proxy configuration for those specific headers +- Whether headers are being stripped somewhere in the chain + +### "trusted_proxy_user_not_allowed" + +The user is authenticated but not in `allowUsers`. Either add them or remove the allowlist. + +### WebSocket Still Failing + +Make sure your proxy: + +- Supports WebSocket upgrades (`Upgrade: websocket`, `Connection: upgrade`) +- Passes the identity headers on WebSocket upgrade requests (not just HTTP) +- Doesn't have a separate auth path for WebSocket connections + +## Migration from Token Auth + +If you're moving from token auth to trusted-proxy: + +1. Configure your proxy to authenticate users and pass headers +2. Test the proxy setup independently (curl with headers) +3. Update OpenClaw config with trusted-proxy auth +4. Restart the Gateway +5. Test WebSocket connections from the Control UI +6. Run `openclaw security audit` and review findings + +## Related + +- [Security](/gateway/security) — full security guide +- [Configuration](/gateway/configuration) — config reference +- [Remote Access](/gateway/remote) — other remote access patterns +- [Tailscale](/gateway/tailscale) — simpler alternative for tailnet-only access diff --git a/src/browser/control-auth.test.ts b/src/browser/control-auth.test.ts new file mode 100644 index 0000000000..817503fb38 --- /dev/null +++ b/src/browser/control-auth.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.js"; +import { ensureBrowserControlAuth } from "./control-auth.js"; + +describe("ensureBrowserControlAuth", () => { + describe("trusted-proxy mode", () => { + it("should not auto-generate token when auth mode is trusted-proxy", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: ["192.168.1.1"], + }, + }; + + const result = await ensureBrowserControlAuth({ + cfg, + env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" }, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.auth.token).toBeUndefined(); + expect(result.auth.password).toBeUndefined(); + }); + }); + + describe("password mode", () => { + it("should not auto-generate token when auth mode is password (even if password not set)", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "password", + }, + }, + }; + + const result = await ensureBrowserControlAuth({ + cfg, + env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" }, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.auth.token).toBeUndefined(); + expect(result.auth.password).toBeUndefined(); + }); + }); + + describe("token mode", () => { + it("should return existing token if configured", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "token", + token: "existing-token-123", + }, + }, + }; + + const result = await ensureBrowserControlAuth({ + cfg, + env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" }, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.auth.token).toBe("existing-token-123"); + }); + + it("should skip auto-generation in test environment", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "token", + }, + }, + }; + + const result = await ensureBrowserControlAuth({ + cfg, + env: { NODE_ENV: "test" }, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.auth.token).toBeUndefined(); + }); + }); +}); diff --git a/src/browser/control-auth.ts b/src/browser/control-auth.ts index 8c828bcaad..0fa25ab86f 100644 --- a/src/browser/control-auth.ts +++ b/src/browser/control-auth.ts @@ -58,6 +58,10 @@ export async function ensureBrowserControlAuth(params: { return { auth }; } + if (params.cfg.gateway?.auth?.mode === "trusted-proxy") { + return { auth }; + } + // Re-read latest config to avoid racing with concurrent config writers. const latestCfg = loadConfig(); const latestAuth = resolveBrowserControlAuth(latestCfg, env); @@ -67,6 +71,9 @@ export async function ensureBrowserControlAuth(params: { if (latestCfg.gateway?.auth?.mode === "password") { return { auth: latestAuth }; } + if (latestCfg.gateway?.auth?.mode === "trusted-proxy") { + return { auth: latestAuth }; + } const generatedToken = crypto.randomBytes(24).toString("hex"); const nextCfg: OpenClawConfig = { diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 2845197efe..6c0d2277f4 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -247,7 +247,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) { defaultRuntime.exit(1); return; } - if (bind !== "loopback" && !hasSharedSecret) { + if (bind !== "loopback" && !hasSharedSecret && resolvedAuthMode !== "trusted-proxy") { defaultRuntime.error( [ `Refusing to bind gateway to ${bind} without auth.`, diff --git a/src/commands/configure.gateway-auth.e2e.test.ts b/src/commands/configure.gateway-auth.e2e.test.ts index ff9e32c31c..6fd2b32991 100644 --- a/src/commands/configure.gateway-auth.e2e.test.ts +++ b/src/commands/configure.gateway-auth.e2e.test.ts @@ -117,4 +117,94 @@ describe("buildGatewayAuthConfig", () => { expect(typeof result?.token).toBe("string"); expect(result?.token?.length).toBeGreaterThan(0); }); + + it("builds trusted-proxy config with all options", () => { + const result = buildGatewayAuthConfig({ + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"], + allowUsers: ["nick@example.com", "admin@company.com"], + }, + }); + + expect(result).toEqual({ + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"], + allowUsers: ["nick@example.com", "admin@company.com"], + }, + }); + }); + + it("builds trusted-proxy config with only userHeader", () => { + const result = buildGatewayAuthConfig({ + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-remote-user", + }, + }); + + expect(result).toEqual({ + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-remote-user", + }, + }); + }); + + it("preserves allowTailscale when switching to trusted-proxy", () => { + const result = buildGatewayAuthConfig({ + existing: { + mode: "token", + token: "abc", + allowTailscale: true, + }, + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }); + + expect(result).toEqual({ + mode: "trusted-proxy", + allowTailscale: true, + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }); + }); + + it("throws error when trusted-proxy mode lacks trustedProxy config", () => { + expect(() => { + buildGatewayAuthConfig({ + mode: "trusted-proxy", + // missing trustedProxy + }); + }).toThrow("trustedProxy config is required when mode is trusted-proxy"); + }); + + it("drops token and password when switching to trusted-proxy", () => { + const result = buildGatewayAuthConfig({ + existing: { + mode: "token", + token: "abc", + password: "secret", + }, + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }); + + expect(result).toEqual({ + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }); + expect(result).not.toHaveProperty("token"); + expect(result).not.toHaveProperty("password"); + }); }); diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index d726040120..f46515001c 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -14,7 +14,7 @@ import { import { promptCustomApiConfig } from "./onboard-custom.js"; import { randomToken } from "./onboard-helpers.js"; -type GatewayAuthChoice = "token" | "password"; +type GatewayAuthChoice = "token" | "password" | "trusted-proxy"; /** Reject undefined, empty, and common JS string-coercion artifacts for token auth. */ function sanitizeTokenValue(value: string | undefined): string | undefined { @@ -40,6 +40,11 @@ export function buildGatewayAuthConfig(params: { mode: GatewayAuthChoice; token?: string; password?: string; + trustedProxy?: { + userHeader: string; + requiredHeaders?: string[]; + allowUsers?: string[]; + }; }): GatewayAuthConfig | undefined { const allowTailscale = params.existing?.allowTailscale; const base: GatewayAuthConfig = {}; @@ -52,8 +57,17 @@ export function buildGatewayAuthConfig(params: { const token = sanitizeTokenValue(params.token) ?? randomToken(); return { ...base, mode: "token", token }; } - const password = params.password?.trim(); - return { ...base, mode: "password", ...(password && { password }) }; + if (params.mode === "password") { + const password = params.password?.trim(); + return { ...base, mode: "password", ...(password && { password }) }; + } + if (params.mode === "trusted-proxy") { + if (!params.trustedProxy) { + throw new Error("trustedProxy config is required when mode is trusted-proxy"); + } + return { ...base, mode: "trusted-proxy", trustedProxy: params.trustedProxy }; + } + return base; } export async function promptAuthConfig( diff --git a/src/commands/configure.gateway.e2e.test.ts b/src/commands/configure.gateway.e2e.test.ts index 94388a5097..092ecd3d40 100644 --- a/src/commands/configure.gateway.e2e.test.ts +++ b/src/commands/configure.gateway.e2e.test.ts @@ -97,4 +97,99 @@ describe("promptGatewayConfig", () => { expect(call?.password).not.toBe("undefined"); expect(call?.password).toBe(""); }); + + it("prompts for trusted-proxy configuration when trusted-proxy mode selected", async () => { + vi.clearAllMocks(); + mocks.resolveGatewayPort.mockReturnValue(18789); + // Flow: loopback bind → trusted-proxy auth → tailscale off + const selectQueue = ["loopback", "trusted-proxy", "off"]; + mocks.select.mockImplementation(async () => selectQueue.shift()); + // Port prompt, userHeader, requiredHeaders, allowUsers, trustedProxies + const textQueue = [ + "18789", + "x-forwarded-user", + "x-forwarded-proto,x-forwarded-host", + "nick@example.com", + "10.0.1.10,192.168.1.5", + ]; + mocks.text.mockImplementation(async () => textQueue.shift()); + mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({ + mode, + trustedProxy, + })); + + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const result = await promptGatewayConfig({}, runtime); + const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0]; + + expect(call?.mode).toBe("trusted-proxy"); + expect(call?.trustedProxy).toEqual({ + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"], + allowUsers: ["nick@example.com"], + }); + expect(result.config.gateway?.bind).toBe("lan"); + expect(result.config.gateway?.trustedProxies).toEqual(["10.0.1.10", "192.168.1.5"]); + }); + + it("handles trusted-proxy with no optional fields", async () => { + vi.clearAllMocks(); + mocks.resolveGatewayPort.mockReturnValue(18789); + const selectQueue = ["loopback", "trusted-proxy", "off"]; + mocks.select.mockImplementation(async () => selectQueue.shift()); + // Port prompt, userHeader (only required), empty requiredHeaders, empty allowUsers, trustedProxies + const textQueue = ["18789", "x-remote-user", "", "", "10.0.0.1"]; + mocks.text.mockImplementation(async () => textQueue.shift()); + mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({ + mode, + trustedProxy, + })); + + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const result = await promptGatewayConfig({}, runtime); + const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0]; + + expect(call?.mode).toBe("trusted-proxy"); + expect(call?.trustedProxy).toEqual({ + userHeader: "x-remote-user", + // requiredHeaders and allowUsers should be undefined when empty + }); + expect(result.config.gateway?.bind).toBe("lan"); + expect(result.config.gateway?.trustedProxies).toEqual(["10.0.0.1"]); + }); + + it("forces tailscale off when trusted-proxy is selected", async () => { + vi.clearAllMocks(); + mocks.resolveGatewayPort.mockReturnValue(18789); + const selectQueue = ["loopback", "trusted-proxy", "serve"]; + mocks.select.mockImplementation(async () => selectQueue.shift()); + const textQueue = ["18789", "x-forwarded-user", "", "", "10.0.0.1"]; + mocks.text.mockImplementation(async () => textQueue.shift()); + mocks.confirm.mockResolvedValue(true); + mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({ + mode, + trustedProxy, + })); + + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const result = await promptGatewayConfig({}, runtime); + expect(result.config.gateway?.bind).toBe("lan"); + expect(result.config.gateway?.tailscale?.mode).toBe("off"); + expect(result.config.gateway?.tailscale?.resetOnExit).toBe(false); + }); }); diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts index 5e4f279b74..162e1a0cf7 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -12,7 +12,7 @@ import { validateGatewayPasswordInput, } from "./onboard-helpers.js"; -type GatewayAuthChoice = "token" | "password"; +type GatewayAuthChoice = "token" | "password" | "trusted-proxy"; export async function promptGatewayConfig( cfg: OpenClawConfig, @@ -103,13 +103,18 @@ export async function promptGatewayConfig( options: [ { value: "token", label: "Token", hint: "Recommended default" }, { value: "password", label: "Password" }, + { + value: "trusted-proxy", + label: "Trusted Proxy", + hint: "Behind reverse proxy (Pomerium, Caddy, Traefik, etc.)", + }, ], initialValue: "token", }), runtime, ) as GatewayAuthChoice; - const tailscaleMode = guardCancel( + let tailscaleMode = guardCancel( await select({ message: "Tailscale exposure", options: [ @@ -175,8 +180,25 @@ export async function promptGatewayConfig( authMode = "password"; } + if (authMode === "trusted-proxy" && bind === "loopback") { + note("Trusted proxy auth requires network bind. Adjusting bind to lan.", "Note"); + bind = "lan"; + } + if (authMode === "trusted-proxy" && tailscaleMode !== "off") { + note( + "Trusted proxy auth is incompatible with Tailscale serve/funnel. Disabling Tailscale.", + "Note", + ); + tailscaleMode = "off"; + tailscaleResetOnExit = false; + } + let gatewayToken: string | undefined; let gatewayPassword: string | undefined; + let trustedProxyConfig: + | { userHeader: string; requiredHeaders?: string[]; allowUsers?: string[] } + | undefined; + let trustedProxies: string[] | undefined; let next = cfg; if (authMode === "token") { @@ -201,11 +223,88 @@ export async function promptGatewayConfig( gatewayPassword = String(password ?? "").trim(); } + if (authMode === "trusted-proxy") { + note( + [ + "Trusted proxy mode: OpenClaw trusts user identity from a reverse proxy.", + "The proxy must authenticate users and pass identity via headers.", + "Only requests from specified proxy IPs will be trusted.", + "", + "Common use cases: Pomerium, Caddy + OAuth, Traefik + forward auth", + "Docs: https://docs.openclaw.ai/gateway/trusted-proxy-auth", + ].join("\n"), + "Trusted Proxy Auth", + ); + + const userHeader = guardCancel( + await text({ + message: "Header containing user identity", + placeholder: "x-forwarded-user", + initialValue: "x-forwarded-user", + validate: (value) => (value?.trim() ? undefined : "User header is required"), + }), + runtime, + ); + + const requiredHeadersRaw = guardCancel( + await text({ + message: "Required headers (comma-separated, optional)", + placeholder: "x-forwarded-proto,x-forwarded-host", + }), + runtime, + ); + const requiredHeaders = requiredHeadersRaw + ? String(requiredHeadersRaw) + .split(",") + .map((h) => h.trim()) + .filter(Boolean) + : []; + + const allowUsersRaw = guardCancel( + await text({ + message: "Allowed users (comma-separated, blank = all authenticated users)", + placeholder: "nick@example.com,admin@company.com", + }), + runtime, + ); + const allowUsers = allowUsersRaw + ? String(allowUsersRaw) + .split(",") + .map((u) => u.trim()) + .filter(Boolean) + : []; + + const trustedProxiesRaw = guardCancel( + await text({ + message: "Trusted proxy IPs (comma-separated)", + placeholder: "10.0.1.10,192.168.1.5", + validate: (value) => { + if (!value || String(value).trim() === "") { + return "At least one trusted proxy IP is required"; + } + return undefined; + }, + }), + runtime, + ); + trustedProxies = String(trustedProxiesRaw) + .split(",") + .map((ip) => ip.trim()) + .filter(Boolean); + + trustedProxyConfig = { + userHeader: String(userHeader).trim(), + requiredHeaders: requiredHeaders.length > 0 ? requiredHeaders : undefined, + allowUsers: allowUsers.length > 0 ? allowUsers : undefined, + }; + } + const authConfig = buildGatewayAuthConfig({ existing: next.gateway?.auth, mode: authMode, token: gatewayToken, password: gatewayPassword, + trustedProxy: trustedProxyConfig, }); next = { @@ -217,6 +316,7 @@ export async function promptGatewayConfig( bind, auth: authConfig, ...(customBindHost && { customBindHost }), + ...(trustedProxies && { trustedProxies }), tailscale: { ...next.gateway?.tailscale, mode: tailscaleMode, diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index cdfc1560e3..1f2c30cba9 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -76,7 +76,32 @@ export type GatewayControlUiConfig = { dangerouslyDisableDeviceAuth?: boolean; }; -export type GatewayAuthMode = "token" | "password"; +export type GatewayAuthMode = "token" | "password" | "trusted-proxy"; + +/** + * Configuration for trusted reverse proxy authentication. + * Used when Clawdbot runs behind an identity-aware proxy (Pomerium, Caddy + OAuth, etc.) + * that handles authentication and passes user identity via headers. + */ +export type GatewayTrustedProxyConfig = { + /** + * Header name containing the authenticated user identity (required). + * Common values: "x-forwarded-user", "x-remote-user", "x-pomerium-claim-email" + */ + userHeader: string; + /** + * Additional headers that MUST be present for the request to be trusted. + * Use this to verify the request actually came through the proxy. + * Example: ["x-forwarded-proto", "x-forwarded-host"] + */ + requiredHeaders?: string[]; + /** + * Optional allowlist of user identities that can access the gateway. + * If empty or omitted, all authenticated users from the proxy are allowed. + * Example: ["nick@example.com", "admin@company.org"] + */ + allowUsers?: string[]; +}; export type GatewayAuthConfig = { /** Authentication mode for Gateway connections. Defaults to token when set. */ @@ -89,6 +114,11 @@ export type GatewayAuthConfig = { allowTailscale?: boolean; /** Rate-limit configuration for failed authentication attempts. */ rateLimit?: GatewayAuthRateLimitConfig; + /** + * Configuration for trusted-proxy auth mode. + * Required when mode is "trusted-proxy". + */ + trustedProxy?: GatewayTrustedProxyConfig; }; export type GatewayAuthRateLimitConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 517ec16de2..7f43b4b1a0 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -398,10 +398,29 @@ export const OpenClawSchema = z .optional(), auth: z .object({ - mode: z.union([z.literal("token"), z.literal("password")]).optional(), + mode: z + .union([z.literal("token"), z.literal("password"), z.literal("trusted-proxy")]) + .optional(), token: z.string().optional().register(sensitive), password: z.string().optional().register(sensitive), allowTailscale: z.boolean().optional(), + rateLimit: z + .object({ + maxAttempts: z.number().optional(), + windowMs: z.number().optional(), + lockoutMs: z.number().optional(), + exemptLoopback: z.boolean().optional(), + }) + .strict() + .optional(), + trustedProxy: z + .object({ + userHeader: z.string().min(1, "userHeader is required for trusted-proxy mode"), + requiredHeaders: z.array(z.string()).optional(), + allowUsers: z.array(z.string()).optional(), + }) + .strict() + .optional(), }) .strict() .optional(), diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 75745f8811..f0a595b984 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; -import { authorizeGatewayConnect } from "./auth.js"; +import { authorizeGatewayConnect, resolveGatewayAuth } from "./auth.js"; function createLimiterSpy(): AuthRateLimiter & { check: ReturnType; @@ -18,6 +18,38 @@ function createLimiterSpy(): AuthRateLimiter & { } describe("gateway auth", () => { + it("resolves token/password from OPENCLAW gateway env vars", () => { + expect( + resolveGatewayAuth({ + authConfig: {}, + env: { + OPENCLAW_GATEWAY_TOKEN: "env-token", + OPENCLAW_GATEWAY_PASSWORD: "env-password", + } as NodeJS.ProcessEnv, + }), + ).toMatchObject({ + mode: "password", + token: "env-token", + password: "env-password", + }); + }); + + it("does not resolve legacy CLAWDBOT gateway env vars", () => { + expect( + resolveGatewayAuth({ + authConfig: {}, + env: { + CLAWDBOT_GATEWAY_TOKEN: "legacy-token", + CLAWDBOT_GATEWAY_PASSWORD: "legacy-password", + } as NodeJS.ProcessEnv, + }), + ).toMatchObject({ + mode: "none", + token: undefined, + password: undefined, + }); + }); + it("does not throw when req is missing socket", async () => { const res = await authorizeGatewayConnect({ auth: { mode: "token", token: "secret", allowTailscale: false }, @@ -149,3 +181,250 @@ describe("gateway auth", () => { expect(limiter.recordFailure).toHaveBeenCalledWith(undefined, "custom-scope"); }); }); + +describe("trusted-proxy auth", () => { + const trustedProxyConfig = { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto"], + allowUsers: [], + }; + + it("accepts valid request from trusted proxy", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + }, + connectAuth: null, + trustedProxies: ["10.0.0.1"], + req: { + socket: { remoteAddress: "10.0.0.1" }, + headers: { + host: "gateway.local", + "x-forwarded-user": "nick@example.com", + "x-forwarded-proto": "https", + }, + } as never, + }); + + expect(res.ok).toBe(true); + expect(res.method).toBe("trusted-proxy"); + expect(res.user).toBe("nick@example.com"); + }); + + it("rejects request from untrusted source", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + }, + connectAuth: null, + trustedProxies: ["10.0.0.1"], + req: { + socket: { remoteAddress: "192.168.1.100" }, + headers: { + host: "gateway.local", + "x-forwarded-user": "attacker@evil.com", + "x-forwarded-proto": "https", + }, + } as never, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_untrusted_source"); + }); + + it("rejects request with missing user header", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + }, + connectAuth: null, + trustedProxies: ["10.0.0.1"], + req: { + socket: { remoteAddress: "10.0.0.1" }, + headers: { + host: "gateway.local", + "x-forwarded-proto": "https", + // missing x-forwarded-user + }, + } as never, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_user_missing"); + }); + + it("rejects request with missing required headers", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + }, + connectAuth: null, + trustedProxies: ["10.0.0.1"], + req: { + socket: { remoteAddress: "10.0.0.1" }, + headers: { + host: "gateway.local", + "x-forwarded-user": "nick@example.com", + // missing x-forwarded-proto + }, + } as never, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_missing_header_x-forwarded-proto"); + }); + + it("rejects user not in allowlist", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { + userHeader: "x-forwarded-user", + allowUsers: ["admin@example.com", "nick@example.com"], + }, + }, + connectAuth: null, + trustedProxies: ["10.0.0.1"], + req: { + socket: { remoteAddress: "10.0.0.1" }, + headers: { + host: "gateway.local", + "x-forwarded-user": "stranger@other.com", + }, + } as never, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_user_not_allowed"); + }); + + it("accepts user in allowlist", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { + userHeader: "x-forwarded-user", + allowUsers: ["admin@example.com", "nick@example.com"], + }, + }, + connectAuth: null, + trustedProxies: ["10.0.0.1"], + req: { + socket: { remoteAddress: "10.0.0.1" }, + headers: { + host: "gateway.local", + "x-forwarded-user": "nick@example.com", + }, + } as never, + }); + + expect(res.ok).toBe(true); + expect(res.method).toBe("trusted-proxy"); + expect(res.user).toBe("nick@example.com"); + }); + + it("rejects when no trustedProxies configured", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + }, + connectAuth: null, + trustedProxies: [], + req: { + socket: { remoteAddress: "10.0.0.1" }, + headers: { + host: "gateway.local", + "x-forwarded-user": "nick@example.com", + }, + } as never, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_no_proxies_configured"); + }); + + it("rejects when trustedProxy config missing", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + // trustedProxy missing + }, + connectAuth: null, + trustedProxies: ["10.0.0.1"], + req: { + socket: { remoteAddress: "10.0.0.1" }, + headers: { + host: "gateway.local", + "x-forwarded-user": "nick@example.com", + }, + } as never, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_config_missing"); + }); + + it("supports Pomerium-style headers", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { + userHeader: "x-pomerium-claim-email", + requiredHeaders: ["x-pomerium-jwt-assertion"], + }, + }, + connectAuth: null, + trustedProxies: ["172.17.0.1"], + req: { + socket: { remoteAddress: "172.17.0.1" }, + headers: { + host: "gateway.local", + "x-pomerium-claim-email": "nick@example.com", + "x-pomerium-jwt-assertion": "eyJ...", + }, + } as never, + }); + + expect(res.ok).toBe(true); + expect(res.method).toBe("trusted-proxy"); + expect(res.user).toBe("nick@example.com"); + }); + + it("trims whitespace from user header value", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + connectAuth: null, + trustedProxies: ["10.0.0.1"], + req: { + socket: { remoteAddress: "10.0.0.1" }, + headers: { + host: "gateway.local", + "x-forwarded-user": " nick@example.com ", + }, + } as never, + }); + + expect(res.ok).toBe(true); + expect(res.user).toBe("nick@example.com"); + }); +}); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 04a6f9e54f..5ae84ce715 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -1,5 +1,9 @@ import type { IncomingMessage } from "node:http"; -import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js"; +import type { + GatewayAuthConfig, + GatewayTailscaleMode, + GatewayTrustedProxyConfig, +} from "../config/config.js"; import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { @@ -14,18 +18,19 @@ import { resolveGatewayClientIp, } from "./net.js"; -export type ResolvedGatewayAuthMode = "token" | "password"; +export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; export type ResolvedGatewayAuth = { mode: ResolvedGatewayAuthMode; token?: string; password?: string; allowTailscale: boolean; + trustedProxy?: GatewayTrustedProxyConfig; }; export type GatewayAuthResult = { ok: boolean; - method?: "token" | "password" | "tailscale" | "device-token"; + method?: "none" | "token" | "password" | "tailscale" | "device-token" | "trusted-proxy"; user?: string; reason?: string; /** Present when the request was blocked by the rate limiter. */ @@ -192,21 +197,31 @@ export function resolveGatewayAuth(params: { }): ResolvedGatewayAuth { const authConfig = params.authConfig ?? {}; const env = params.env ?? process.env; - const token = - authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined; - const password = - authConfig.password ?? - env.OPENCLAW_GATEWAY_PASSWORD ?? - env.CLAWDBOT_GATEWAY_PASSWORD ?? - undefined; - const mode: ResolvedGatewayAuth["mode"] = authConfig.mode ?? (password ? "password" : "token"); + const token = authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? undefined; + const password = authConfig.password ?? env.OPENCLAW_GATEWAY_PASSWORD ?? undefined; + const trustedProxy = authConfig.trustedProxy; + + let mode: ResolvedGatewayAuth["mode"]; + if (authConfig.mode) { + mode = authConfig.mode; + } else if (password) { + mode = "password"; + } else if (token) { + mode = "token"; + } else { + mode = "none"; + } + const allowTailscale = - authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password"); + authConfig.allowTailscale ?? + (params.tailscaleMode === "serve" && mode !== "password" && mode !== "trusted-proxy"); + return { mode, token, password, allowTailscale, + trustedProxy, }; } @@ -222,6 +237,61 @@ export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void { if (auth.mode === "password" && !auth.password) { throw new Error("gateway auth mode is password, but no password was configured"); } + if (auth.mode === "trusted-proxy") { + if (!auth.trustedProxy) { + throw new Error( + "gateway auth mode is trusted-proxy, but no trustedProxy config was provided (set gateway.auth.trustedProxy)", + ); + } + if (!auth.trustedProxy.userHeader || auth.trustedProxy.userHeader.trim() === "") { + throw new Error( + "gateway auth mode is trusted-proxy, but trustedProxy.userHeader is empty (set gateway.auth.trustedProxy.userHeader)", + ); + } + } +} + +/** + * Check if the request came from a trusted proxy and extract user identity. + * Returns the user identity if valid, or null with a reason if not. + */ +function authorizeTrustedProxy(params: { + req?: IncomingMessage; + trustedProxies?: string[]; + trustedProxyConfig: GatewayTrustedProxyConfig; +}): { user: string } | { reason: string } { + const { req, trustedProxies, trustedProxyConfig } = params; + + if (!req) { + return { reason: "trusted_proxy_no_request" }; + } + + const remoteAddr = req.socket?.remoteAddress; + if (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) { + return { reason: "trusted_proxy_untrusted_source" }; + } + + const requiredHeaders = trustedProxyConfig.requiredHeaders ?? []; + for (const header of requiredHeaders) { + const value = headerValue(req.headers[header.toLowerCase()]); + if (!value || value.trim() === "") { + return { reason: `trusted_proxy_missing_header_${header}` }; + } + } + + const userHeaderValue = headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]); + if (!userHeaderValue || userHeaderValue.trim() === "") { + return { reason: "trusted_proxy_user_missing" }; + } + + const user = userHeaderValue.trim(); + + const allowUsers = trustedProxyConfig.allowUsers ?? []; + if (allowUsers.length > 0 && !allowUsers.includes(user)) { + return { reason: "trusted_proxy_user_not_allowed" }; + } + + return { user }; } export async function authorizeGatewayConnect(params: { @@ -241,7 +311,26 @@ export async function authorizeGatewayConnect(params: { const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity; const localDirect = isLocalDirectRequest(req, trustedProxies); - // --- Rate-limit gate --- + if (auth.mode === "trusted-proxy") { + if (!auth.trustedProxy) { + return { ok: false, reason: "trusted_proxy_config_missing" }; + } + if (!trustedProxies || trustedProxies.length === 0) { + return { ok: false, reason: "trusted_proxy_no_proxies_configured" }; + } + + const result = authorizeTrustedProxy({ + req, + trustedProxies, + trustedProxyConfig: auth.trustedProxy, + }); + + if ("user" in result) { + return { ok: true, method: "trusted-proxy", user: result.user }; + } + return { ok: false, reason: result.reason }; + } + const limiter = params.rateLimiter; const ip = params.clientIp ?? resolveRequestClientIp(req, trustedProxies) ?? req?.socket?.remoteAddress; @@ -264,7 +353,6 @@ export async function authorizeGatewayConnect(params: { tailscaleWhois, }); if (tailscaleCheck.ok) { - // Successful auth – reset rate-limit counter for this IP. limiter?.reset(ip, rateLimitScope); return { ok: true, diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index faa039abd1..f9cddf6f27 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -2,10 +2,108 @@ import os from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; import { isPrivateOrLoopbackAddress, + isTrustedProxyAddress, pickPrimaryLanIPv4, resolveGatewayListenHosts, } from "./net.js"; +describe("isTrustedProxyAddress", () => { + describe("exact IP matching", () => { + it("returns true when IP matches exactly", () => { + expect(isTrustedProxyAddress("192.168.1.1", ["192.168.1.1"])).toBe(true); + }); + + it("returns false when IP does not match", () => { + expect(isTrustedProxyAddress("192.168.1.2", ["192.168.1.1"])).toBe(false); + }); + + it("returns true when IP matches one of multiple proxies", () => { + expect(isTrustedProxyAddress("10.0.0.5", ["192.168.1.1", "10.0.0.5", "172.16.0.1"])).toBe( + true, + ); + }); + }); + + describe("CIDR subnet matching", () => { + it("returns true when IP is within /24 subnet", () => { + expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/24"])).toBe(true); + expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.0/24"])).toBe(true); + expect(isTrustedProxyAddress("10.42.0.254", ["10.42.0.0/24"])).toBe(true); + }); + + it("returns false when IP is outside /24 subnet", () => { + expect(isTrustedProxyAddress("10.42.1.1", ["10.42.0.0/24"])).toBe(false); + expect(isTrustedProxyAddress("10.43.0.1", ["10.42.0.0/24"])).toBe(false); + }); + + it("returns true when IP is within /16 subnet", () => { + expect(isTrustedProxyAddress("172.19.5.100", ["172.19.0.0/16"])).toBe(true); + expect(isTrustedProxyAddress("172.19.255.255", ["172.19.0.0/16"])).toBe(true); + }); + + it("returns false when IP is outside /16 subnet", () => { + expect(isTrustedProxyAddress("172.20.0.1", ["172.19.0.0/16"])).toBe(false); + }); + + it("returns true when IP is within /32 subnet (single IP)", () => { + expect(isTrustedProxyAddress("10.42.0.0", ["10.42.0.0/32"])).toBe(true); + }); + + it("returns false when IP does not match /32 subnet", () => { + expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.0/32"])).toBe(false); + }); + + it("handles mixed exact IPs and CIDR notation", () => { + const proxies = ["192.168.1.1", "10.42.0.0/24", "172.19.0.0/16"]; + expect(isTrustedProxyAddress("192.168.1.1", proxies)).toBe(true); // exact match + expect(isTrustedProxyAddress("10.42.0.59", proxies)).toBe(true); // CIDR match + expect(isTrustedProxyAddress("172.19.5.100", proxies)).toBe(true); // CIDR match + expect(isTrustedProxyAddress("10.43.0.1", proxies)).toBe(false); // no match + }); + }); + + describe("backward compatibility", () => { + it("preserves exact IP matching behavior (no CIDR notation)", () => { + // Old configs with exact IPs should work exactly as before + expect(isTrustedProxyAddress("192.168.1.1", ["192.168.1.1"])).toBe(true); + expect(isTrustedProxyAddress("192.168.1.2", ["192.168.1.1"])).toBe(false); + expect(isTrustedProxyAddress("10.0.0.5", ["192.168.1.1", "10.0.0.5"])).toBe(true); + }); + + it("does NOT treat plain IPs as /32 CIDR (exact match only)", () => { + // "10.42.0.1" without /32 should match ONLY that exact IP + expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.1"])).toBe(true); + expect(isTrustedProxyAddress("10.42.0.2", ["10.42.0.1"])).toBe(false); + expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.1"])).toBe(false); + }); + + it("handles IPv4-mapped IPv6 addresses (existing normalizeIp behavior)", () => { + // Existing normalizeIp() behavior should be preserved + expect(isTrustedProxyAddress("::ffff:192.168.1.1", ["192.168.1.1"])).toBe(true); + }); + }); + + describe("edge cases", () => { + it("returns false when IP is undefined", () => { + expect(isTrustedProxyAddress(undefined, ["192.168.1.1"])).toBe(false); + }); + + it("returns false when trustedProxies is undefined", () => { + expect(isTrustedProxyAddress("192.168.1.1", undefined)).toBe(false); + }); + + it("returns false when trustedProxies is empty", () => { + expect(isTrustedProxyAddress("192.168.1.1", [])).toBe(false); + }); + + it("returns false for invalid CIDR notation", () => { + expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/33"])).toBe(false); // invalid prefix + expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/-1"])).toBe(false); // negative prefix + expect(isTrustedProxyAddress("10.42.0.59", ["invalid/24"])).toBe(false); // invalid IP + }); + }); +}); + describe("resolveGatewayListenHosts", () => { it("returns the input host when not loopback", async () => { const hosts = await resolveGatewayListenHosts("0.0.0.0", { diff --git a/src/gateway/net.ts b/src/gateway/net.ts index aea9732588..977102eb76 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -139,12 +139,65 @@ function parseRealIp(realIp?: string): string | undefined { return normalizeIp(stripOptionalPort(raw)); } +/** + * Check if an IP address matches a CIDR block. + * Supports IPv4 CIDR notation (e.g., "10.42.0.0/24"). + * + * @param ip - The IP address to check (e.g., "10.42.0.59") + * @param cidr - The CIDR block (e.g., "10.42.0.0/24") + * @returns True if the IP is within the CIDR block + */ +function ipMatchesCIDR(ip: string, cidr: string): boolean { + // Handle exact IP match (no CIDR notation) + if (!cidr.includes("/")) { + return ip === cidr; + } + + const [subnet, prefixLenStr] = cidr.split("/"); + const prefixLen = parseInt(prefixLenStr, 10); + + // Validate prefix length + if (Number.isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) { + return false; + } + + // Convert IPs to 32-bit integers + const ipParts = ip.split(".").map((p) => parseInt(p, 10)); + const subnetParts = subnet.split(".").map((p) => parseInt(p, 10)); + + // Validate IP format + if ( + ipParts.length !== 4 || + subnetParts.length !== 4 || + ipParts.some((p) => Number.isNaN(p) || p < 0 || p > 255) || + subnetParts.some((p) => Number.isNaN(p) || p < 0 || p > 255) + ) { + return false; + } + + const ipInt = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3]; + const subnetInt = + (subnetParts[0] << 24) | (subnetParts[1] << 16) | (subnetParts[2] << 8) | subnetParts[3]; + + // Create mask and compare + const mask = prefixLen === 0 ? 0 : (-1 >>> (32 - prefixLen)) << (32 - prefixLen); + return (ipInt & mask) === (subnetInt & mask); +} + export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean { const normalized = normalizeIp(ip); if (!normalized || !trustedProxies || trustedProxies.length === 0) { return false; } - return trustedProxies.some((proxy) => normalizeIp(proxy) === normalized); + + return trustedProxies.some((proxy) => { + // Handle CIDR notation + if (proxy.includes("/")) { + return ipMatchesCIDR(normalized, proxy); + } + // Exact IP match + return normalizeIp(proxy) === normalized; + }); } export function resolveGatewayClientIp(params: { diff --git a/src/gateway/protocol/schema/snapshot.ts b/src/gateway/protocol/schema/snapshot.ts index 764b25734e..1ac6ebc1a8 100644 --- a/src/gateway/protocol/schema/snapshot.ts +++ b/src/gateway/protocol/schema/snapshot.ts @@ -52,6 +52,14 @@ export const SnapshotSchema = Type.Object( configPath: Type.Optional(NonEmptyString), stateDir: Type.Optional(NonEmptyString), sessionDefaults: Type.Optional(SessionDefaultsSchema), + authMode: Type.Optional( + Type.Union([ + Type.Literal("none"), + Type.Literal("token"), + Type.Literal("password"), + Type.Literal("trusted-proxy"), + ]), + ), }, { additionalProperties: false }, ); diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts new file mode 100644 index 0000000000..2f85796886 --- /dev/null +++ b/src/gateway/server-runtime-config.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js"; + +describe("resolveGatewayRuntimeConfig", () => { + describe("trusted-proxy auth mode", () => { + // This test validates BOTH validation layers: + // 1. CLI validation in src/cli/gateway-cli/run.ts (line 246) + // 2. Runtime config validation in src/gateway/server-runtime-config.ts (line 99) + // Both must allow lan binding when authMode === "trusted-proxy" + it("should allow lan binding with trusted-proxy auth mode", async () => { + const cfg = { + gateway: { + bind: "lan" as const, + auth: { + mode: "trusted-proxy" as const, + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: ["192.168.1.1"], + }, + }; + + const result = await resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + }); + + expect(result.authMode).toBe("trusted-proxy"); + expect(result.bindHost).toBe("0.0.0.0"); + }); + + it("should reject loopback binding with trusted-proxy auth mode", async () => { + const cfg = { + gateway: { + bind: "loopback" as const, + auth: { + mode: "trusted-proxy" as const, + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: ["192.168.1.1"], + }, + }; + + await expect( + resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + }), + ).rejects.toThrow("gateway auth mode=trusted-proxy makes no sense with bind=loopback"); + }); + + it("should reject trusted-proxy without trustedProxies configured", async () => { + const cfg = { + gateway: { + bind: "lan" as const, + auth: { + mode: "trusted-proxy" as const, + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: [], + }, + }; + + await expect( + resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + }), + ).rejects.toThrow( + "gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured", + ); + }); + }); + + describe("token/password auth modes", () => { + it("should reject token mode without token configured", async () => { + const cfg = { + gateway: { + bind: "lan" as const, + auth: { + mode: "token" as const, + }, + }, + }; + + await expect( + resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + }), + ).rejects.toThrow("gateway auth mode is token, but no token was configured"); + }); + + it("should allow lan binding with token", async () => { + const cfg = { + gateway: { + bind: "lan" as const, + auth: { + mode: "token" as const, + token: "test-token-123", + }, + }, + }; + + const result = await resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + }); + + expect(result.authMode).toBe("token"); + expect(result.bindHost).toBe("0.0.0.0"); + }); + }); +}); diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index 6fedc290f6..8763341f00 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -85,6 +85,8 @@ export async function resolveGatewayRuntimeConfig(params: { const canvasHostEnabled = process.env.OPENCLAW_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false; + const trustedProxies = params.cfg.gateway?.trustedProxies ?? []; + assertGatewayAuthConfigured(resolvedAuth); if (tailscaleMode === "funnel" && authMode !== "password") { throw new Error( @@ -94,12 +96,25 @@ export async function resolveGatewayRuntimeConfig(params: { if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) { throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)"); } - if (!isLoopbackHost(bindHost) && !hasSharedSecret) { + if (!isLoopbackHost(bindHost) && !hasSharedSecret && authMode !== "trusted-proxy") { throw new Error( `refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD)`, ); } + if (authMode === "trusted-proxy") { + if (isLoopbackHost(bindHost)) { + throw new Error( + "gateway auth mode=trusted-proxy makes no sense with bind=loopback; use bind=lan or bind=custom with gateway.trustedProxies configured", + ); + } + if (trustedProxies.length === 0) { + throw new Error( + "gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured with at least one proxy IP", + ); + } + } + return { bindHost, controlUiEnabled, diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index 8bc481dfc8..9d38799b37 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -5,6 +5,7 @@ import { CONFIG_PATH, STATE_DIR, loadConfig } from "../../config/config.js"; import { resolveMainSessionKey } from "../../config/sessions.js"; import { listSystemPresence } from "../../infra/system-presence.js"; import { normalizeMainKey } from "../../routing/session-key.js"; +import { resolveGatewayAuth } from "../auth.js"; let presenceVersion = 1; let healthVersion = 1; @@ -20,6 +21,7 @@ export function buildGatewaySnapshot(): Snapshot { const scope = cfg.session?.scope ?? "per-sender"; const presence = listSystemPresence(); const uptimeMs = Math.round(process.uptime() * 1000); + const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env }); // Health is async; caller should await getHealthSnapshot and replace later if needed. const emptyHealth: unknown = {}; return { @@ -36,6 +38,7 @@ export function buildGatewaySnapshot(): Snapshot { mainSessionKey, scope, }, + authMode: auth.mode, }; } diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 06a16f55c0..4496dfc3bb 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -449,7 +449,10 @@ export function collectSecretsInConfigFindings(cfg: OpenClawConfig): SecurityAud return findings; } -export function collectHooksHardeningFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { +export function collectHooksHardeningFindings( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; if (cfg.hooks?.enabled !== true) { return findings; @@ -468,13 +471,20 @@ export function collectHooksHardeningFindings(cfg: OpenClawConfig): SecurityAudi const gatewayAuth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off", + env, }); + const openclawGatewayToken = + typeof env.OPENCLAW_GATEWAY_TOKEN === "string" && env.OPENCLAW_GATEWAY_TOKEN.trim() + ? env.OPENCLAW_GATEWAY_TOKEN.trim() + : null; const gatewayToken = gatewayAuth.mode === "token" && typeof gatewayAuth.token === "string" && gatewayAuth.token.trim() ? gatewayAuth.token.trim() - : null; + : openclawGatewayToken + ? openclawGatewayToken + : null; if (token && gatewayToken && token === gatewayToken) { findings.push({ checkId: "hooks.token_reuse_gateway_token", @@ -545,6 +555,33 @@ export function collectHooksHardeningFindings(cfg: OpenClawConfig): SecurityAudi return findings; } +export function collectGatewayHttpSessionKeyOverrideFindings( + cfg: OpenClawConfig, +): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + const chatCompletionsEnabled = cfg.gateway?.http?.endpoints?.chatCompletions?.enabled === true; + const responsesEnabled = cfg.gateway?.http?.endpoints?.responses?.enabled === true; + if (!chatCompletionsEnabled && !responsesEnabled) { + return findings; + } + + const enabledEndpoints = [ + chatCompletionsEnabled ? "/v1/chat/completions" : null, + responsesEnabled ? "/v1/responses" : null, + ].filter((entry): entry is string => Boolean(entry)); + + findings.push({ + checkId: "gateway.http.session_key_override_enabled", + severity: "info", + title: "HTTP API session-key override is enabled", + detail: + `${enabledEndpoints.join(", ")} accept x-openclaw-session-key for per-request session routing. ` + + "Treat API credential holders as trusted principals.", + }); + + 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 35b4d3405a..1b507d6ae9 100644 --- a/src/security/audit-extra.ts +++ b/src/security/audit-extra.ts @@ -11,6 +11,7 @@ export { collectAttackSurfaceSummaryFindings, collectExposureMatrixFindings, + collectGatewayHttpSessionKeyOverrideFindings, collectHooksHardeningFindings, collectMinimalProfileOverrideFindings, collectModelHygieneFindings, diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 54adece003..e63d90ca39 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -95,23 +95,42 @@ describe("security audit", () => { }); it("flags non-loopback bind without auth as critical", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "lan", - auth: {}, - }, - }; + // Clear env tokens so resolveGatewayAuth defaults to mode=none + const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; + const prevPassword = process.env.OPENCLAW_GATEWAY_PASSWORD; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_PASSWORD; - const res = await runSecurityAudit({ - config: cfg, - env: {}, - includeFilesystem: false, - includeChannelSecurity: false, - }); + try { + const cfg: OpenClawConfig = { + gateway: { + bind: "lan", + auth: {}, + }, + }; - expect( - res.findings.some((f) => f.checkId === "gateway.bind_no_auth" && f.severity === "critical"), - ).toBe(true); + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect( + res.findings.some((f) => f.checkId === "gateway.bind_no_auth" && f.severity === "critical"), + ).toBe(true); + } finally { + // Restore env + if (prevToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + } + if (prevPassword === undefined) { + delete process.env.OPENCLAW_GATEWAY_PASSWORD; + } else { + process.env.OPENCLAW_GATEWAY_PASSWORD = prevPassword; + } + } }); it("warns when non-loopback bind has auth but no auth rate limit", async () => { @@ -593,6 +612,127 @@ describe("security audit", () => { ); }); + it("flags trusted-proxy auth mode without generic shared-secret findings", async () => { + const cfg: OpenClawConfig = { + gateway: { + bind: "lan", + trustedProxies: ["10.0.0.1"], + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "gateway.trusted_proxy_auth", + severity: "critical", + }), + ]), + ); + expect(res.findings.some((f) => f.checkId === "gateway.bind_no_auth")).toBe(false); + expect(res.findings.some((f) => f.checkId === "gateway.auth_no_rate_limit")).toBe(false); + }); + + it("flags trusted-proxy auth without trustedProxies configured", async () => { + const cfg: OpenClawConfig = { + gateway: { + bind: "lan", + trustedProxies: [], + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "gateway.trusted_proxy_no_proxies", + severity: "critical", + }), + ]), + ); + }); + + it("flags trusted-proxy auth without userHeader configured", async () => { + const cfg: OpenClawConfig = { + gateway: { + bind: "lan", + trustedProxies: ["10.0.0.1"], + auth: { + mode: "trusted-proxy", + trustedProxy: {} as never, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "gateway.trusted_proxy_no_user_header", + severity: "critical", + }), + ]), + ); + }); + + it("warns when trusted-proxy auth allows all users", async () => { + const cfg: OpenClawConfig = { + gateway: { + bind: "lan", + trustedProxies: ["10.0.0.1"], + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + allowUsers: [], + }, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "gateway.trusted_proxy_no_allowlist", + severity: "warn", + }), + ]), + ); + }); + it("warns when multiple DM senders share the main session", async () => { const cfg: OpenClawConfig = { session: { dmScope: "main" } }; const plugins: ChannelPlugin[] = [ diff --git a/src/security/audit.ts b/src/security/audit.ts index f003423c6d..0e761dd3ec 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -12,6 +12,7 @@ import { collectChannelSecurityFindings } from "./audit-channel.js"; import { collectAttackSurfaceSummaryFindings, collectExposureMatrixFindings, + collectGatewayHttpSessionKeyOverrideFindings, collectHooksHardeningFindings, collectIncludeFilePermFindings, collectInstalledSkillsCodeSafetyFindings, @@ -257,10 +258,7 @@ 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) { + if (bind !== "loopback" && !hasSharedSecret && auth.mode !== "trusted-proxy") { findings.push({ checkId: "gateway.bind_no_auth", severity: "critical", @@ -346,26 +344,66 @@ 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)); + if (auth.mode === "trusted-proxy") { + const trustedProxies = cfg.gateway?.trustedProxies ?? []; + const trustedProxyConfig = cfg.gateway?.auth?.trustedProxy; + findings.push({ - checkId: "gateway.http.session_key_override_enabled", - severity: remotelyExposed ? "warn" : "info", - title: "HTTP APIs accept explicit session key override headers", + checkId: "gateway.trusted_proxy_auth", + severity: "critical", + title: "Trusted-proxy auth mode enabled", detail: - `${enabledEndpoints.join(", ")} support x-openclaw-session-key. ` + - "Any authenticated caller can route requests into arbitrary sessions.", + '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: - "Treat HTTP API credentials as full-trust, disable unused endpoints, and avoid sharing tokens across tenants.", + "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" && !cfg.gateway?.auth?.rateLimit) { + if (bind !== "loopback" && auth.mode !== "trusted-proxy" && !cfg.gateway?.auth?.rateLimit) { findings.push({ checkId: "gateway.auth_no_rate_limit", severity: "warn", @@ -570,7 +608,8 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise; + stop: ReturnType; + emitClose: (code: number, reason?: string) => void; + emitGap: (expected: number, received: number) => void; + emitEvent: (evt: { event: string; payload?: unknown; seq?: number }) => void; +}; + +const gatewayClientInstances: GatewayClientMock[] = []; + +vi.mock("./gateway.ts", () => { + class GatewayBrowserClient { + readonly start = vi.fn(); + readonly stop = vi.fn(); + + constructor( + private opts: { + onClose?: (info: { code: number; reason: string }) => void; + onGap?: (info: { expected: number; received: number }) => void; + onEvent?: (evt: { event: string; payload?: unknown; seq?: number }) => void; + }, + ) { + gatewayClientInstances.push({ + start: this.start, + stop: this.stop, + emitClose: (code, reason) => { + this.opts.onClose?.({ code, reason: reason ?? "" }); + }, + emitGap: (expected, received) => { + this.opts.onGap?.({ expected, received }); + }, + emitEvent: (evt) => { + this.opts.onEvent?.(evt); + }, + }); + } + } + + return { GatewayBrowserClient }; +}); + +function createHost() { + return { + settings: { + gatewayUrl: "ws://127.0.0.1:18789", + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }, + password: "", + client: null, + connected: false, + hello: null, + lastError: null, + eventLogBuffer: [], + eventLog: [], + tab: "overview", + presenceEntries: [], + presenceError: null, + presenceStatus: null, + agentsLoading: false, + agentsList: null, + agentsError: null, + debugHealth: null, + assistantName: "OpenClaw", + assistantAvatar: null, + assistantAgentId: null, + sessionKey: "main", + chatRunId: null, + refreshSessionsAfterChat: new Set(), + execApprovalQueue: [], + execApprovalError: null, + } as unknown as Parameters[0]; +} + +describe("connectGateway", () => { + beforeEach(() => { + gatewayClientInstances.length = 0; + }); + + it("ignores stale client onGap callbacks after reconnect", () => { + const host = createHost(); + + connectGateway(host); + const firstClient = gatewayClientInstances[0]; + expect(firstClient).toBeDefined(); + + connectGateway(host); + const secondClient = gatewayClientInstances[1]; + expect(secondClient).toBeDefined(); + + firstClient.emitGap(10, 13); + expect(host.lastError).toBeNull(); + + secondClient.emitGap(20, 24); + expect(host.lastError).toBe( + "event gap detected (expected seq 20, got 24); refresh recommended", + ); + }); + + it("ignores stale client onEvent callbacks after reconnect", () => { + const host = createHost(); + + connectGateway(host); + const firstClient = gatewayClientInstances[0]; + expect(firstClient).toBeDefined(); + + connectGateway(host); + const secondClient = gatewayClientInstances[1]; + expect(secondClient).toBeDefined(); + + firstClient.emitEvent({ event: "presence", payload: { presence: [{ host: "stale" }] } }); + expect(host.eventLogBuffer).toHaveLength(0); + + secondClient.emitEvent({ event: "presence", payload: { presence: [{ host: "active" }] } }); + expect(host.eventLogBuffer).toHaveLength(1); + expect(host.eventLogBuffer[0]?.event).toBe("presence"); + }); + + it("ignores stale client onClose callbacks after reconnect", () => { + const host = createHost(); + + connectGateway(host); + const firstClient = gatewayClientInstances[0]; + expect(firstClient).toBeDefined(); + + connectGateway(host); + const secondClient = gatewayClientInstances[1]; + expect(secondClient).toBeDefined(); + + firstClient.emitClose(1005); + expect(host.lastError).toBeNull(); + + secondClient.emitClose(1005); + expect(host.lastError).toBe("disconnected (1005): no reason"); + }); +}); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 4cfa01134e..12b2c7b6d9 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -122,14 +122,17 @@ export function connectGateway(host: GatewayHost) { host.execApprovalQueue = []; host.execApprovalError = null; - host.client?.stop(); - host.client = new GatewayBrowserClient({ + const previousClient = host.client; + const client = new GatewayBrowserClient({ url: host.settings.gatewayUrl, token: host.settings.token.trim() ? host.settings.token : undefined, password: host.password.trim() ? host.password : undefined, clientName: "openclaw-control-ui", mode: "webchat", onHello: (hello) => { + if (host.client !== client) { + return; + } host.connected = true; host.lastError = null; host.hello = hello; @@ -147,18 +150,31 @@ export function connectGateway(host: GatewayHost) { void refreshActiveTab(host as unknown as Parameters[0]); }, onClose: ({ code, reason }) => { + if (host.client !== client) { + return; + } host.connected = false; // Code 1012 = Service Restart (expected during config saves, don't show as error) if (code !== 1012) { host.lastError = `disconnected (${code}): ${reason || "no reason"}`; } }, - onEvent: (evt) => handleGatewayEvent(host, evt), + onEvent: (evt) => { + if (host.client !== client) { + return; + } + handleGatewayEvent(host, evt); + }, onGap: ({ expected, received }) => { + if (host.client !== client) { + return; + } host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`; }, }); - host.client.start(); + host.client = client; + previousClient?.stop(); + client.start(); } export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) { diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 9fa0c1d567..ba425f8e12 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -24,10 +24,16 @@ export type OverviewProps = { export function renderOverview(props: OverviewProps) { const snapshot = props.hello?.snapshot as - | { uptimeMs?: number; policy?: { tickIntervalMs?: number } } + | { + uptimeMs?: number; + policy?: { tickIntervalMs?: number }; + authMode?: "none" | "token" | "password" | "trusted-proxy"; + } | undefined; const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : "n/a"; const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a"; + const authMode = snapshot?.authMode; + const isTrustedProxy = authMode === "trusted-proxy"; const authHint = (() => { if (props.connected || !props.lastError) { return null; @@ -136,29 +142,35 @@ export function renderOverview(props: OverviewProps) { placeholder="ws://100.x.y.z:18789" /> - - + ${ + isTrustedProxy + ? "" + : html` + + + ` + }