mirror of https://github.com/openclaw/openclaw.git
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:
parent
e530865274
commit
a96eded4a0
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue