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

@@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai
## Unreleased ## Unreleased
### Breaking
- **BREAKING:** non-loopback Control UI now requires explicit `gateway.controlUi.allowedOrigins` (full origins). Startup fails closed when missing unless `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set to use Host-header origin fallback mode.
### Fixes ### Fixes
- Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution. - Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution.

View File

@@ -2097,6 +2097,8 @@ See [Plugins](/tools/plugin).
enabled: true, enabled: true,
basePath: "/openclaw", basePath: "/openclaw",
// root: "dist/control-ui", // root: "dist/control-ui",
// allowedOrigins: ["https://control.example.com"], // required for non-loopback Control UI
// dangerouslyAllowHostHeaderOriginFallback: false, // dangerous Host-header origin fallback mode
// allowInsecureAuth: false, // allowInsecureAuth: false,
// dangerouslyDisableDeviceAuth: false, // dangerouslyDisableDeviceAuth: false,
}, },
@@ -2131,6 +2133,8 @@ See [Plugins](/tools/plugin).
- `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`. - `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`.
- `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments). - `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments).
- `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth). - `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth).
- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Control UI/WebChat WebSocket connects. Required when Control UI is reachable on non-loopback binds.
- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy.
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`. - `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
- `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth. - `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth.
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control. - `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.

View File

