diff --git a/CHANGELOG.md b/CHANGELOG.md index edd5a441b38..945b5c4f9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -294,6 +294,7 @@ Docs: https://docs.openclaw.ai - Memory/SQLite contention resilience: re-apply `PRAGMA busy_timeout` on every sync-store and QMD connection open so process restarts/reopens no longer revert to immediate `SQLITE_BUSY` failures under lock contention. (#39183) Thanks @MumuTW. - Gateway/webchat route safety: block webchat/control-ui clients from inheriting stored external delivery routes on channel-scoped sessions (while preserving route inheritance for UI/TUI clients), preventing cross-channel leakage from scoped chats. (#39175) Thanks @widingmarcus-cyber. - Telegram error-surface resilience: return a user-visible fallback reply when dispatch/debounce processing fails instead of going silent, while preserving draft-stream cleanup and best-effort thread-scoped fallback delivery. (#39209) Thanks @riftzen-bit. +- Gateway/password auth startup diagnostics: detect unresolved provider-reference objects in `gateway.auth.password` and fail with a specific bootstrap-secrets error message instead of generic misconfiguration output. (#39230) Thanks @ademczuk. ## 2026.3.2 diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index cb85fca810c..f4efebf0339 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import { + assertGatewayAuthConfigured, authorizeGatewayConnect, authorizeHttpGatewayConnect, authorizeWsControlUiGatewayConnect, @@ -367,7 +368,6 @@ describe("gateway auth", () => { expect(limiter.check).toHaveBeenCalledWith(undefined, "custom-scope"); expect(limiter.recordFailure).toHaveBeenCalledWith(undefined, "custom-scope"); }); - it("does not record rate-limit failure for missing token (misconfigured client, not brute-force)", async () => { const limiter = createLimiterSpy(); const res = await authorizeGatewayConnect({ @@ -419,6 +419,27 @@ describe("gateway auth", () => { expect(res.reason).toBe("password_mismatch"); expect(limiter.recordFailure).toHaveBeenCalled(); }); + it("throws specific error when password is a provider reference object", () => { + const auth = resolveGatewayAuth({ + authConfig: { + mode: "password", + password: { source: "exec", provider: "op", id: "pw" } as never, + }, + }); + expect(() => + assertGatewayAuthConfigured(auth, { + mode: "password", + password: { source: "exec", provider: "op", id: "pw" } as never, + }), + ).toThrow(/provider reference object/); + }); + + it("throws generic error when password mode has no password at all", () => { + const auth = resolveGatewayAuth({ authConfig: { mode: "password" } }); + expect(() => assertGatewayAuthConfigured(auth, { mode: "password" })).toThrow( + "gateway auth mode is password, but no password was configured", + ); + }); }); describe("trusted-proxy auth", () => { diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index cb1673778f4..78ca456a53c 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -291,7 +291,10 @@ export function resolveGatewayAuth(params: { }; } -export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void { +export function assertGatewayAuthConfigured( + auth: ResolvedGatewayAuth, + rawAuthConfig?: GatewayAuthConfig | null, +): void { if (auth.mode === "token" && !auth.token) { if (auth.allowTailscale) { return; @@ -301,6 +304,11 @@ export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void { ); } if (auth.mode === "password" && !auth.password) { + if (rawAuthConfig?.password != null && typeof rawAuthConfig.password !== "string") { + throw new Error( + "gateway auth mode is password, but gateway.auth.password contains a provider reference object instead of a resolved string — bootstrap secrets (gateway.auth.password) must be plaintext strings or set via the OPENCLAW_GATEWAY_PASSWORD environment variable because the secrets provider system has not initialised yet at gateway startup", + ); + } throw new Error("gateway auth mode is password, but no password was configured"); } if (auth.mode === "trusted-proxy") { diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index 2722d36acd7..6262208eeaf 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -121,7 +121,7 @@ export async function resolveGatewayRuntimeConfig(params: { const dangerouslyAllowHostHeaderOriginFallback = params.cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true; - assertGatewayAuthConfigured(resolvedAuth); + assertGatewayAuthConfigured(resolvedAuth, params.cfg.gateway?.auth); if (tailscaleMode === "funnel" && authMode !== "password") { throw new Error( "tailscale funnel requires gateway auth mode=password (set gateway.auth.password or OPENCLAW_GATEWAY_PASSWORD)",