mirror of https://github.com/openclaw/openclaw.git
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:
parent
9abcfdadf5
commit
6b3f99a11f
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue