From c6e6023e3ac6b71cf531395414cb376426f54f2a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 03:35:11 +0100 Subject: [PATCH] refactor(gateway): share Control UI bootstrap contract and CSP --- src/gateway/control-ui-contract.ts | 8 ++++++++ src/gateway/control-ui-csp.test.ts | 12 ++++++++++++ src/gateway/control-ui-csp.ts | 15 +++++++++++++++ src/gateway/control-ui.ts | 25 +++++++------------------ 4 files changed, 42 insertions(+), 18 deletions(-) create mode 100644 src/gateway/control-ui-contract.ts create mode 100644 src/gateway/control-ui-csp.test.ts create mode 100644 src/gateway/control-ui-csp.ts diff --git a/src/gateway/control-ui-contract.ts b/src/gateway/control-ui-contract.ts new file mode 100644 index 00000000000..654835e0424 --- /dev/null +++ b/src/gateway/control-ui-contract.ts @@ -0,0 +1,8 @@ +export const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/__openclaw/control-ui-config.json"; + +export type ControlUiBootstrapConfig = { + basePath: string; + assistantName: string; + assistantAvatar: string; + assistantAgentId: string; +}; diff --git a/src/gateway/control-ui-csp.test.ts b/src/gateway/control-ui-csp.test.ts new file mode 100644 index 00000000000..012c826656c --- /dev/null +++ b/src/gateway/control-ui-csp.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { buildControlUiCspHeader } from "./control-ui-csp.js"; + +describe("buildControlUiCspHeader", () => { + it("blocks inline scripts while allowing inline styles", () => { + const csp = buildControlUiCspHeader(); + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).toContain("script-src 'self'"); + expect(csp).not.toContain("script-src 'self' 'unsafe-inline'"); + expect(csp).toContain("style-src 'self' 'unsafe-inline'"); + }); +}); diff --git a/src/gateway/control-ui-csp.ts b/src/gateway/control-ui-csp.ts new file mode 100644 index 00000000000..31cd98bd673 --- /dev/null +++ b/src/gateway/control-ui-csp.ts @@ -0,0 +1,15 @@ +export function buildControlUiCspHeader(): string { + // Control UI: block framing, block inline scripts, keep styles permissive + // (UI uses a lot of inline style attributes in templates). + return [ + "default-src 'self'", + "base-uri 'none'", + "object-src 'none'", + "frame-ancestors 'none'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self'", + "connect-src 'self' ws: wss:", + ].join("; "); +} diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 527fc889a27..1256a3e5f94 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -4,6 +4,11 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import { resolveControlUiRootSync } from "../infra/control-ui-assets.js"; import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js"; +import { + CONTROL_UI_BOOTSTRAP_CONFIG_PATH, + type ControlUiBootstrapConfig, +} from "./control-ui-contract.js"; +import { buildControlUiCspHeader } from "./control-ui-csp.js"; import { buildControlUiAvatarUrl, CONTROL_UI_AVATAR_PREFIX, @@ -12,7 +17,6 @@ import { } from "./control-ui-shared.js"; const ROOT_PREFIX = "/"; -const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/__openclaw/control-ui-config.json"; export type ControlUiRequestOptions = { basePath?: string; @@ -69,22 +73,7 @@ type ControlUiAvatarMeta = { function applyControlUiSecurityHeaders(res: ServerResponse) { res.setHeader("X-Frame-Options", "DENY"); - // Control UI: block framing, block inline scripts, keep styles permissive - // (UI uses a lot of inline style attributes in templates). - res.setHeader( - "Content-Security-Policy", - [ - "default-src 'self'", - "base-uri 'none'", - "object-src 'none'", - "frame-ancestors 'none'", - "script-src 'self'", - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: https:", - "font-src 'self'", - "connect-src 'self' ws: wss:", - ].join("; "), - ); + res.setHeader("Content-Security-Policy", buildControlUiCspHeader()); res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("Referrer-Policy", "no-referrer"); } @@ -265,7 +254,7 @@ export function handleControlUiHttpRequest( assistantName: identity.name, assistantAvatar: avatarValue ?? identity.avatar, assistantAgentId: identity.agentId, - }); + } satisfies ControlUiBootstrapConfig); return true; }