feat(csp): support inline script hashes in Control UI CSP (#53307) thanks @BunsDev

Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Co-authored-by: Nova <nova@openknot.ai>
This commit is contained in:
Val Alexander 2026-03-23 21:35:33 -05:00
parent e530865274
commit a96eded4a0
No known key found for this signature in database
5 changed files with 126 additions and 8 deletions

View File

@ -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 `<script>` blocks in the served `index.html` and include them in the `script-src` CSP directive, keeping inline scripts blocked by default while allowing explicitly hashed bootstrap code. (#53307) Thanks @BunsDev.
### Fixes

View File

@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { buildControlUiCspHeader } from "./control-ui-csp.js";
import { buildControlUiCspHeader, computeInlineScriptHashes } from "./control-ui-csp.js";
describe("buildControlUiCspHeader", () => {
it("blocks inline scripts while allowing inline styles", () => {
@ -15,4 +16,66 @@ describe("buildControlUiCspHeader", () => {
expect(csp).toContain("https://fonts.googleapis.com");
expect(csp).toContain("font-src 'self' https://fonts.gstatic.com");
});
it("includes inline script hashes in script-src when provided", () => {
const csp = buildControlUiCspHeader({
inlineScriptHashes: ["sha256-abc123"],
});
expect(csp).toContain("script-src 'self' 'sha256-abc123'");
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/);
});
it("includes multiple inline script hashes", () => {
const csp = buildControlUiCspHeader({
inlineScriptHashes: ["sha256-aaa", "sha256-bbb"],
});
expect(csp).toContain("script-src 'self' 'sha256-aaa' 'sha256-bbb'");
});
it("falls back to plain script-src self when hashes array is empty", () => {
const csp = buildControlUiCspHeader({ inlineScriptHashes: [] });
expect(csp).toMatch(/script-src 'self'(?:;|$)/);
});
});
describe("computeInlineScriptHashes", () => {
it("returns empty for HTML without scripts", () => {
expect(computeInlineScriptHashes("<html><body>hi</body></html>")).toEqual([]);
});
it("hashes inline script content", () => {
const content = "alert(1)";
const expected = createHash("sha256").update(content, "utf8").digest("base64");
const hashes = computeInlineScriptHashes(`<html><script>${content}</script></html>`);
expect(hashes).toEqual([`sha256-${expected}`]);
});
it("skips scripts with src attribute", () => {
const hashes = computeInlineScriptHashes('<html><script src="/app.js"></script></html>');
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 = [
"<html><head>",
`<script>${inlineContent}</script>`,
'<script type="module" src="/app.js"></script>',
"</head></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(`<script>${content}</script>`);
expect(hashes).toEqual([`sha256-${expected}`]);
});
it("skips empty inline scripts", () => {
expect(computeInlineScriptHashes("<script></script>")).toEqual([]);
});
});

View File

@ -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 `<script>` blocks in an HTML string.
* Only scripts without a `src` attribute are considered inline.
*/
export function computeInlineScriptHashes(html: string): string[] {
const hashes: string[] = [];
const re = /<script(?:\s[^>]*)?>([^]*?)<\/script>/gi;
let match: RegExpExecArray | null;
while ((match = re.exec(html)) !== null) {
const openTag = match[0].slice(0, match[0].indexOf(">") + 1);
if (/\bsrc\s*=/i.test(openTag)) {
continue;
}
const content = match[1];
if (!content) {
continue;
}
const hash = createHash("sha256").update(content, "utf8").digest("base64");
hashes.push(`sha256-${hash}`);
}
return hashes;
}
export function buildControlUiCspHeader(opts?: { inlineScriptHashes?: string[] }): string {
const hashes = opts?.inlineScriptHashes;
const scriptSrc = hashes?.length
? `script-src 'self' ${hashes.map((h) => `'${h}'`).join(" ")}`
: "script-src 'self'";
return [
"default-src 'self'",
"base-uri 'none'",
"object-src 'none'",
"frame-ancestors 'none'",
"script-src 'self'",
scriptSrc,
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: https:",
"font-src 'self' https://fonts.gstatic.com",

View File

@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import type { IncomingMessage } from "node:http";
import os from "node:os";
@ -131,6 +132,27 @@ describe("handleControlUiHttpRequest", () => {
});
});
it("includes CSP hash for inline scripts in index.html", async () => {
const scriptContent = "(function(){ var x = 1; })();";
const html = `<html><head><script>${scriptContent}</script></head><body></body></html>\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 = "<html><head></head><body>Hello</body></html>\n";
await withControlUiRoot({

View File

@ -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);