diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e2c38ccd8b..277e4b83312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878) - UI/clarity: consolidate button primitives (`btn--icon`, `btn--ghost`, `btn--xs`), refine the Knot theme to a black-and-red palette with WCAG 2.1 AA contrast, add config icons for Diagnostics/CLI/Secrets/ACP/MCP sections, replace the roundness slider with discrete stops, and improve accessibility with aria-labels across usage filters. (#53272) Thanks @BunsDev. +- CSP/Control UI: compute SHA-256 hashes for inline ``); + expect(hashes).toEqual([`sha256-${expected}`]); + }); + + it("skips scripts with src attribute", () => { + const hashes = computeInlineScriptHashes(''); + expect(hashes).toEqual([]); + }); + + it("hashes only inline scripts when mixed with external", () => { + const inlineContent = "console.log('init')"; + const expected = createHash("sha256").update(inlineContent, "utf8").digest("base64"); + const html = [ + "", + ``, + '', + "", + ].join(""); + const hashes = computeInlineScriptHashes(html); + expect(hashes).toEqual([`sha256-${expected}`]); + }); + + it("handles multiline inline scripts", () => { + const content = "\n var x = 1;\n console.log(x);\n"; + const expected = createHash("sha256").update(content, "utf8").digest("base64"); + const hashes = computeInlineScriptHashes(``); + expect(hashes).toEqual([`sha256-${expected}`]); + }); + + it("skips empty inline scripts", () => { + expect(computeInlineScriptHashes("")).toEqual([]); + }); }); diff --git a/src/gateway/control-ui-csp.ts b/src/gateway/control-ui-csp.ts index 8a7b56f1eb0..c6e8c53bf31 100644 --- a/src/gateway/control-ui-csp.ts +++ b/src/gateway/control-ui-csp.ts @@ -1,14 +1,39 @@ -export function buildControlUiCspHeader(): string { - // Control UI: block framing, block inline scripts, keep styles permissive - // (UI uses a lot of inline style attributes in templates). - // Keep Google Fonts origins explicit in CSP for deployments that load - // external Google Fonts stylesheets/font files. +import { createHash } from "node:crypto"; + +/** + * Compute SHA-256 CSP hashes for inline `\n`; + const expectedHash = createHash("sha256").update(scriptContent, "utf8").digest("base64"); + await withControlUiRoot({ + indexHtml: html, + fn: async (tmp) => { + const { res, setHeader } = makeMockHttpResponse(); + handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, { + root: { kind: "resolved", path: tmp }, + }); + const cspCalls = setHeader.mock.calls.filter( + (call) => call[0] === "Content-Security-Policy", + ); + const lastCsp = String(cspCalls[cspCalls.length - 1]?.[1] ?? ""); + expect(lastCsp).toContain(`'sha256-${expectedHash}'`); + expect(lastCsp).not.toMatch(/script-src[^;]*'unsafe-inline'/); + }, + }); + }); + it("does not inject inline scripts into index.html", async () => { const html = "Hello\n"; await withControlUiRoot({ diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index ece3c7f1337..b68b453cc18 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -16,7 +16,7 @@ import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH, type ControlUiBootstrapConfig, } from "./control-ui-contract.js"; -import { buildControlUiCspHeader } from "./control-ui-csp.js"; +import { buildControlUiCspHeader, computeInlineScriptHashes } from "./control-ui-csp.js"; import { isReadHttpMethod, respondNotFound as respondControlUiNotFound, @@ -234,6 +234,13 @@ function serveResolvedFile(res: ServerResponse, filePath: string, body: Buffer) } function serveResolvedIndexHtml(res: ServerResponse, body: string) { + const hashes = computeInlineScriptHashes(body); + if (hashes.length > 0) { + res.setHeader( + "Content-Security-Policy", + buildControlUiCspHeader({ inlineScriptHashes: hashes }), + ); + } res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); res.end(body);