From 6b3f99a11f4d070fa5ed2533abbb3d7329ea4f0d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 31 Mar 2026 19:49:26 +0900 Subject: [PATCH] fix(gateway): enforce trusted-proxy HTTP origin checks (#58229) * fix(gateway): enforce trusted-proxy HTTP origin checks * Update CHANGELOG.md --- CHANGELOG.md | 1 + src/gateway/auth.test.ts | 93 +++++++++++++++++++ src/gateway/auth.ts | 41 ++++++++ src/gateway/http-auth-helpers.test.ts | 29 +++++- src/gateway/http-auth-helpers.ts | 4 +- .../http-utils.authorize-request.test.ts | 43 +++++++++ src/gateway/http-utils.ts | 15 +++ src/gateway/server-http.ts | 3 +- src/gateway/server/http-auth.ts | 3 +- 9 files changed, 228 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1284d9c538..f23946038ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/auth: reject mismatched browser `Origin` headers on trusted-proxy HTTP operator requests while keeping origin-less headless proxy clients working. Thanks @AntAISecurityLab and @vincentkoc. - Plugins/startup: block workspace `.env` from overriding `OPENCLAW_BUNDLED_PLUGINS_DIR`, so bundled plugin trust roots only come from inherited runtime env or package resolution instead of repo-local dotenv files. Thanks @nexrin and @vincentkoc. - Image generation/build: write stable runtime alias files into `dist/` and route provider-auth runtime lookups through those aliases so image-generation providers keep resolving auth/runtime modules after rebuilds instead of crashing on missing hashed chunk files. - Config/runtime: pin the first successful config load in memory for the running process and refresh that snapshot on successful writes/reloads, so hot paths stop reparsing `openclaw.json` between watcher-driven swaps. diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index f3d943a0b61..e87ed80a2d0 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -491,6 +491,99 @@ describe("trusted-proxy auth", () => { expect(res.user).toBe("nick@example.com"); }); + it("rejects trusted-proxy HTTP requests from origins outside the allowlist", async () => { + await expect( + authorizeHttpGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + }, + connectAuth: null, + trustedProxies: ["10.0.0.1"], + req: { + socket: { remoteAddress: "10.0.0.1" }, + headers: { + host: "gateway.example.com", + origin: "https://evil.example", + "x-forwarded-user": "nick@example.com", + "x-forwarded-proto": "https", + }, + } as never, + browserOriginPolicy: { + requestHost: "gateway.example.com", + origin: "https://evil.example", + allowedOrigins: ["https://control.example.com"], + }, + }), + ).resolves.toEqual({ + ok: false, + reason: "trusted_proxy_origin_not_allowed", + }); + }); + + it("accepts trusted-proxy HTTP requests from allowed origins", async () => { + await expect( + authorizeHttpGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + }, + connectAuth: null, + trustedProxies: ["10.0.0.1"], + req: { + socket: { remoteAddress: "10.0.0.1" }, + headers: { + host: "gateway.example.com", + origin: "https://control.example.com", + "x-forwarded-user": "nick@example.com", + "x-forwarded-proto": "https", + }, + } as never, + browserOriginPolicy: { + requestHost: "gateway.example.com", + origin: "https://control.example.com", + allowedOrigins: ["https://control.example.com"], + }, + }), + ).resolves.toMatchObject({ + ok: true, + method: "trusted-proxy", + user: "nick@example.com", + }); + }); + + it("keeps origin-less trusted-proxy HTTP requests working", async () => { + await expect( + authorizeHttpGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + }, + connectAuth: null, + trustedProxies: ["10.0.0.1"], + req: { + socket: { remoteAddress: "10.0.0.1" }, + headers: { + host: "gateway.example.com", + "x-forwarded-user": "nick@example.com", + "x-forwarded-proto": "https", + }, + } as never, + browserOriginPolicy: { + requestHost: "gateway.example.com", + allowedOrigins: ["https://control.example.com"], + }, + }), + ).resolves.toMatchObject({ + ok: true, + method: "trusted-proxy", + user: "nick@example.com", + }); + }); + it("rejects request from untrusted source", async () => { const res = await authorizeTrustedProxy({ remoteAddress: "192.168.1.100", diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 51d666c3e48..09cc50a7ff7 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -19,6 +19,7 @@ import { isTrustedProxyAddress, resolveClientIp, } from "./net.js"; +import { checkBrowserOrigin } from "./origin-check.js"; export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; export type ResolvedGatewayAuthModeSource = @@ -81,6 +82,13 @@ export type AuthorizeGatewayConnectParams = { rateLimitScope?: string; /** Trust X-Real-IP only when explicitly enabled. */ allowRealIpFallback?: boolean; + /** Optional browser-origin policy for trusted-proxy HTTP requests. */ + browserOriginPolicy?: { + requestHost?: string; + origin?: string; + allowedOrigins?: string[]; + allowHostHeaderOriginFallback?: boolean; + }; }; type TailscaleUser = { @@ -367,6 +375,32 @@ function shouldAllowTailscaleHeaderAuth(authSurface: GatewayAuthSurface): boolea return authSurface === "ws-control-ui"; } +function authorizeTrustedProxyBrowserOrigin(params: { + authSurface: GatewayAuthSurface; + browserOriginPolicy?: AuthorizeGatewayConnectParams["browserOriginPolicy"]; +}): { ok: false; reason: string } | null { + if (params.authSurface !== "http") { + return null; + } + + const origin = params.browserOriginPolicy?.origin?.trim(); + if (!origin) { + return null; + } + + const originCheck = checkBrowserOrigin({ + requestHost: params.browserOriginPolicy?.requestHost, + origin, + allowedOrigins: params.browserOriginPolicy?.allowedOrigins, + allowHostHeaderOriginFallback: params.browserOriginPolicy?.allowHostHeaderOriginFallback, + isLocalClient: false, + }); + if (originCheck.ok) { + return null; + } + return { ok: false, reason: "trusted_proxy_origin_not_allowed" }; +} + function authorizeTokenAuth(params: { authToken?: string; connectToken?: string; @@ -452,6 +486,13 @@ export async function authorizeGatewayConnect( }); if ("user" in result) { + const originResult = authorizeTrustedProxyBrowserOrigin({ + authSurface, + browserOriginPolicy: params.browserOriginPolicy, + }); + if (originResult) { + return originResult; + } return { ok: true, method: "trusted-proxy", user: result.user }; } return { ok: false, reason: result.reason }; diff --git a/src/gateway/http-auth-helpers.test.ts b/src/gateway/http-auth-helpers.test.ts index bbf0227de05..33c659e5c9a 100644 --- a/src/gateway/http-auth-helpers.test.ts +++ b/src/gateway/http-auth-helpers.test.ts @@ -18,11 +18,18 @@ vi.mock("./http-common.js", () => ({ vi.mock("./http-utils.js", () => ({ getBearerToken: vi.fn(), getHeader: vi.fn(), + resolveHttpBrowserOriginPolicy: vi.fn(() => ({ + requestHost: "gateway.example.com", + origin: "https://evil.example", + allowedOrigins: ["https://control.example.com"], + allowHostHeaderOriginFallback: false, + })), })); const { authorizeHttpGatewayConnect } = await import("./auth.js"); const { sendGatewayAuthFailure } = await import("./http-common.js"); -const { getBearerToken, getHeader } = await import("./http-utils.js"); +const { getBearerToken, getHeader, resolveHttpBrowserOriginPolicy } = + await import("./http-utils.js"); describe("authorizeGatewayBearerRequestOrReply", () => { const bearerAuth = { @@ -74,6 +81,26 @@ describe("authorizeGatewayBearerRequestOrReply", () => { ); expect(vi.mocked(sendGatewayAuthFailure)).not.toHaveBeenCalled(); }); + + it("forwards browser-origin policy into HTTP auth", async () => { + const params = makeAuthorizeParams(); + vi.mocked(getBearerToken).mockReturnValue(undefined); + vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({ ok: true, method: "trusted-proxy" }); + + await authorizeGatewayBearerRequestOrReply(params); + + expect(vi.mocked(resolveHttpBrowserOriginPolicy)).toHaveBeenCalledWith(params.req); + expect(vi.mocked(authorizeHttpGatewayConnect)).toHaveBeenCalledWith( + expect.objectContaining({ + browserOriginPolicy: { + requestHost: "gateway.example.com", + origin: "https://evil.example", + allowedOrigins: ["https://control.example.com"], + allowHostHeaderOriginFallback: false, + }, + }), + ); + }); }); describe("resolveGatewayRequestedOperatorScopes", () => { diff --git a/src/gateway/http-auth-helpers.ts b/src/gateway/http-auth-helpers.ts index 1ca21a67155..807879f8eff 100644 --- a/src/gateway/http-auth-helpers.ts +++ b/src/gateway/http-auth-helpers.ts @@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; import { sendGatewayAuthFailure } from "./http-common.js"; -import { getBearerToken, getHeader } from "./http-utils.js"; +import { getBearerToken, getHeader, resolveHttpBrowserOriginPolicy } from "./http-utils.js"; import { CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js"; const OPERATOR_SCOPES_HEADER = "x-openclaw-scopes"; @@ -16,6 +16,7 @@ export async function authorizeGatewayBearerRequestOrReply(params: { rateLimiter?: AuthRateLimiter; }): Promise { const token = getBearerToken(params.req); + const browserOriginPolicy = resolveHttpBrowserOriginPolicy(params.req); const authResult = await authorizeHttpGatewayConnect({ auth: params.auth, connectAuth: token ? { token, password: token } : null, @@ -23,6 +24,7 @@ export async function authorizeGatewayBearerRequestOrReply(params: { trustedProxies: params.trustedProxies, allowRealIpFallback: params.allowRealIpFallback, rateLimiter: params.rateLimiter, + browserOriginPolicy, }); if (!authResult.ok) { sendGatewayAuthFailure(params.res, authResult); diff --git a/src/gateway/http-utils.authorize-request.test.ts b/src/gateway/http-utils.authorize-request.test.ts index 0644c43e8e9..c6b5c901d09 100644 --- a/src/gateway/http-utils.authorize-request.test.ts +++ b/src/gateway/http-utils.authorize-request.test.ts @@ -5,6 +5,16 @@ vi.mock("./auth.js", () => ({ authorizeHttpGatewayConnect: vi.fn(), })); +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn(() => ({ + gateway: { + controlUi: { + allowedOrigins: ["https://control.example.com"], + }, + }, + })), +})); + vi.mock("./http-common.js", () => ({ sendGatewayAuthFailure: vi.fn(), })); @@ -66,6 +76,39 @@ describe("authorizeGatewayHttpRequestOrReply", () => { }); }); + it("forwards browser-origin policy into HTTP auth", async () => { + vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({ + ok: true, + method: "trusted-proxy", + user: "operator", + }); + + await authorizeGatewayHttpRequestOrReply({ + req: createReq({ + host: "gateway.example.com", + origin: "https://evil.example", + }), + res: {} as ServerResponse, + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { userHeader: "x-user" }, + }, + trustedProxies: ["127.0.0.1"], + }); + + expect(vi.mocked(authorizeHttpGatewayConnect)).toHaveBeenCalledWith( + expect.objectContaining({ + browserOriginPolicy: { + requestHost: "gateway.example.com", + origin: "https://evil.example", + allowedOrigins: ["https://control.example.com"], + allowHostHeaderOriginFallback: false, + }, + }), + ); + }); + it("replies with auth failure and returns null when auth fails", async () => { const res = {} as ServerResponse; vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({ diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index 8fe8637f162..bef70d00f2c 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -49,6 +49,19 @@ export type AuthorizedGatewayHttpRequest = { trustDeclaredOperatorScopes: boolean; }; +export function resolveHttpBrowserOriginPolicy( + req: IncomingMessage, + cfg = loadConfig(), +): NonNullable[0]["browserOriginPolicy"]> { + return { + requestHost: getHeader(req, "host"), + origin: getHeader(req, "origin"), + allowedOrigins: cfg.gateway?.controlUi?.allowedOrigins, + allowHostHeaderOriginFallback: + cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true, + }; +} + function usesSharedSecretHttpAuth(auth: SharedSecretGatewayAuth | undefined): boolean { return auth?.mode === "token" || auth?.mode === "password"; } @@ -79,6 +92,7 @@ export async function authorizeGatewayHttpRequestOrReply(params: { rateLimiter?: AuthRateLimiter; }): Promise { const token = getBearerToken(params.req); + const browserOriginPolicy = resolveHttpBrowserOriginPolicy(params.req); const authResult = await authorizeHttpGatewayConnect({ auth: params.auth, connectAuth: token ? { token, password: token } : null, @@ -86,6 +100,7 @@ export async function authorizeGatewayHttpRequestOrReply(params: { trustedProxies: params.trustedProxies, allowRealIpFallback: params.allowRealIpFallback, rateLimiter: params.rateLimiter, + browserOriginPolicy, }); if (!authResult.ok) { sendGatewayAuthFailure(params.res, authResult); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index f4b9a2f6cff..4bde6aed8fa 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -55,7 +55,7 @@ import { resolveHookDeliver, } from "./hooks.js"; import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js"; -import { getBearerToken } from "./http-utils.js"; +import { getBearerToken, resolveHttpBrowserOriginPolicy } from "./http-utils.js"; import { handleOpenAiModelsHttpRequest } from "./models-http.js"; import { resolveRequestClientIp } from "./net.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; @@ -217,6 +217,7 @@ async function canRevealReadinessDetails(params: { req: params.req, trustedProxies: params.trustedProxies, allowRealIpFallback: params.allowRealIpFallback, + browserOriginPolicy: resolveHttpBrowserOriginPolicy(params.req), }); return authResult.ok; } diff --git a/src/gateway/server/http-auth.ts b/src/gateway/server/http-auth.ts index e459a4606a4..76ee19a8f16 100644 --- a/src/gateway/server/http-auth.ts +++ b/src/gateway/server/http-auth.ts @@ -9,7 +9,7 @@ import { } from "../auth.js"; import { CANVAS_CAPABILITY_TTL_MS } from "../canvas-capability.js"; import { authorizeGatewayBearerRequestOrReply } from "../http-auth-helpers.js"; -import { getBearerToken } from "../http-utils.js"; +import { getBearerToken, resolveHttpBrowserOriginPolicy } from "../http-utils.js"; import { GATEWAY_CLIENT_MODES, normalizeGatewayClientMode } from "../protocol/client-info.js"; import type { GatewayWsClient } from "./ws-types.js"; @@ -88,6 +88,7 @@ export async function authorizeCanvasRequest(params: { trustedProxies, allowRealIpFallback, rateLimiter, + browserOriginPolicy: resolveHttpBrowserOriginPolicy(req), }); if (authResult.ok) { return authResult;