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

@@ -101,6 +101,7 @@ const TARGET_KEYS = [
"models.providers.*.auth",
"models.providers.*.authHeader",
"gateway.reload.mode",
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback",
"gateway.controlUi.allowInsecureAuth",
"gateway.controlUi.dangerouslyDisableDeviceAuth",
"cron",

View File

@@ -300,7 +300,9 @@ export const FIELD_HELP: Record<string, string> = {
"gateway.controlUi.root":
"Optional filesystem root for Control UI assets (defaults to dist/control-ui).",
"gateway.controlUi.allowedOrigins":
"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com).",
"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.",
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback":
"DANGEROUS toggle that enables Host-header based origin fallback for Control UI/WebChat websocket checks. This mode is supported when your deployment intentionally relies on Host-header origin policy; explicit gateway.controlUi.allowedOrigins remains the recommended hardened default.",
"gateway.controlUi.allowInsecureAuth":
"Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.",
"gateway.controlUi.dangerouslyDisableDeviceAuth":

View File

@@ -238,6 +238,8 @@ export const FIELD_LABELS: Record<string, string> = {
"gateway.controlUi.basePath": "Control UI Base Path",
"gateway.controlUi.root": "Control UI Assets Root",
"gateway.controlUi.allowedOrigins": "Control UI Allowed Origins",
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback":
"Dangerously Allow Host-Header Origin Fallback",
"gateway.controlUi.allowInsecureAuth": "Insecure Control UI Auth Toggle",
"gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth",
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",

View File

@@ -41,6 +41,12 @@ const TAG_PRIORITY: Record<ConfigTag, number> = {
const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
"gateway.auth.token": ["security", "auth", "access", "network"],
"gateway.auth.password": ["security", "auth", "access", "network"],
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": [
"security",
"access",
"network",
"advanced",
],
"gateway.controlUi.dangerouslyDisableDeviceAuth": ["security", "access", "network", "advanced"],
"gateway.controlUi.allowInsecureAuth": ["security", "access", "network", "advanced"],
"tools.exec.applyPatch.workspaceOnly": ["tools", "security", "access", "advanced"],

View File

@@ -70,6 +70,11 @@ export type GatewayControlUiConfig = {
root?: string;
/** Allowed browser origins for Control UI/WebChat websocket connections. */
allowedOrigins?: string[];
/**
* DANGEROUS: Keep Host-header origin fallback behavior.
* Supported long-term for deployments that intentionally rely on this policy.
*/
dangerouslyAllowHostHeaderOriginFallback?: boolean;
/**
* Insecure-auth toggle.
* Control UI still requires secure context + device identity unless

View File

@@ -454,6 +454,7 @@ export const OpenClawSchema = z
basePath: z.string().optional(),
root: z.string().optional(),
allowedOrigins: z.array(z.string()).optional(),
dangerouslyAllowHostHeaderOriginFallback: z.boolean().optional(),
allowInsecureAuth: z.boolean().optional(),
dangerouslyDisableDeviceAuth: z.boolean().optional(),
})

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 =

View File

@@ -1136,6 +1136,38 @@ describe("security audit", () => {
expect(finding?.detail).toContain("tools.exec.applyPatch.workspaceOnly=false");
});
it("flags non-loopback Control UI without allowed origins", async () => {
const cfg: OpenClawConfig = {
gateway: {
bind: "lan",
auth: { mode: "token", token: "very-long-browser-token-0123456789" },
},
};
const res = await audit(cfg);
expectFinding(res, "gateway.control_ui.allowed_origins_required", "critical");
});
it("flags dangerous host-header origin fallback and suppresses missing allowed-origins finding", async () => {
const cfg: OpenClawConfig = {
gateway: {
bind: "lan",
auth: { mode: "token", token: "very-long-browser-token-0123456789" },
controlUi: {
dangerouslyAllowHostHeaderOriginFallback: true,
},
},
};
const res = await audit(cfg);
expectFinding(res, "gateway.control_ui.host_header_origin_fallback", "critical");
expectNoFinding(res, "gateway.control_ui.allowed_origins_required");
const flags = res.findings.find((f) => f.checkId === "config.insecure_or_dangerous_flags");
expect(flags?.detail ?? "").toContain(
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true",
);
});
it("scores X-Real-IP fallback risk by gateway exposure", async () => {
const trustedProxyCfg = (trustedProxies: string[]): OpenClawConfig => ({
gateway: {

View File

@@ -266,6 +266,11 @@ function collectGatewayConfigFindings(
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env });
const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false;
const controlUiAllowedOrigins = (cfg.gateway?.controlUi?.allowedOrigins ?? [])
.map((value) => value.trim())
.filter(Boolean);
const dangerouslyAllowHostHeaderOriginFallback =
cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true;
const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
? cfg.gateway.trustedProxies
: [];
@@ -340,6 +345,37 @@ function collectGatewayConfigFindings(
remediation: "Set gateway.auth (token recommended) or keep the Control UI local-only.",
});
}
if (
bind !== "loopback" &&
controlUiEnabled &&
controlUiAllowedOrigins.length === 0 &&
!dangerouslyAllowHostHeaderOriginFallback
) {
findings.push({
checkId: "gateway.control_ui.allowed_origins_required",
severity: "critical",
title: "Non-loopback Control UI missing explicit allowed origins",
detail:
"Control UI is enabled on a non-loopback bind but gateway.controlUi.allowedOrigins is empty. " +
"Strict origin policy requires explicit allowed origins for non-loopback deployments.",
remediation:
"Set gateway.controlUi.allowedOrigins to full trusted origins (for example https://control.example.com). " +
"If your deployment intentionally relies on Host-header origin fallback, set gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true.",
});
}
if (dangerouslyAllowHostHeaderOriginFallback) {
const exposed = bind !== "loopback";
findings.push({
checkId: "gateway.control_ui.host_header_origin_fallback",
severity: exposed ? "critical" : "warn",
title: "DANGEROUS: Host-header origin fallback enabled",
detail:
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true enables Host-header origin fallback " +
"for Control UI/WebChat websocket checks and weakens DNS rebinding protections.",
remediation:
"Disable gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback and configure explicit gateway.controlUi.allowedOrigins.",
});
}
if (allowRealIpFallback) {
const hasNonLoopbackTrustedProxy = trustedProxies.some(

View File

@@ -5,6 +5,9 @@ export function collectEnabledInsecureOrDangerousFlags(cfg: OpenClawConfig): str
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
enabledFlags.push("gateway.controlUi.allowInsecureAuth=true");
}
if (cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true) {
enabledFlags.push("gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true");
}
if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) {
enabledFlags.push("gateway.controlUi.dangerouslyDisableDeviceAuth=true");
}