feat(gateway)!: require explicit non-loopback control-ui origins

This commit is contained in:
Peter Steinberger
2026-02-24 01:52:15 +00:00
parent edfefdff7d
commit 223d7dc23d
19 changed files with 187 additions and 10 deletions

View File

@@ -2,14 +2,23 @@ import { describe, expect, it } from "vitest";
import { checkBrowserOrigin } from "./origin-check.js";
describe("checkBrowserOrigin", () => {
it("accepts same-origin host matches", () => {
it("accepts same-origin host matches only with legacy host-header fallback", () => {
const result = checkBrowserOrigin({
requestHost: "127.0.0.1:18789",
origin: "http://127.0.0.1:18789",
allowHostHeaderOriginFallback: true,
});
expect(result.ok).toBe(true);
});
it("rejects same-origin host matches when legacy host-header fallback is disabled", () => {
const result = checkBrowserOrigin({
requestHost: "gateway.example.com:18789",
origin: "https://gateway.example.com:18789",
});
expect(result.ok).toBe(false);
});
it("accepts loopback host mismatches for dev", () => {
const result = checkBrowserOrigin({
requestHost: "127.0.0.1:18789",

View File

@@ -25,6 +25,7 @@ export function checkBrowserOrigin(params: {
requestHost?: string;
origin?: string;
allowedOrigins?: string[];
allowHostHeaderOriginFallback?: boolean;
}): OriginCheckResult {
const parsedOrigin = parseOrigin(params.origin);
if (!parsedOrigin) {
@@ -39,7 +40,11 @@ export function checkBrowserOrigin(params: {
}
const requestHost = normalizeHostHeader(params.requestHost);
if (requestHost && parsedOrigin.host === requestHost) {
if (
params.allowHostHeaderOriginFallback === true &&
requestHost &&
parsedOrigin.host === requestHost
) {
return { ok: true };
}

View File

@@ -27,6 +27,7 @@ describe("resolveGatewayRuntimeConfig", () => {
bind: "lan" as const,
auth: TRUSTED_PROXY_AUTH,
trustedProxies: ["192.168.1.1"],
controlUi: { allowedOrigins: ["https://control.example.com"] },
},
},
expectedBindHost: "0.0.0.0",
@@ -90,7 +91,12 @@ describe("resolveGatewayRuntimeConfig", () => {
{
name: "lan binding without trusted proxies",
cfg: {
gateway: { bind: "lan" as const, auth: TRUSTED_PROXY_AUTH, trustedProxies: [] },
gateway: {
bind: "lan" as const,
auth: TRUSTED_PROXY_AUTH,
trustedProxies: [],
controlUi: { allowedOrigins: ["https://control.example.com"] },
},
},
expectedMessage:
"gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured",
@@ -121,7 +127,13 @@ describe("resolveGatewayRuntimeConfig", () => {
it.each([
{
name: "lan binding with token",
cfg: { gateway: { bind: "lan" as const, auth: TOKEN_AUTH } },
cfg: {
gateway: {
bind: "lan" as const,
auth: TOKEN_AUTH,
controlUi: { allowedOrigins: ["https://control.example.com"] },
},
},
expectedAuthMode: "token",
expectedBindHost: "0.0.0.0",
},
@@ -188,6 +200,36 @@ describe("resolveGatewayRuntimeConfig", () => {
expectedMessage,
);
});
it("rejects non-loopback control UI when allowed origins are missing", async () => {
await expect(
resolveGatewayRuntimeConfig({
cfg: {
gateway: {
bind: "lan",
auth: TOKEN_AUTH,
},
},
port: 18789,
}),
).rejects.toThrow("non-loopback Control UI requires gateway.controlUi.allowedOrigins");
});
it("allows non-loopback control UI without allowed origins when dangerous fallback is enabled", async () => {
const result = await resolveGatewayRuntimeConfig({
cfg: {
gateway: {
bind: "lan",
auth: TOKEN_AUTH,
controlUi: {
dangerouslyAllowHostHeaderOriginFallback: true,
},
},
},
port: 18789,
});
expect(result.bindHost).toBe("0.0.0.0");
});
});
describe("HTTP security headers", () => {

View File

@@ -115,6 +115,11 @@ export async function resolveGatewayRuntimeConfig(params: {
process.env.OPENCLAW_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
const trustedProxies = params.cfg.gateway?.trustedProxies ?? [];
const controlUiAllowedOrigins = (params.cfg.gateway?.controlUi?.allowedOrigins ?? [])
.map((value) => value.trim())
.filter(Boolean);
const dangerouslyAllowHostHeaderOriginFallback =
params.cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true;
assertGatewayAuthConfigured(resolvedAuth);
if (tailscaleMode === "funnel" && authMode !== "password") {
@@ -130,6 +135,16 @@ export async function resolveGatewayRuntimeConfig(params: {
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD)`,
);
}
if (
controlUiEnabled &&
!isLoopbackHost(bindHost) &&
controlUiAllowedOrigins.length === 0 &&
!dangerouslyAllowHostHeaderOriginFallback
) {
throw new Error(
"non-loopback Control UI requires gateway.controlUi.allowedOrigins (set explicit origins), or set gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true to use Host-header origin fallback mode",
);
}
if (authMode === "trusted-proxy") {
if (trustedProxies.length === 0) {

View File

@@ -334,6 +334,8 @@ export function attachGatewayWsMessageHandler(params: {
requestHost,
origin: requestOrigin,
allowedOrigins: configSnapshot.gateway?.controlUi?.allowedOrigins,
allowHostHeaderOriginFallback:
configSnapshot.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true,
});
if (!originCheck.ok) {
const errorMessage =