diff --git a/CHANGELOG.md b/CHANGELOG.md index 26abd73008a..6d93605042c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Gateway/supervisor: exit cleanly when a supervised restart finds an existing healthy gateway and bound retries when the existing gateway stays unhealthy, so stale lock contention cannot loop indefinitely. Refs #72846. Thanks @azgardtek. - Gateway/startup: scope primary-model provider discovery during channel prewarm to the configured provider owner and add split startup trace timings, so boot avoids staging unrelated bundled provider dependencies while setup discovery remains broad. Fixes #73002. Thanks @Schnup03. - Channels/Microsoft Teams: unwrap staged CommonJS JWT runtime dependencies before Bot Connector token validation so inbound Teams messages no longer 401 after the bundled runtime-deps move. Fixes #73026. Thanks @kbrown10000. +- Gateway/auth: allow local direct callers in trusted-proxy mode to use the configured gateway password as an internal fallback while keeping token fallback rejected. Fixes #17761. Thanks @dashed, @vincentkoc, and @jetd1. - Channels/sessions: prevent guarded inbound session recording from creating route-only phantom sessions while still allowing last-route updates for sessions that already exist. Carries forward #73009. Thanks @jzakirov. - Cron: accept `delivery.threadId` in Gateway cron add/update schemas so scheduled announce delivery can target Telegram forum topics and other threaded channel destinations through the documented delivery path. Fixes #73017. Thanks @coachsootz. - Plugins/runtime deps: stage bundled plugin dependencies imported by mirrored root dist chunks, so packaged memory and status commands do not miss `chokidar` or similar root-chunk dependencies after update. Fixes #72882 and #72970; carries forward #72992. Thanks @shrimpy8, @colin-chang, and @Schnup03. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ff3756dbc04..e683dd40be0 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -394,7 +394,7 @@ See [Plugins](/tools/plugin). - **Auth**: required by default. Non-loopback binds require gateway auth. In practice that means a shared token/password or an identity-aware reverse proxy with `gateway.auth.mode: "trusted-proxy"`. Onboarding wizard generates a token by default. - If both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs), set `gateway.auth.mode` explicitly to `token` or `password`. Startup and service install/repair flows fail when both are configured and mode is unset. - `gateway.auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts. -- `gateway.auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)). This mode expects a **non-loopback** proxy source; same-host loopback reverse proxies do not satisfy trusted-proxy auth. +- `gateway.auth.mode: "trusted-proxy"`: delegate browser/user auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)). This mode expects a **non-loopback** proxy source; same-host loopback reverse proxies do not satisfy trusted-proxy identity auth. Internal same-host callers can use `gateway.auth.password` as a local direct fallback; `gateway.auth.token` remains mutually exclusive with trusted-proxy mode. - `gateway.auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`). HTTP API endpoints do **not** use that Tailscale header auth; they follow the gateway's normal HTTP auth mode instead. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`. - `gateway.auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`. - On the async Tailscale Serve Control UI path, failed attempts for the same `{scope, clientIp}` are serialized before the failure write. Concurrent bad attempts from the same client can therefore trip the limiter on the second request instead of both racing through as plain mismatches. diff --git a/docs/gateway/trusted-proxy-auth.md b/docs/gateway/trusted-proxy-auth.md index 4776d8d7678..14dd9a4b7d2 100644 --- a/docs/gateway/trusted-proxy-auth.md +++ b/docs/gateway/trusted-proxy-auth.md @@ -293,7 +293,7 @@ If you see a `mixed_trusted_proxy_token` error on startup: - Remove the shared token when using trusted-proxy mode, or - Switch `gateway.auth.mode` to `"token"` if you intend token-based auth. -Loopback trusted-proxy auth also fails closed: same-host callers must supply the configured identity headers through a trusted proxy instead of being silently authenticated. +Loopback trusted-proxy identity headers still fail closed: same-host callers are not silently authenticated as proxy users. Internal OpenClaw callers that bypass the proxy may authenticate with `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD` instead. Token fallback remains intentionally unsupported in trusted-proxy mode. ## Operator scopes header @@ -327,6 +327,7 @@ Before enabling trusted-proxy auth, verify: - [ ] **allowedOrigins is explicit**: Non-loopback Control UI uses explicit `gateway.controlUi.allowedOrigins`. - [ ] **allowUsers is set** (recommended): Restrict to known users rather than allowing anyone authenticated. - [ ] **No mixed token config**: Do not set both `gateway.auth.token` and `gateway.auth.mode: "trusted-proxy"`. +- [ ] **Local password fallback is private**: If you configure `gateway.auth.password` for internal direct callers, keep the Gateway port firewalled so non-proxy remote clients cannot reach it directly. ## Security audit diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 290f42bc5dc..ecbd290a616 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -902,6 +902,9 @@ describe("trusted-proxy auth", () => { function authorizeLocalDirect(options?: { token?: string; connectToken?: string; + password?: string; + connectPassword?: string; + rateLimiter?: AuthRateLimiter; trustedProxy?: GatewayConnectInput["auth"]["trustedProxy"]; trustedProxies?: string[]; }) { @@ -913,8 +916,13 @@ describe("trusted-proxy auth", () => { ? { trustedProxy: options?.trustedProxy } : { trustedProxy: trustedProxyConfig }), token: options?.token, + password: options?.password, // pragma: allowlist secret }, - connectAuth: options?.connectToken ? { token: options.connectToken } : null, + connectAuth: + options?.connectToken || options?.connectPassword + ? { token: options.connectToken, password: options.connectPassword } + : null, + rateLimiter: options?.rateLimiter, trustedProxies: options?.trustedProxies ?? ["127.0.0.1"], req: { socket: { remoteAddress: "127.0.0.1" }, @@ -956,6 +964,67 @@ describe("trusted-proxy auth", () => { expect(res.reason).toBe("trusted_proxy_loopback_source"); }); + it("accepts local-direct password fallback when trusted-proxy auth fails", async () => { + const limiter = createLimiterSpy(); + const res = await authorizeLocalDirect({ + password: "local-password", // pragma: allowlist secret + connectPassword: "local-password", // pragma: allowlist secret + rateLimiter: limiter, + }); + + expect(res).toEqual({ ok: true, method: "password" }); + expect(limiter.check).toHaveBeenCalledWith("127.0.0.1", "shared-secret"); + expect(limiter.reset).toHaveBeenCalledWith("127.0.0.1", "shared-secret"); + expect(limiter.recordFailure).not.toHaveBeenCalled(); + }); + + it("rejects wrong local-direct password fallback and records the failure", async () => { + const limiter = createLimiterSpy(); + const res = await authorizeLocalDirect({ + password: "local-password", // pragma: allowlist secret + connectPassword: "wrong-password", // pragma: allowlist secret + rateLimiter: limiter, + }); + + expect(res).toEqual({ ok: false, reason: "password_mismatch" }); + expect(limiter.check).toHaveBeenCalledWith("127.0.0.1", "shared-secret"); + expect(limiter.recordFailure).toHaveBeenCalledWith("127.0.0.1", "shared-secret"); + expect(limiter.reset).not.toHaveBeenCalled(); + }); + + it("enforces rate-limit lockout before local-direct password fallback", async () => { + const limiter = createLimiterSpy(); + limiter.check.mockReturnValueOnce({ + allowed: false, + remaining: 0, + retryAfterMs: 2500, + }); + + const res = await authorizeLocalDirect({ + password: "local-password", // pragma: allowlist secret + connectPassword: "local-password", // pragma: allowlist secret + rateLimiter: limiter, + }); + + expect(res).toEqual({ + ok: false, + reason: "rate_limited", + rateLimited: true, + retryAfterMs: 2500, + }); + expect(limiter.recordFailure).not.toHaveBeenCalled(); + expect(limiter.reset).not.toHaveBeenCalled(); + }); + + it("keeps local-direct trusted-proxy on proxy failure when no password is supplied", async () => { + const res = await authorizeLocalDirect({ + password: "local-password", // pragma: allowlist secret + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_loopback_source"); + }); + it("rejects trusted-proxy identity headers from loopback sources", async () => { const res = await authorizeGatewayConnect({ auth: { @@ -984,9 +1053,9 @@ describe("trusted-proxy auth", () => { mode: "trusted-proxy", allowTailscale: false, trustedProxy: trustedProxyConfig, - token: "secret", + password: "secret", // pragma: allowlist secret }, - connectAuth: null, + connectAuth: { password: "secret" }, trustedProxies: ["127.0.0.1"], req: { socket: { remoteAddress: "127.0.0.1" }, @@ -1048,8 +1117,8 @@ describe("trusted-proxy auth", () => { it("still fails closed when trusted-proxy config is missing", async () => { const res = await authorizeLocalDirect({ - token: "secret", - connectToken: "secret", + password: "secret", // pragma: allowlist secret + connectPassword: "secret", // pragma: allowlist secret trustedProxy: undefined, }); expect(res.ok).toBe(false); @@ -1058,8 +1127,8 @@ describe("trusted-proxy auth", () => { it("still fails closed when trusted proxies are not configured", async () => { const res = await authorizeLocalDirect({ - token: "secret", - connectToken: "secret", + password: "secret", // pragma: allowlist secret + connectPassword: "secret", // pragma: allowlist secret trustedProxies: [], }); expect(res.ok).toBe(false); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 11af88b12b6..705c9e67b74 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -358,6 +358,28 @@ function authorizeTokenAuth(params: { return { ok: true, method: "token" }; } +function authorizePasswordAuth(params: { + authPassword?: string; + connectPassword?: string; + limiter?: AuthRateLimiter; + ip?: string; + rateLimitScope: string; +}): GatewayAuthResult { + if (!params.authPassword) { + return { ok: false, reason: "password_missing_config" }; + } + if (!params.connectPassword) { + // Same as token_missing — don't penalize absent credentials. + return { ok: false, reason: "password_missing" }; + } + if (!safeEqualSecret(params.connectPassword, params.authPassword)) { + params.limiter?.recordFailure(params.ip, params.rateLimitScope); + return { ok: false, reason: "password_mismatch" }; + } + params.limiter?.reset(params.ip, params.rateLimitScope); + return { ok: true, method: "password" }; +} + export async function authorizeGatewayConnect( params: AuthorizeGatewayConnectParams, ): Promise { @@ -439,6 +461,26 @@ async function authorizeGatewayConnectCore( } return { ok: true, method: "trusted-proxy", user: result.user }; } + if (localDirect && auth.password && connectAuth?.password) { + if (limiter) { + const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope); + if (!rlCheck.allowed) { + return { + ok: false, + reason: "rate_limited", + rateLimited: true, + retryAfterMs: rlCheck.retryAfterMs, + }; + } + } + return authorizePasswordAuth({ + authPassword: auth.password, + connectPassword: connectAuth.password, + limiter, + ip, + rateLimitScope, + }); + } return { ok: false, reason: result.reason }; } @@ -489,20 +531,13 @@ async function authorizeGatewayConnectCore( } if (auth.mode === "password") { - const password = connectAuth?.password; - if (!auth.password) { - return { ok: false, reason: "password_missing_config" }; - } - if (!password) { - // Same as token_missing — don't penalize absent credentials. - return { ok: false, reason: "password_missing" }; - } - if (!safeEqualSecret(password, auth.password)) { - limiter?.recordFailure(ip, rateLimitScope); - return { ok: false, reason: "password_mismatch" }; - } - limiter?.reset(ip, rateLimitScope); - return { ok: true, method: "password" }; + return authorizePasswordAuth({ + authPassword: auth.password, + connectPassword: connectAuth?.password, + limiter, + ip, + rateLimitScope, + }); } limiter?.recordFailure(ip, rateLimitScope); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 3c5a3150192..bae1c13e1a9 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -1266,31 +1266,72 @@ describe("callGateway password resolution", () => { await expect(callGateway({ method: "health" })).rejects.toThrow("gateway.auth.token"); }); - it.each(["none", "trusted-proxy"] as const)( - "ignores unresolved local password ref when auth mode is %s", - async (mode) => { - getRuntimeConfig.mockReturnValue({ - gateway: { - mode: "local", - bind: "loopback", - auth: { - mode, - password: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_PASSWORD" }, - }, + it("ignores unresolved local password ref when auth mode is none", async () => { + getRuntimeConfig.mockReturnValue({ + gateway: { + mode: "local", + bind: "loopback", + auth: { + mode: "none", + password: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_PASSWORD" }, }, - secrets: { - providers: { - default: { source: "env" }, - }, + }, + secrets: { + providers: { + default: { source: "env" }, }, - } as unknown as OpenClawConfig); + }, + } as unknown as OpenClawConfig); - await callGateway({ method: "health" }); + await callGateway({ method: "health" }); - expect(lastClientOptions?.token).toBeUndefined(); - expect(lastClientOptions?.password).toBeUndefined(); - }, - ); + expect(lastClientOptions?.token).toBeUndefined(); + expect(lastClientOptions?.password).toBeUndefined(); + }); + + it("resolves local password refs when auth mode is trusted-proxy", async () => { + process.env.LOCAL_TRUSTED_PROXY_PASSWORD = "resolved-trusted-proxy-password"; + getRuntimeConfig.mockReturnValue({ + gateway: { + mode: "local", + bind: "loopback", + auth: { + mode: "trusted-proxy", + password: { source: "env", provider: "default", id: "LOCAL_TRUSTED_PROXY_PASSWORD" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig); + + await callGateway({ method: "health" }); + + expect(lastClientOptions?.token).toBeUndefined(); + expect(lastClientOptions?.password).toBe("resolved-trusted-proxy-password"); // pragma: allowlist secret + }); + + it("fails closed when trusted-proxy local password ref cannot resolve", async () => { + getRuntimeConfig.mockReturnValue({ + gateway: { + mode: "local", + bind: "loopback", + auth: { + mode: "trusted-proxy", + password: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_PASSWORD" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig); + + await expect(callGateway({ method: "health" })).rejects.toThrow("gateway.auth.password"); + }); it("does not resolve local password ref when remote password is already configured", async () => { getRuntimeConfig.mockReturnValue({ @@ -1467,33 +1508,55 @@ describe("callGateway password resolution", () => { expect(lastClientOptions?.password).toBeUndefined(); }); - it.each(["none", "trusted-proxy"] as const)( - "does not resolve remote refs on non-remote gateway calls when auth mode is %s", - async (mode) => { - getRuntimeConfig.mockReturnValue({ - gateway: { - mode: "local", - bind: "loopback", - auth: { mode }, - remote: { - url: "wss://remote.example:18789", - token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" }, - password: { source: "env", provider: "default", id: "MISSING_REMOTE_PASSWORD" }, - }, + it("does not resolve remote refs on non-remote gateway calls when auth mode is none", async () => { + getRuntimeConfig.mockReturnValue({ + gateway: { + mode: "local", + bind: "loopback", + auth: { mode: "none" }, + remote: { + url: "wss://remote.example:18789", + token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" }, + password: { source: "env", provider: "default", id: "MISSING_REMOTE_PASSWORD" }, }, - secrets: { - providers: { - default: { source: "env" }, - }, + }, + secrets: { + providers: { + default: { source: "env" }, }, - } as unknown as OpenClawConfig); + }, + } as unknown as OpenClawConfig); - await callGateway({ method: "health" }); + await callGateway({ method: "health" }); - expect(lastClientOptions?.token).toBeUndefined(); - expect(lastClientOptions?.password).toBeUndefined(); - }, - ); + expect(lastClientOptions?.token).toBeUndefined(); + expect(lastClientOptions?.password).toBeUndefined(); + }); + + it("does not resolve remote refs on non-remote gateway calls when auth mode is trusted-proxy", async () => { + getRuntimeConfig.mockReturnValue({ + gateway: { + mode: "local", + bind: "loopback", + auth: { mode: "trusted-proxy" }, + remote: { + url: "wss://remote.example:18789", + token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" }, + password: { source: "env", provider: "default", id: "MISSING_REMOTE_PASSWORD" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig); + + await callGateway({ method: "health" }); + + expect(lastClientOptions?.token).toBeUndefined(); + expect(lastClientOptions?.password).toBeUndefined(); + }); it.each(explicitAuthCases)("uses explicit $label when url override is set", async (testCase) => { process.env[testCase.envKey] = testCase.envValue; diff --git a/src/gateway/credential-planner.ts b/src/gateway/credential-planner.ts index bbb29f865d3..6933506efee 100644 --- a/src/gateway/credential-planner.ts +++ b/src/gateway/credential-planner.ts @@ -125,7 +125,8 @@ export function createGatewayCredentialPlan(params: { const tokenCanWin = Boolean(envToken || localToken.configured || remoteToken.configured); const passwordCanWin = authMode === "password" || - (authMode !== "token" && authMode !== "none" && authMode !== "trusted-proxy" && !tokenCanWin); + authMode === "trusted-proxy" || + (authMode !== "token" && authMode !== "none" && !tokenCanWin); const localTokenSurfaceActive = localTokenCanWin && !envToken && diff --git a/src/gateway/credentials-secret-inputs.ts b/src/gateway/credentials-secret-inputs.ts index 37eb002674d..c68dc8a0b23 100644 --- a/src/gateway/credentials-secret-inputs.ts +++ b/src/gateway/credentials-secret-inputs.ts @@ -112,9 +112,12 @@ function localAuthModeAllowsGatewaySecretInputPath(params: { path: SupportedGatewaySecretInputPath; }): boolean { const { authMode, path } = params; - if (authMode === "none" || authMode === "trusted-proxy") { + if (authMode === "none") { return false; } + if (authMode === "trusted-proxy") { + return !isTokenGatewaySecretInputPath(path); + } if (authMode === "token") { return isTokenGatewaySecretInputPath(path); } diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index e791bb77a58..ef7b9560335 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -283,11 +283,29 @@ describe("resolveGatewayCredentialsFromConfig", () => { }); }); - it("ignores unresolved local password ref when local auth mode is trusted-proxy", () => { - const resolved = resolveLocalModeWithUnresolvedPassword("trusted-proxy"); + it("throws when trusted-proxy local password SecretRef cannot resolve", () => { + expect(() => resolveLocalModeWithUnresolvedPassword("trusted-proxy")).toThrow( + "gateway.auth.password", + ); + }); + + it("resolves trusted-proxy local password credentials", () => { + const resolved = resolveGatewayCredentialsFromConfig({ + cfg: cfg({ + gateway: { + mode: "local", + auth: { + mode: "trusted-proxy", + password: "local-trusted-proxy-password", // pragma: allowlist secret + }, + }, + }), + env: {} as NodeJS.ProcessEnv, + }); + expect(resolved).toEqual({ token: undefined, - password: undefined, + password: "local-trusted-proxy-password", // pragma: allowlist secret }); }); diff --git a/src/gateway/credentials.ts b/src/gateway/credentials.ts index 2414c362f3e..a95f791635b 100644 --- a/src/gateway/credentials.ts +++ b/src/gateway/credentials.ts @@ -120,10 +120,8 @@ function resolveLocalGatewayCredentials(params: { }); const localPasswordCanWin = params.plan.authMode === "password" || - (params.plan.authMode !== "token" && - params.plan.authMode !== "none" && - params.plan.authMode !== "trusted-proxy" && - !localResolved.token); + params.plan.authMode === "trusted-proxy" || + (params.plan.authMode !== "token" && params.plan.authMode !== "none" && !localResolved.token); const localTokenCanWin = params.plan.authMode === "token" || (params.plan.authMode !== "password" &&