From 9d6b4ba3fac6eefa253a2d72aa6ffa147315a765 Mon Sep 17 00:00:00 2001 From: Dobbie Date: Thu, 12 Mar 2026 12:40:06 +0000 Subject: [PATCH 1/2] Gateway: support auth token from trusted proxy headers Browser clients cannot send custom headers during WebSocket connections. This prevents them from authenticating via X-OpenClaw-Token header when connecting through a reverse proxy like nginx. This change allows the gateway to extract the auth token from HTTP upgrade request headers when the connection comes from a trusted proxy. The proxy injects X-OpenClaw-Token (or Authorization: Bearer) header, which is then used to satisfy sharedAuthOk for the device identity bypass when dangerouslyDisableDeviceAuth is configured. Security model: - Only trusted proxies (gateway.trustedProxies config) can inject tokens - IP-based validation prevents header spoofing from untrusted sources - Works with existing dangerouslyDisableDeviceAuth + sharedAuthOk flow Co-Authored-By: Claude --- .../server/ws-connection/auth-context.ts | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/gateway/server/ws-connection/auth-context.ts b/src/gateway/server/ws-connection/auth-context.ts index bf5d3a25f1f..fd9e4969a8b 100644 --- a/src/gateway/server/ws-connection/auth-context.ts +++ b/src/gateway/server/ws-connection/auth-context.ts @@ -11,6 +11,7 @@ import { type GatewayAuthResult, type ResolvedGatewayAuth, } from "../../auth.js"; +import { isTrustedProxyAddress } from "../../net.js"; type HandshakeConnectAuth = { token?: string; @@ -49,10 +50,32 @@ function trimToUndefined(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +/** + * Extract token from WebSocket upgrade request headers. + * This allows nginx proxy to inject X-OpenClaw-Token header for browser clients + * that cannot send custom WebSocket headers. + */ +function extractHeaderToken(req: IncomingMessage): string | undefined { + const headerToken = req.headers["x-openclaw-token"]; + if (typeof headerToken === "string" && headerToken.trim()) { + return headerToken.trim(); + } + // Also check Authorization: Bearer header + const auth = req.headers.authorization; + if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) { + const token = auth.slice(7).trim(); + if (token) { + return token; + } + } + return undefined; +} + function resolveSharedConnectAuth( connectAuth: HandshakeConnectAuth | null | undefined, + headerToken?: string, ): { token?: string; password?: string } | undefined { - const token = trimToUndefined(connectAuth?.token); + const token = trimToUndefined(connectAuth?.token) ?? headerToken; const password = trimToUndefined(connectAuth?.password); if (!token && !password) { return undefined; @@ -91,7 +114,15 @@ export async function resolveConnectAuthState(params: { rateLimiter?: AuthRateLimiter; clientIp?: string; }): Promise { - const sharedConnectAuth = resolveSharedConnectAuth(params.connectAuth); + // Check if request comes from a trusted proxy - if so, we can use header token + const remoteAddr = params.req.socket?.remoteAddress; + const isFromTrustedProxy = isTrustedProxyAddress(remoteAddr, params.trustedProxies); + + // Extract token from HTTP headers (for browser clients behind trusted proxy) + // Only use header token when request comes from a trusted proxy to prevent spoofing + const headerToken = isFromTrustedProxy ? extractHeaderToken(params.req) : undefined; + + const sharedConnectAuth = resolveSharedConnectAuth(params.connectAuth, headerToken); const sharedAuthProvided = Boolean(sharedConnectAuth); const bootstrapTokenCandidate = params.hasDeviceIdentity ? resolveBootstrapTokenCandidate(params.connectAuth) From 1f96a47f2a0194decda52b430b700c0c033347a3 Mon Sep 17 00:00:00 2001 From: Dobbie Date: Thu, 12 Mar 2026 15:08:53 +0000 Subject: [PATCH 2/2] Address PR review feedback - Remove Authorization: Bearer extraction to avoid spurious rate-limit hits from unrelated OAuth/OIDC tokens forwarded by proxy - Add JSDoc documenting that header token feature requires dangerouslyDisableDeviceAuth: true (token not used as device candidate) --- .../server/ws-connection/auth-context.ts | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/gateway/server/ws-connection/auth-context.ts b/src/gateway/server/ws-connection/auth-context.ts index fd9e4969a8b..0a4ac195af5 100644 --- a/src/gateway/server/ws-connection/auth-context.ts +++ b/src/gateway/server/ws-connection/auth-context.ts @@ -52,22 +52,18 @@ function trimToUndefined(value: string | undefined): string | undefined { /** * Extract token from WebSocket upgrade request headers. - * This allows nginx proxy to inject X-OpenClaw-Token header for browser clients - * that cannot send custom WebSocket headers. + * This allows a trusted reverse proxy to inject X-OpenClaw-Token header + * for browser clients that cannot send custom WebSocket headers. + * + * Note: Only X-OpenClaw-Token is extracted, not Authorization: Bearer. + * The standard Authorization header may contain unrelated OAuth/OIDC tokens + * that would cause spurious rate-limit hits if treated as gateway auth. */ function extractHeaderToken(req: IncomingMessage): string | undefined { const headerToken = req.headers["x-openclaw-token"]; if (typeof headerToken === "string" && headerToken.trim()) { return headerToken.trim(); } - // Also check Authorization: Bearer header - const auth = req.headers.authorization; - if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) { - const token = auth.slice(7).trim(); - if (token) { - return token; - } - } return undefined; } @@ -104,6 +100,19 @@ function resolveBootstrapTokenCandidate( return trimToUndefined(connectAuth?.bootstrapToken); } +/** + * Resolve auth state for a WebSocket connect handshake. + * + * When the request comes from a trusted proxy (gateway.trustedProxies), + * extracts X-OpenClaw-Token header and uses it as shared auth. This enables + * browser clients behind a reverse proxy to authenticate without device pairing + * when gateway.controlUi.dangerouslyDisableDeviceAuth is enabled. + * + * Limitations: + * - Header token is only used as shared auth, not as device token candidate. + * Browser clients authing solely via header need dangerouslyDisableDeviceAuth: true + * or must pass primary auth (token/password) via authorizeWsControlUiGatewayConnect. + */ export async function resolveConnectAuthState(params: { resolvedAuth: ResolvedGatewayAuth; connectAuth: HandshakeConnectAuth | null | undefined;