diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index dcef4c1c1..b3add633d 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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). diff --git a/docs/web/index.md b/docs/web/index.md index 4955ac38a..3ec00abad 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -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 diff --git a/src/config/schema.ts b/src/config/schema.ts index 9be9c3f44..09bcd038c 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -202,6 +202,7 @@ const FIELD_LABELS: Record = { "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 = { "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 = { "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", }; diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index af713c7d0..1bb17c9c7 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -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). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index cbab23ef6..0d1b12bee 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(), }) diff --git a/src/gateway/control-ui.test.ts b/src/gateway/control-ui.test.ts index 9ae2267ed..13dd3020b 100644 --- a/src/gateway/control-ui.test.ts +++ b/src/gateway/control-ui.test.ts @@ -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; + end: ReturnType; +} => { + 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"), "\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 }); + } }); }); diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 8e739d243..4484bf26e 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -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; diff --git a/src/gateway/origin-check.test.ts b/src/gateway/origin-check.test.ts new file mode 100644 index 000000000..4018903dd --- /dev/null +++ b/src/gateway/origin-check.test.ts @@ -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); + }); +}); diff --git a/src/gateway/origin-check.ts b/src/gateway/origin-check.ts new file mode 100644 index 000000000..a115eb857 --- /dev/null +++ b/src/gateway/origin-check.ts @@ -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" }; +} diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 04e3bf5ea..9593ca204 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -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 = diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 5423e1241..b54b17ae0 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -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");