fix: harden control ui framing + ws origin

main
Peter Steinberger 2026-02-03 16:00:57 -08:00
parent 0223416c61
commit 66d8117d44
11 changed files with 265 additions and 91 deletions

View File

@ -197,5 +197,20 @@ Notes:
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
- `token` is stored in localStorage; `password` is kept in memory only.
- 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.
- For cross-origin dev setups (e.g. `pnpm ui:dev` to a remote Gateway), add the UI
origin to `gateway.controlUi.allowedOrigins`.
Example:
```json5
{
gateway: {
controlUi: {
allowedOrigins: ["http://localhost:5173"],
},
},
}
```
Remote access setup details: [Remote access](/gateway/remote).

View File

@ -99,6 +99,8 @@ Open:
- 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 UI sends `connect.params.auth.token` or `connect.params.auth.password`.
- The Control UI sends anti-clickjacking headers and only accepts same-origin browser
websocket connections unless `gateway.controlUi.allowedOrigins` is set.
- With Serve, Tailscale identity headers can satisfy auth when
`gateway.auth.allowTailscale` is `true` (no token/password required). Set
`gateway.auth.allowTailscale: false` to require explicit credentials. See

View File

@ -202,6 +202,7 @@ const FIELD_LABELS: Record<string, string> = {
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
"gateway.controlUi.basePath": "Control UI Base Path",
"gateway.controlUi.root": "Control UI Assets Root",
"gateway.controlUi.allowedOrigins": "Control UI Allowed Origins",
"gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth",
"gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth",
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
@ -416,6 +417,8 @@ const FIELD_HELP: Record<string, string> = {
"Optional URL prefix where the Control UI is served (e.g. /openclaw).",
"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).",
"gateway.controlUi.allowInsecureAuth":
"Allow Control UI auth over insecure HTTP (token-only; not recommended).",
"gateway.controlUi.dangerouslyDisableDeviceAuth":
@ -751,6 +754,7 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
"gateway.remote.sshTarget": "user@host",
"gateway.controlUi.basePath": "/openclaw",
"gateway.controlUi.root": "dist/control-ui",
"gateway.controlUi.allowedOrigins": "https://control.example.com",
"channels.mattermost.baseUrl": "https://chat.example.com",
"agents.list[].identity.avatar": "avatars/openclaw.png",
};

View File

@ -68,6 +68,8 @@ export type GatewayControlUiConfig = {
basePath?: string;
/** Optional filesystem root for Control UI assets (defaults to dist/control-ui). */
root?: string;
/** Allowed browser origins for Control UI/WebChat websocket connections. */
allowedOrigins?: string[];
/** Allow token-only auth over insecure HTTP (default: false). */
allowInsecureAuth?: boolean;
/** DANGEROUS: Disable device identity checks for the Control UI (default: false). */

View File

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

View File

@ -1,92 +1,44 @@
import { describe, expect, it } from "vitest";
import {
buildControlUiAvatarUrl,
normalizeControlUiBasePath,
resolveAssistantAvatarUrl,
} from "./control-ui-shared.js";
import type { IncomingMessage, ServerResponse } from "node:http";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { handleControlUiHttpRequest } from "./control-ui.js";
describe("resolveAssistantAvatarUrl", () => {
it("normalizes base paths", () => {
expect(normalizeControlUiBasePath()).toBe("");
expect(normalizeControlUiBasePath("")).toBe("");
expect(normalizeControlUiBasePath(" ")).toBe("");
expect(normalizeControlUiBasePath("/")).toBe("");
expect(normalizeControlUiBasePath("ui")).toBe("/ui");
expect(normalizeControlUiBasePath("/ui/")).toBe("/ui");
});
const makeResponse = (): {
res: ServerResponse;
setHeader: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
} => {
const setHeader = vi.fn();
const end = vi.fn();
const res = {
headersSent: false,
statusCode: 200,
setHeader,
end,
} as unknown as ServerResponse;
return { res, setHeader, end };
};
it("builds avatar URLs", () => {
expect(buildControlUiAvatarUrl("", "main")).toBe("/avatar/main");
expect(buildControlUiAvatarUrl("/ui", "main")).toBe("/ui/avatar/main");
});
it("keeps remote and data URLs", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "https://example.com/avatar.png",
agentId: "main",
basePath: "/ui",
}),
).toBe("https://example.com/avatar.png");
expect(
resolveAssistantAvatarUrl({
avatar: "data:image/png;base64,abc",
agentId: "main",
basePath: "/ui",
}),
).toBe("data:image/png;base64,abc");
});
it("prefixes basePath for /avatar endpoints", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "/avatar/main",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
expect(
resolveAssistantAvatarUrl({
avatar: "/ui/avatar/main",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
});
it("maps local avatar paths to the avatar endpoint", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "avatars/me.png",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
expect(
resolveAssistantAvatarUrl({
avatar: "avatars/profile",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
});
it("leaves local paths untouched when agentId is missing", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "avatars/me.png",
basePath: "/ui",
}),
).toBe("avatars/me.png");
});
it("keeps short text avatars", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "PS",
agentId: "main",
basePath: "/ui",
}),
).toBe("PS");
describe("handleControlUiHttpRequest", () => {
it("sets anti-clickjacking headers for Control UI responses", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
try {
await fs.writeFile(path.join(tmp, "index.html"), "<html></html>\n");
const { res, setHeader } = makeResponse();
const handled = handleControlUiHttpRequest(
{ url: "/", method: "GET" } as IncomingMessage,
res,
{
root: { kind: "resolved", path: tmp },
},
);
expect(handled).toBe(true);
expect(setHeader).toHaveBeenCalledWith("X-Frame-Options", "DENY");
expect(setHeader).toHaveBeenCalledWith("Content-Security-Policy", "frame-ancestors 'none'");
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
});
});