@@ -216,6 +216,8 @@ High-signal `checkId` values you will most likely see in real deployments (not e
| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no | | `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no |
| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no | | `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no |
| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no | | `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no |
| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no |
| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no | | `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no |
| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no | | `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no |
| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no | | `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no |
@@ -252,6 +254,7 @@ keep it off unless you are actively debugging and can revert quickly.
`openclaw security audit` includes `config.insecure_or_dangerous_flags` when any `openclaw security audit` includes `config.insecure_or_dangerous_flags` when any
insecure/dangerous debug switches are enabled. This warning aggregates the exact insecure/dangerous debug switches are enabled. This warning aggregates the exact
keys so you can review them in one place (for example keys so you can review them in one place (for example
`gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true`,
`gateway.controlUi.allowInsecureAuth=true`, `gateway.controlUi.allowInsecureAuth=true`,
`gateway.controlUi.dangerouslyDisableDeviceAuth=true`, `gateway.controlUi.dangerouslyDisableDeviceAuth=true`,
`hooks.gmail.allowUnsafeExternalContent=true`, or `hooks.gmail.allowUnsafeExternalContent=true`, or
@@ -295,7 +298,8 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- OpenClaw gateway is local/loopback first. If you terminate TLS at a reverse proxy, set HSTS on the proxy-facing HTTPS domain there. - OpenClaw gateway is local/loopback first. If you terminate TLS at a reverse proxy, set HSTS on the proxy-facing HTTPS domain there.
- If the gateway itself terminates HTTPS, you can set `gateway.http.securityHeaders.strictTransportSecurity` to emit the HSTS header from OpenClaw responses. - If the gateway itself terminates HTTPS, you can set `gateway.http.securityHeaders.strictTransportSecurity` to emit the HSTS header from OpenClaw responses.
- Detailed deployment guidance is in [Trusted Proxy Auth](/gateway/trusted-proxy-auth#tls-termination-and-hsts). - Detailed deployment guidance is in [Trusted Proxy Auth](/gateway/trusted-proxy-auth#tls-termination-and-hsts).
- For non-loopback Control UI deployments, explicitly configure `gateway.controlUi.allowedOrigins` instead of relying on permissive defaults. - For non-loopback Control UI deployments, `gateway.controlUi.allowedOrigins` is required by default.
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode; treat it as a dangerous operator-selected policy.
- Treat DNS rebinding and proxy-host header behavior as deployment hardening concerns; keep `trustedProxies` tight and avoid exposing the gateway directly to the public internet. - Treat DNS rebinding and proxy-host header behavior as deployment hardening concerns; keep `trustedProxies` tight and avoid exposing the gateway directly to the public internet.
## Local session logs live on disk ## Local session logs live on disk

View File

@@ -233,8 +233,10 @@ Notes:
Provide `token` (or `password`) explicitly. Missing explicit credentials is an error. Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.
- Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.). - Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.).
- `gatewayUrl` is only accepted in a top-level window (not embedded) to prevent clickjacking. - `gatewayUrl` is only accepted in a top-level window (not embedded) to prevent clickjacking.
- For cross-origin dev setups (e.g. `pnpm ui:dev` to a remote Gateway), add the UI - Non-loopback Control UI deployments must set `gateway.controlUi.allowedOrigins`
origin to `gateway.controlUi.allowedOrigins`. explicitly (full origins). This includes remote dev setups.
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables
Host-header origin fallback mode, but it is a dangerous security mode.
Example: Example:

View File

@@ -99,8 +99,10 @@ Open:
- Non-loopback binds still **require** a shared token/password (`gateway.auth` or env). - Non-loopback binds still **require** a shared token/password (`gateway.auth` or env).
- The wizard generates a gateway token by default (even on loopback). - The wizard generates a gateway token by default (even on loopback).
- The UI sends `connect.params.auth.token` or `connect.params.auth.password`. - The UI sends `connect.params.auth.token` or `connect.params.auth.password`.
- The Control UI sends anti-clickjacking headers and only accepts same-origin browser - For non-loopback Control UI deployments, set `gateway.controlUi.allowedOrigins`
websocket connections unless `gateway.controlUi.allowedOrigins` is set. explicitly (full origins). Without it, gateway startup is refused by default.
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables
Host-header origin fallback mode, but is a dangerous security downgrade.
- With Serve, Tailscale identity headers can satisfy Control UI/WebSocket auth - With Serve, Tailscale identity headers can satisfy Control UI/WebSocket auth
when `gateway.auth.allowTailscale` is `true` (no token/password required). when `gateway.auth.allowTailscale` is `true` (no token/password required).
HTTP API endpoints still require token/password. Set HTTP API endpoints still require token/password. Set

View File

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

View File

@@ -300,7 +300,9 @@ export const FIELD_HELP: Record<string, string> = {
"gateway.controlUi.root": "gateway.controlUi.root":
"Optional filesystem root for Control UI assets (defaults to dist/control-ui).", "Optional filesystem root for Control UI assets (defaults to dist/control-ui).",
"gateway.controlUi.allowedOrigins": "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": "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.", "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": "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.basePath": "Control UI Base Path",
"gateway.controlUi.root": "Control UI Assets Root", "gateway.controlUi.root": "Control UI Assets Root",
"gateway.controlUi.allowedOrigins": "Control UI Allowed Origins", "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.allowInsecureAuth": "Insecure Control UI Auth Toggle",
"gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth",
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", "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[]> = { const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
"gateway.auth.token": ["security", "auth", "access", "network"], "gateway.auth.token": ["security", "auth", "access", "network"],
"gateway.auth.password": ["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.dangerouslyDisableDeviceAuth": ["security", "access", "network", "advanced"],
"gateway.controlUi.allowInsecureAuth": ["security", "access", "network", "advanced"], "gateway.controlUi.allowInsecureAuth": ["security", "access", "network", "advanced"],
"tools.exec.applyPatch.workspaceOnly": ["tools", "security", "access", "advanced"], "tools.exec.applyPatch.workspaceOnly": ["tools", "security", "access", "advanced"],

View File

@@ -70,6 +70,11 @@ export type GatewayControlUiConfig = {
root?: string; root?: string;
/** Allowed browser origins for Control UI/WebChat websocket connections. */ /** Allowed browser origins for Control UI/WebChat websocket connections. */
allowedOrigins?: string[]; 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. * Insecure-auth toggle.
* Control UI still requires secure context + device identity unless * Control UI still requires secure context + device identity unless

View File

@@ -454,6 +454,7 @@ export const OpenClawSchema = z
basePath: z.string().optional(), basePath: z.string().optional(),
root: z.string().optional(), root: z.string().optional(),
allowedOrigins: z.array(z.string()).optional(), allowedOrigins: z.array(z.string()).optional(),
dangerouslyAllowHostHeaderOriginFallback: z.boolean().optional(),
allowInsecureAuth: z.boolean().optional(), allowInsecureAuth: z.boolean().optional(),
dangerouslyDisableDeviceAuth: 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"; import { checkBrowserOrigin } from "./origin-check.js";
describe("checkBrowserOrigin", () => { describe("checkBrowserOrigin", () => {
it("accepts same-origin host matches", () => { it("accepts same-origin host matches only with legacy host-header fallback", () => {
const result = checkBrowserOrigin({ const result = checkBrowserOrigin({
requestHost: "127.0.0.1:18789", requestHost: "127.0.0.1:18789",
origin: "http://127.0.0.1:18789", origin: "http://127.0.0.1:18789",
allowHostHeaderOriginFallback: true,
}); });
expect(result.ok).toBe(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", () => { it("accepts loopback host mismatches for dev", () => {
const result = checkBrowserOrigin({ const result = checkBrowserOrigin({
requestHost: "127.0.0.1:18789", requestHost: "127.0.0.1:18789",

View File

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

View File

@@ -27,6 +27,7 @@ describe("resolveGatewayRuntimeConfig", () => {
bind: "lan" as const, bind: "lan" as const,
auth: TRUSTED_PROXY_AUTH, auth: TRUSTED_PROXY_AUTH,
trustedProxies: ["192.168.1.1"], trustedProxies: ["192.168.1.1"],
controlUi: { allowedOrigins: ["https://control.example.com"] },
}, },
}, },
expectedBindHost: "0.0.0.0", expectedBindHost: "0.0.0.0",
@@ -90,7 +91,12 @@ describe("resolveGatewayRuntimeConfig", () => {
{ {
name: "lan binding without trusted proxies", name: "lan binding without trusted proxies",
cfg: { 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: expectedMessage:
"gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured", "gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured",
@@ -121,7 +127,13 @@ describe("resolveGatewayRuntimeConfig", () => {
it.each([ it.each([
{ {
name: "lan binding with token", 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", expectedAuthMode: "token",
expectedBindHost: "0.0.0.0", expectedBindHost: "0.0.0.0",
}, },
@@ -188,6 +200,36 @@ describe("resolveGatewayRuntimeConfig", () => {
expectedMessage, 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", () => { 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; process.env.OPENCLAW_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
const trustedProxies = params.cfg.gateway?.trustedProxies ?? []; 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); assertGatewayAuthConfigured(resolvedAuth);
if (tailscaleMode === "funnel" && authMode !== "password") { 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)`, `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 (authMode === "trusted-proxy") {
if (trustedProxies.length === 0) { if (trustedProxies.length === 0) {

View File

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

View File

@@ -1136,6 +1136,38 @@ describe("security audit", () => {
expect(finding?.detail).toContain("tools.exec.applyPatch.workspaceOnly=false"); 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 () => { it("scores X-Real-IP fallback risk by gateway exposure", async () => {
const trustedProxyCfg = (trustedProxies: string[]): OpenClawConfig => ({ const trustedProxyCfg = (trustedProxies: string[]): OpenClawConfig => ({
gateway: { gateway: {

View File

@@ -266,6 +266,11 @@ function collectGatewayConfigFindings(
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env }); const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env });
const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false; 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) const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
? 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.", 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) { if (allowRealIpFallback) {
const hasNonLoopbackTrustedProxy = trustedProxies.some( const hasNonLoopbackTrustedProxy = trustedProxies.some(

View File

@@ -5,6 +5,9 @@ export function collectEnabledInsecureOrDangerousFlags(cfg: OpenClawConfig): str
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) { if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
enabledFlags.push("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) { if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) {
enabledFlags.push("gateway.controlUi.dangerouslyDisableDeviceAuth=true"); enabledFlags.push("gateway.controlUi.dangerouslyDisableDeviceAuth=true");
} }