fix: harden control ui framing + ws origin
parent
0223416c61
commit
66d8117d44
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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" };
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue