feat(gateway)!: require explicit non-loopback control-ui origins
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user