From 9d6b4ba3fac6eefa253a2d72aa6ffa147315a765 Mon Sep 17 00:00:00 2001 From: Dobbie Date: Thu, 12 Mar 2026 12:40:06 +0000 Subject: [PATCH] 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)