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/src/commands/configure.gateway.e2e.test.ts b/src/commands/configure.gateway.e2e.test.ts index 368be1cfdb..092ecd3d40 100644 --- a/src/commands/configure.gateway.e2e.test.ts +++ b/src/commands/configure.gateway.e2e.test.ts @@ -133,6 +133,7 @@ describe("promptGatewayConfig", () => { 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"]); }); @@ -163,6 +164,32 @@ describe("promptGatewayConfig", () => { 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 ce3ed02ed0..162e1a0cf7 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -114,7 +114,7 @@ export async function promptGatewayConfig( runtime, ) as GatewayAuthChoice; - const tailscaleMode = guardCancel( + let tailscaleMode = guardCancel( await select({ message: "Tailscale exposure", options: [ @@ -180,6 +180,19 @@ 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: @@ -218,7 +231,7 @@ export async function promptGatewayConfig( "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", + "Docs: https://docs.openclaw.ai/gateway/trusted-proxy-auth", ].join("\n"), "Trusted Proxy Auth", ); diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index fdc2be0182..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 }, diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 175da3c750..5ae84ce715 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -197,8 +197,8 @@ export function resolveGatewayAuth(params: { }): ResolvedGatewayAuth { const authConfig = params.authConfig ?? {}; const env = params.env ?? process.env; - const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined; - const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined; + 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"]; diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index a50d0d7ff2..e63d90ca39 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -96,13 +96,13 @@ describe("security audit", () => { it("flags non-loopback bind without auth as critical", async () => { // Clear env tokens so resolveGatewayAuth defaults to mode=none - const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; - const prevPassword = process.env.CLAWDBOT_GATEWAY_PASSWORD; - delete process.env.CLAWDBOT_GATEWAY_TOKEN; - delete process.env.CLAWDBOT_GATEWAY_PASSWORD; + 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; try { - const cfg: ClawdbotConfig = { + const cfg: OpenClawConfig = { gateway: { bind: "lan", auth: {}, @@ -121,14 +121,14 @@ describe("security audit", () => { } finally { // Restore env if (prevToken === undefined) { - delete process.env.CLAWDBOT_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_TOKEN; } else { - process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; } if (prevPassword === undefined) { - delete process.env.CLAWDBOT_GATEWAY_PASSWORD; + delete process.env.OPENCLAW_GATEWAY_PASSWORD; } else { - process.env.CLAWDBOT_GATEWAY_PASSWORD = prevPassword; + process.env.OPENCLAW_GATEWAY_PASSWORD = prevPassword; } } }); @@ -612,8 +612,8 @@ describe("security audit", () => { ); }); - it("flags trusted-proxy auth mode as critical warning", async () => { - const cfg: ClawdbotConfig = { + it("flags trusted-proxy auth mode without generic shared-secret findings", async () => { + const cfg: OpenClawConfig = { gateway: { bind: "lan", trustedProxies: ["10.0.0.1"], @@ -640,10 +640,12 @@ describe("security audit", () => { }), ]), ); + 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: ClawdbotConfig = { + const cfg: OpenClawConfig = { gateway: { bind: "lan", trustedProxies: [], @@ -673,7 +675,7 @@ describe("security audit", () => { }); it("flags trusted-proxy auth without userHeader configured", async () => { - const cfg: ClawdbotConfig = { + const cfg: OpenClawConfig = { gateway: { bind: "lan", trustedProxies: ["10.0.0.1"], @@ -701,7 +703,7 @@ describe("security audit", () => { }); it("warns when trusted-proxy auth allows all users", async () => { - const cfg: ClawdbotConfig = { + const cfg: OpenClawConfig = { gateway: { bind: "lan", trustedProxies: ["10.0.0.1"], diff --git a/src/security/audit.ts b/src/security/audit.ts index d2e12e36ff..0e761dd3ec 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -258,7 +258,7 @@ function collectGatewayConfigFindings( (auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword); const hasTailscaleAuth = auth.allowTailscale && tailscaleMode === "serve"; const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth; - if (bind !== "loopback" && !hasSharedSecret) { + if (bind !== "loopback" && !hasSharedSecret && auth.mode !== "trusted-proxy") { findings.push({ checkId: "gateway.bind_no_auth", severity: "critical", @@ -403,7 +403,7 @@ function collectGatewayConfigFindings( } } - 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",