fix(gateway): enforce trusted-proxy HTTP origin checks (#58229)

* fix(gateway): enforce trusted-proxy HTTP origin checks

* Update CHANGELOG.md
This commit is contained in:
Vincent Koc 2026-03-31 19:49:26 +09:00 committed by GitHub
parent 9abcfdadf5
commit 6b3f99a11f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 228 additions and 4 deletions

View File

@ -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.

View File

@ -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",

View File

@ -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 };

View File

@ -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", () => {

View File

@ -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<boolean> {
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);

View File

@ -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({

View File

@ -49,6 +49,19 @@ export type AuthorizedGatewayHttpRequest = {
trustDeclaredOperatorScopes: boolean;
};
export function resolveHttpBrowserOriginPolicy(
req: IncomingMessage,
cfg = loadConfig(),
): NonNullable<Parameters<typeof authorizeHttpGatewayConnect>[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<AuthorizedGatewayHttpRequest | null> {
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);

View File

@ -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;
}

View File

@ -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;