View File

@ -66,6 +66,12 @@ type ControlUiAvatarMeta = {
avatarUrl: string | null;
};
function applyControlUiSecurityHeaders(res: ServerResponse) {
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
res.setHeader("X-Content-Type-Options", "nosniff");
}
function sendJson(res: ServerResponse, status: number, body: unknown) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
@ -100,6 +106,8 @@ export function handleControlUiAvatarRequest(
return false;
}
applyControlUiSecurityHeaders(res);
const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean);
const agentId = agentIdParts[0] ?? "";
if (agentIdParts.length !== 1 || !agentId || !isValidAgentId(agentId)) {
@ -250,6 +258,7 @@ export function handleControlUiHttpRequest(
if (!basePath) {
if (pathname === "/ui" || pathname.startsWith("/ui/")) {
applyControlUiSecurityHeaders(res);
respondNotFound(res);
return true;
}
@ -257,6 +266,7 @@ export function handleControlUiHttpRequest(
if (basePath) {
if (pathname === basePath) {
applyControlUiSecurityHeaders(res);
res.statusCode = 302;
res.setHeader("Location", `${basePath}/${url.search}`);
res.end();
@ -267,6 +277,8 @@ export function handleControlUiHttpRequest(
}
}
applyControlUiSecurityHeaders(res);
const rootState = opts?.root;
if (rootState?.kind === "invalid") {
res.statusCode = 503;

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { checkBrowserOrigin } from "./origin-check.js";
describe("checkBrowserOrigin", () => {
it("accepts same-origin host matches", () => {
const result = checkBrowserOrigin({
requestHost: "127.0.0.1:18789",
origin: "http://127.0.0.1:18789",
});
expect(result.ok).toBe(true);
});
it("accepts loopback host mismatches for dev", () => {
const result = checkBrowserOrigin({
requestHost: "127.0.0.1:18789",
origin: "http://localhost:5173",
});
expect(result.ok).toBe(true);
});
it("accepts allowlisted origins", () => {
const result = checkBrowserOrigin({
requestHost: "gateway.example.com:18789",
origin: "https://control.example.com",
allowedOrigins: ["https://control.example.com"],
});
expect(result.ok).toBe(true);
});
it("rejects missing origin", () => {
const result = checkBrowserOrigin({
requestHost: "gateway.example.com:18789",
origin: "",
});
expect(result.ok).toBe(false);
});
it("rejects mismatched origins", () => {
const result = checkBrowserOrigin({
requestHost: "gateway.example.com:18789",
origin: "https://attacker.example.com",
});
expect(result.ok).toBe(false);
});
});

View File

@ -0,0 +1,85 @@
type OriginCheckResult = { ok: true } | { ok: false; reason: string };
function normalizeHostHeader(hostHeader?: string): string {
return (hostHeader ?? "").trim().toLowerCase();
}
function resolveHostName(hostHeader?: string): string {
const host = normalizeHostHeader(hostHeader);
if (!host) {
return "";
}
if (host.startsWith("[")) {
const end = host.indexOf("]");
if (end !== -1) {
return host.slice(1, end);
}
}
const [name] = host.split(":");
return name ?? "";
}
function parseOrigin(
originRaw?: string,
): { origin: string; host: string; hostname: string } | null {
const trimmed = (originRaw ?? "").trim();
if (!trimmed || trimmed === "null") {
return null;
}
try {
const url = new URL(trimmed);
return {
origin: url.origin.toLowerCase(),
host: url.host.toLowerCase(),
hostname: url.hostname.toLowerCase(),
};
} catch {
return null;
}
}
function isLoopbackHost(hostname: string): boolean {
if (!hostname) {
return false;
}
if (hostname === "localhost") {
return true;
}
if (hostname === "::1") {
return true;
}
if (hostname === "127.0.0.1" || hostname.startsWith("127.")) {
return true;
}
return false;
}
export function checkBrowserOrigin(params: {
requestHost?: string;
origin?: string;
allowedOrigins?: string[];
}): OriginCheckResult {
const parsedOrigin = parseOrigin(params.origin);
if (!parsedOrigin) {
return { ok: false, reason: "origin missing or invalid" };
}
const allowlist = (params.allowedOrigins ?? [])
.map((value) => value.trim().toLowerCase())
.filter(Boolean);
if (allowlist.includes(parsedOrigin.origin)) {
return { ok: true };
}
const requestHost = normalizeHostHeader(params.requestHost);
if (requestHost && parsedOrigin.host === requestHost) {
return { ok: true };
}
const requestHostname = resolveHostName(requestHost);
if (isLoopbackHost(parsedOrigin.hostname) && isLoopbackHost(requestHostname)) {
return { ok: true };
}
return { ok: false, reason: "origin not allowed" };
}

View File

@ -29,6 +29,7 @@ import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js";
import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import { checkBrowserOrigin } from "../../origin-check.js";
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
import {
type ConnectParams,
@ -365,12 +366,43 @@ export function attachGatewayWsMessageHandler(params: {
connectParams.role = role;
connectParams.scopes = scopes;
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
const isWebchat = isWebchatConnect(connectParams);
if (isControlUi || isWebchat) {
const originCheck = checkBrowserOrigin({
requestHost,
origin: requestOrigin,
allowedOrigins: configSnapshot.gateway?.controlUi?.allowedOrigins,
});
if (!originCheck.ok) {
const errorMessage =
"origin not allowed (open the Control UI from the gateway host or allow it in gateway.controlUi.allowedOrigins)";
setHandshakeState("failed");
setCloseCause("origin-mismatch", {
origin: requestOrigin ?? "n/a",
host: requestHost ?? "n/a",
reason: originCheck.reason,
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, errorMessage),
});
close(1008, truncateCloseReason(errorMessage));
return;
}
}
const deviceRaw = connectParams.device;
let devicePublicKey: string | null = null;
const hasTokenAuth = Boolean(connectParams.auth?.token);
const hasPasswordAuth = Boolean(connectParams.auth?.password);
const hasSharedAuth = hasTokenAuth || hasPasswordAuth;
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
const allowInsecureControlUi =
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
const disableControlUiDeviceAuth =

View File

@ -51,6 +51,30 @@ type SettingsHost = {
pendingGatewayUrl?: string | null;
};
function isTopLevelWindow(): boolean {
try {
return window.top === window.self;
} catch {
return false;
}
}
function normalizeGatewayUrl(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
return null;
}
return trimmed;
} catch {
return null;
}
}
export function applySettings(host: SettingsHost, next: UiSettings) {
const normalized = {
...next,
@ -118,8 +142,8 @@ export function applySettingsFromUrl(host: SettingsHost) {
}
if (gatewayUrlRaw != null) {
const gatewayUrl = gatewayUrlRaw.trim();
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
const gatewayUrl = normalizeGatewayUrl(gatewayUrlRaw);
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl && isTopLevelWindow()) {
host.pendingGatewayUrl = gatewayUrl;
}
params.delete("gatewayUrl");