diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 4484bf26e52..527fc889a27 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -12,6 +12,7 @@ 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; @@ -68,8 +69,24 @@ type ControlUiAvatarMeta = { function applyControlUiSecurityHeaders(res: ServerResponse) { res.setHeader("X-Frame-Options", "DENY"); - res.setHeader("Content-Security-Policy", "frame-ancestors 'none'"); + // 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("X-Content-Type-Options", "nosniff"); + res.setHeader("Referrer-Policy", "no-referrer"); } function sendJson(res: ServerResponse, status: number, body: unknown) { @@ -160,66 +177,10 @@ function serveFile(res: ServerResponse, filePath: string) { res.end(fs.readFileSync(filePath)); } -interface ControlUiInjectionOpts { - basePath: string; - assistantName?: string; - assistantAvatar?: string; -} - -function injectControlUiConfig(html: string, opts: ControlUiInjectionOpts): string { - const { basePath, assistantName, assistantAvatar } = opts; - const script = - ``; - // Check if already injected - if (html.includes("__OPENCLAW_ASSISTANT_NAME__")) { - return html; - } - const headClose = html.indexOf(""); - if (headClose !== -1) { - return `${html.slice(0, headClose)}${script}${html.slice(headClose)}`; - } - return `${script}${html}`; -} - -interface ServeIndexHtmlOpts { - basePath: string; - config?: OpenClawConfig; - agentId?: string; -} - -function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) { - const { basePath, config, agentId } = opts; - const identity = config - ? resolveAssistantIdentity({ cfg: config, agentId }) - : DEFAULT_ASSISTANT_IDENTITY; - const resolvedAgentId = - typeof (identity as { agentId?: string }).agentId === "string" - ? (identity as { agentId?: string }).agentId - : agentId; - const avatarValue = - resolveAssistantAvatarUrl({ - avatar: identity.avatar, - agentId: resolvedAgentId, - basePath, - }) ?? identity.avatar; +function serveIndexHtml(res: ServerResponse, indexPath: string) { res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); - const raw = fs.readFileSync(indexPath, "utf8"); - res.end( - injectControlUiConfig(raw, { - basePath, - assistantName: identity.name, - assistantAvatar: avatarValue, - }), - ); + res.end(fs.readFileSync(indexPath, "utf8")); } function isSafeRelativePath(relPath: string) { @@ -279,6 +240,35 @@ export function handleControlUiHttpRequest( applyControlUiSecurityHeaders(res); + const bootstrapConfigPath = basePath + ? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}` + : CONTROL_UI_BOOTSTRAP_CONFIG_PATH; + if (pathname === bootstrapConfigPath) { + const config = opts?.config; + const identity = config + ? resolveAssistantIdentity({ cfg: config, agentId: opts?.agentId }) + : DEFAULT_ASSISTANT_IDENTITY; + const avatarValue = resolveAssistantAvatarUrl({ + avatar: identity.avatar, + agentId: identity.agentId, + basePath, + }); + if (req.method === "HEAD") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-cache"); + res.end(); + return true; + } + sendJson(res, 200, { + basePath, + assistantName: identity.name, + assistantAvatar: avatarValue ?? identity.avatar, + assistantAgentId: identity.agentId, + }); + return true; + } + const rootState = opts?.root; if (rootState?.kind === "invalid") { res.statusCode = 503; @@ -341,11 +331,7 @@ export function handleControlUiHttpRequest( if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { if (path.basename(filePath) === "index.html") { - serveIndexHtml(res, filePath, { - basePath, - config: opts?.config, - agentId: opts?.agentId, - }); + serveIndexHtml(res, filePath); return true; } serveFile(res, filePath); @@ -355,11 +341,7 @@ export function handleControlUiHttpRequest( // SPA fallback (client-side router): serve index.html for unknown paths. const indexPath = path.join(root, "index.html"); if (fs.existsSync(indexPath)) { - serveIndexHtml(res, indexPath, { - basePath, - config: opts?.config, - agentId: opts?.agentId, - }); + serveIndexHtml(res, indexPath); return true; } diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index 033f4aa5352..a510f93550b 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -80,7 +80,68 @@ describe("handleControlUiHttpRequest", () => { ); expect(handled).toBe(true); expect(setHeader).toHaveBeenCalledWith("X-Frame-Options", "DENY"); - expect(setHeader).toHaveBeenCalledWith("Content-Security-Policy", "frame-ancestors 'none'"); + const csp = setHeader.mock.calls.find((call) => call[0] === "Content-Security-Policy")?.[1]; + expect(typeof csp).toBe("string"); + expect(String(csp)).toContain("frame-ancestors 'none'"); + expect(String(csp)).toContain("script-src 'self'"); + expect(String(csp)).not.toContain("script-src 'self' 'unsafe-inline'"); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("does not inject inline scripts into index.html", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + const html = "Hello\n"; + await fs.writeFile(path.join(tmp, "index.html"), html); + const { res, end } = makeControlUiResponse(); + const handled = handleControlUiHttpRequest( + { url: "/", method: "GET" } as IncomingMessage, + res, + { + root: { kind: "resolved", path: tmp }, + config: { + agents: { defaults: { workspace: tmp } }, + ui: { assistant: { name: ".png" } }, + }, + }, + ); + expect(handled).toBe(true); + const payload = String(end.mock.calls[0]?.[0] ?? ""); + const parsed = JSON.parse(payload) as { + basePath: string; + assistantName: string; + assistantAvatar: string; + assistantAgentId: string; + }; + expect(parsed.basePath).toBe(""); + expect(parsed.assistantName).toBe("