Public: anyone can read this; the crew can change it.

Content-Security-Policy violations are reported to the forge, counted by directive and shown on the security page #10767

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10759-content-security-policy-violations-are-r → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/10759-content-security-policy-violations-are-r was merged into main as 7359a09.

Review

Sign in to review this change.
12 files changed +386 −4
docs/features/csp-reports.md added +7
@@ -0,0 +1,7 @@
1 # Content-Security-Policy reports
2
3 Every page the forge renders carries `report-uri /csp-report` and `report-to csp` in its policy, with a `Reporting-Endpoints: csp="/csp-report"` header. The bare floor policy that `securityFloor` puts on non-document answers does not name it: the floor also covers an app's proxied responses, and a relative address there would resolve to the app's host, where no report could be counted. When a browser blocks something it posts a report there, so a broken or hostile page is seen, not guessed.
4
5 `POST /csp-report` (`src/http/csp_report.ts`) takes `application/csp-report`, `application/reports+json` or `application/json`, at most 8 KB, in either the `report-uri` or the Reporting API shape. Anything else gets a 4xx and nothing is stored. It keeps only the directive, the blocked host (no path, no query string) and the page's path, as one count per UTC day in `csp_violation`. That is at most 500 rows a day (the rest fold into one row) and 30 days kept. Anonymous callers are limited to 30 a minute each and 600 a minute per forge. It answers 204.
6
7 `/settings/security` shows one line, "Blocked by the page policy, 7 days", listing counts by directive. When nothing was blocked, the line is not shown. The terminal's Security controls screen (admin only) lists the same weekly counts, one row per directive below the controls, and none when nothing was blocked.
src/core/migration-pins/20261110140000-csp-violations.pin added +1
@@ -0,0 +1,1 @@
1 19e1ec5fcfc478399028550e391b4baa5221195730a7ec0ce59d33180a6b8967
src/core/migrations/20261110140000-csp-violations.ts added +14
@@ -0,0 +1,14 @@
1 // Content-Security-Policy violations browsers report (#10759): one count per
2 // UTC day, directive, blocked host and page path. Bounded per day by
3 // `recordViolation` (src/http/csp_report.ts); the primary key leads with
4 // `day`, so the week's read and the prune walk it. Additive.
5 export default `
6 CREATE TABLE csp_violation (
7 day TEXT NOT NULL,
8 directive TEXT NOT NULL,
9 blocked TEXT NOT NULL,
10 path TEXT NOT NULL,
11 n INTEGER NOT NULL DEFAULT 0,
12 PRIMARY KEY (day, directive, blocked, path)
13 ) WITHOUT ROWID;
14 `;
src/core/reserved.ts +1
@@ -65,6 +65,7 @@ export const SYSTEM_NAMES: readonly string[] = [
6565 "board",
6666 "boards",
6767 "contribute",
68 "csp-report",
6869 "deploy-to",
6970 "pinned",
7071 "pipeline",
src/http/csp_report.ts added +176
@@ -0,0 +1,176 @@
1 /**
2 * POST /csp-report: where a browser says what the Content-Security-Policy
3 * blocked (#10759), so a broken or hostile page is seen, not guessed.
4 *
5 * Both shapes a browser sends are read: the old `report-uri` body
6 * (`application/csp-report`, `{"csp-report": {...}}`) and the Reporting API's
7 * (`application/reports+json`, an array of `{type: "csp-violation", body}`).
8 * At most 8 KB, a known content type, a known shape; anything else is a 4xx
9 * and stores nothing. Only three things are kept: the directive, the blocked
10 * host (never a path or query string) and the page's path, as one count per
11 * UTC day. Rows a day are bounded (`DAY_ROWS`, the rest fold into one), and
12 * days past `KEEP_DAYS` are dropped as new ones arrive. Anonymous callers are
13 * limited per client and per forge like the page-view beacon. Always 204.
14 */
15
16 import type { Database } from "bun:sqlite";
17 import type { App } from "../app.ts";
18 import { RateLimiter } from "../core/ratelimit.ts";
19 import type { Ctx } from "./context.ts";
20 import { readBounded } from "./limits.ts";
21 import { clientBucket } from "./peer.ts";
22
23 export const CSP_REPORT_PATH = "/csp-report";
24 export const CSP_REPORT_MAX = 8 * 1024;
25 /** The Reporting API endpoint name a policy's `report-to` names. */
26 export const CSP_REPORT_GROUP = "csp";
27 /** What a page's policy appends: both the old and the new way to report. */
28 export const CSP_REPORT_DIRECTIVES = `report-uri ${CSP_REPORT_PATH}; report-to ${CSP_REPORT_GROUP}`;
29 /** The `Reporting-Endpoints` header naming that group. */
30 export const CSP_REPORTING_ENDPOINTS = `${CSP_REPORT_GROUP}="${CSP_REPORT_PATH}"`;
31
32 const TYPES = new Set(["application/csp-report", "application/reports+json", "application/json"]);
33 const DIRECTIVE = /^[a-z][a-z-]{0,39}$/;
34 const KEYWORD = /^[a-z][a-z0-9-]{0,19}$/;
35 const PATH_MAX = 200;
36 const DAY_ROWS = 500;
37 const KEEP_DAYS = 30;
38 const OVERFLOW = "(more)";
39 const PER_CLIENT = 30;
40 const PER_FORGE = 600;
41
42 export interface Violation {
43 readonly directive: string;
44 readonly blocked: string;
45 readonly path: string;
46 }
47
48 const limiter = (perMinute: number) => new RateLimiter({ perMinute, adminPerMinute: perMinute, enabled: true });
49 const limits = new WeakMap<App, { client: RateLimiter; forge: RateLimiter }>();
50
51 const answer = (status: number, headers?: Record<string, string>): Response =>
52 new Response(null, { status, headers: { "cache-control": "no-store", ...headers } });
53
54 const text = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
55
56 /** `script-src-elem 'self'` → `script-src-elem`; anything not a directive name → undefined. */
57 function directiveOf(raw: string | undefined): string | undefined {
58 const name = raw?.trim().split(/\s+/)[0]?.toLowerCase();
59 return name !== undefined && DIRECTIVE.test(name) ? name : undefined;
60 }
61
62 /** A URL's host (an http or ws scheme), its scheme otherwise (`data`), or a keyword (`inline`, `eval`). */
63 function blockedOf(raw: string | undefined): string | undefined {
64 if (raw === undefined) return undefined;
65 if (raw === "") return "none";
66 if (KEYWORD.test(raw)) return raw;
67 try {
68 const url = new URL(raw);
69 if (/^(https?|wss?):$/.test(url.protocol)) return url.host.slice(0, 253) || undefined;
70 const scheme = url.protocol.slice(0, -1).toLowerCase();
71 return KEYWORD.test(scheme) ? scheme : undefined;
72 } catch {
73 return undefined;
74 }
75 }
76
77 function pathOf(raw: string | undefined): string | undefined {
78 if (raw === undefined) return undefined;
79 try {
80 return new URL(raw).pathname.slice(0, PATH_MAX);
81 } catch {
82 return undefined;
83 }
84 }
85
86 function one(directive: unknown, blocked: unknown, page: unknown): Violation | undefined {
87 const d = directiveOf(text(directive));
88 const b = blockedOf(text(blocked));
89 const p = pathOf(text(page));
90 return d === undefined || b === undefined || p === undefined ? undefined : { directive: d, blocked: b, path: p };
91 }
92
93 /** The violations a body names, or "malformed" when it is not one of the two shapes. */
94 export function parseCspReport(body: string): Violation[] | "malformed" {
95 let data: unknown;
96 try {
97 data = JSON.parse(body);
98 } catch {
99 return "malformed";
100 }
101 if (Array.isArray(data)) {
102 if (data.length === 0 || data.length > 20) return "malformed";
103 const out: Violation[] = [];
104 for (const r of data as unknown[]) {
105 if (typeof r !== "object" || r === null) return "malformed";
106 const { type, body: b } = r as { type?: unknown; body?: unknown };
107 if (type !== "csp-violation") continue;
108 if (typeof b !== "object" || b === null) return "malformed";
109 const v = b as Record<string, unknown>;
110 const got = one(v.effectiveDirective ?? v.violatedDirective, v.blockedURL, v.documentURL);
111 if (got === undefined) return "malformed";
112 out.push(got);
113 }
114 return out.length === 0 ? "malformed" : out;
115 }
116 if (typeof data !== "object" || data === null) return "malformed";
117 const r = (data as Record<string, unknown>)["csp-report"];
118 if (typeof r !== "object" || r === null) return "malformed";
119 const v = r as Record<string, unknown>;
120 const got = one(v["effective-directive"] ?? v["violated-directive"], v["blocked-uri"], v["document-uri"]);
121 return got === undefined ? "malformed" : [got];
122 }
123
124 /** Count one violation on its UTC day; past `DAY_ROWS` distinct rows a day, the rest share one. */
125 export function recordViolation(db: Database, v: Violation, now: Date = new Date()): void {
126 const day = now.toISOString().slice(0, 10);
127 const bump = db.query<unknown, [string, string, string, string]>(
128 "UPDATE csp_violation SET n = n + 1 WHERE day = ?1 AND directive = ?2 AND blocked = ?3 AND path = ?4",
129 );
130 if (bump.run(day, v.directive, v.blocked, v.path).changes > 0) return;
131 const rows = db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM csp_violation WHERE day = ?1").get(day)?.n ?? 0;
132 const key = rows < DAY_ROWS ? v : { directive: v.directive, blocked: OVERFLOW, path: "" };
133 db.query(
134 `INSERT INTO csp_violation (day, directive, blocked, path, n) VALUES (?1, ?2, ?3, ?4, 1)
135 ON CONFLICT (day, directive, blocked, path) DO UPDATE SET n = n + 1`,
136 ).run(day, key.directive, key.blocked, key.path);
137 if (rows === 0) {
138 const cutoff = new Date(now.getTime() - KEEP_DAYS * 86_400_000).toISOString().slice(0, 10);
139 db.query("DELETE FROM csp_violation WHERE day < ?1").run(cutoff);
140 }
141 }
142
143 /** Violations over the last `days` UTC days (today included), per directive, most first. */
144 export function violationsByDirective(db: Database, days = 7, now: Date = new Date()): { directive: string; n: number }[] {
145 const since = new Date(now.getTime() - (days - 1) * 86_400_000).toISOString().slice(0, 10);
146 return db
147 .query<{ directive: string; n: number }, [string]>(
148 "SELECT directive, SUM(n) AS n FROM csp_violation WHERE day >= ?1 GROUP BY directive ORDER BY n DESC, directive",
149 )
150 .all(since);
151 }
152
153 export async function cspReportSubmit(ctx: Ctx): Promise<Response> {
154 const { request, app } = ctx;
155 const origin = request.headers.get("origin");
156 // A report carries no Origin or this forge's own; any other site's is not ours to count.
157 if (origin !== null && origin !== "null" && origin !== ctx.url.origin) return answer(403);
158 const type = (request.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase();
159 if (!TYPES.has(type)) return answer(415);
160
161 let pair = limits.get(app);
162 if (pair === undefined) {
163 pair = { client: limiter(PER_CLIENT), forge: limiter(PER_FORGE) };
164 limits.set(app, pair);
165 }
166 for (const verdict of [pair.client.take(clientBucket(request), "ro"), pair.forge.take("forge", "ro")]) {
167 if (!verdict.allowed) return answer(429, { "retry-after": String(Math.max(1, verdict.retryAfter)) });
168 }
169
170 const bytes = await readBounded(request, CSP_REPORT_MAX);
171 if (bytes === "too_large") return answer(413);
172 const found = parseCspReport(new TextDecoder().decode(bytes));
173 if (found === "malformed") return answer(400);
174 for (const v of found) recordViolation(app.db, v);
175 return answer(204);
176 }
src/http/server.ts +3
@@ -34,6 +34,7 @@ import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts";
3434 import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
3535 import { allFlowsPage } from "../web/pages/flows-all.ts";
3636 import { measureSubmit } from "./measure.ts";
37 import { cspReportSubmit } from "./csp_report.ts";
3738 import { settingsRoutes } from "./settings_routes.ts";
3839 import { pluginRoutes } from "./plugin_routes.ts";
3940 import { pinnedRoutes } from "./pinned_routes.ts";
@@ -803,6 +804,8 @@ export function buildRouter(): Router<Handler> {
803804 router.add("GET", "/sw.js", serviceWorker);
804805 // The page-view beacon's report (http/measure.ts): numbers only, no IP kept.
805806 router.add("POST", "/_measure", (ctx) => measureSubmit(ctx, routeOf));
807 // What a page's Content-Security-Policy blocked (http/csp_report.ts): counts only.
808 router.add("POST", "/csp-report", cspReportSubmit);
806809 router.add("GET", "/", home);
807810 // A spawned child's claim link: GET shows, POST takes it (spawn/claim.ts).
808811 router.add("GET", "/claim", claimFragmentPage);
src/tui/screens/controls.ts +16 −2
@@ -1,10 +1,14 @@
11 /**
22 * Security controls in the terminal, a forge admin's: the same lines as
33 * /settings/security/controls (`checks/controls.ts`), each safeguard and when
4 * it was last tested. Read-only.
4 * it was last tested, then what the page policy blocked this week by
5 * directive (the security page's "Blocked by the page policy" line), absent
6 * when nothing was. Read-only.
57 */
68
9 import type { Database } from "bun:sqlite";
710 import { controlStandings } from "../../checks/controls.ts";
11 import { violationsByDirective } from "../../http/csp_report.ts";
812 import type { Ctx, Screen } from "../app/app.ts";
913 import { binding } from "../app/keys.ts";
1014 import type { Session } from "../session.ts";
@@ -24,9 +28,19 @@ export function controlRows(dataDir: string, nowMs: number): ListRow[] {
2428 }));
2529 }
2630
31 /** One row per directive the Content-Security-Policy blocked in the last 7 days; none when nothing was. */
32 export function cspRows(db: Database, nowMs: number): ListRow[] {
33 return violationsByDirective(db, 7, new Date(nowMs)).map((w) => ({
34 title: `Blocked by the page policy: ${w.directive}`,
35 secondary: "",
36 meta: `${w.n} in 7 days`,
37 }));
38 }
39
2740 export function controlsScreen(f: Session, ctx: Ctx): Screen {
2841 const state = new ListState();
29 const rows = controlRows(f.app.config.dataDir, Date.now());
42 const now = Date.now();
43 const rows = [...controlRows(f.app.config.dataDir, now), ...cspRows(f.app.db, now)];
3044 return {
3145 title: "Security controls",
3246 keys: [binding(["j", "k"], "move cursor")],
src/web/layout.ts +4 −1
@@ -66,6 +66,7 @@ import { quotaFull } from "../spawn/quota.ts";
6666 import { icon, inlineIcons, pointAtSprite, themeGlyph } from "./icons.ts";
6767 import { btn, copyField, hintAttr, kbd, menu, type MenuItem } from "./ui.ts";
6868 import { SITE_LINE } from "./site_line.ts";
69 import { CSP_REPORT_DIRECTIVES, CSP_REPORTING_ENDPOINTS } from "../http/csp_report.ts";
6970
7071 /** One row of the command palette: title, href, category. */
7172 export interface PaletteItem {
@@ -927,7 +928,9 @@ export function page(options: PageOptions, status = 200): Response {
927928 // The hashed stylesheet and scripts are 'self'; the two inline <style>s
928929 // are named by hash. connect-src 'self' lets the live event stream
929930 // (EventSource) and fragment fetches reach same-origin.
930 "content-security-policy": `default-src 'none'; style-src 'self' ${styleSources}; script-src 'self'${options.wasm ? " 'wasm-unsafe-eval'" : ""}${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; connect-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; ${frameSources(options)}worker-src 'self'; manifest-src 'self'; img-src 'self' data:; ${options.objectSrc ? "object-src 'self'; " : ""}${options.fontSrc ? "font-src 'self'; " : ""}form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
931 "content-security-policy": `default-src 'none'; style-src 'self' ${styleSources}; script-src 'self'${options.wasm ? " 'wasm-unsafe-eval'" : ""}${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; connect-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; ${frameSources(options)}worker-src 'self'; manifest-src 'self'; img-src 'self' data:; ${options.objectSrc ? "object-src 'self'; " : ""}${options.fontSrc ? "font-src 'self'; " : ""}form-action 'self'; base-uri 'none'; frame-ancestors 'none'; ${CSP_REPORT_DIRECTIVES}`,
932 // Where the browser says what that policy blocked (http/csp_report.ts).
933 "reporting-endpoints": CSP_REPORTING_ENDPOINTS,
931934 "referrer-policy": "same-origin",
932935 "x-content-type-options": "nosniff",
933936 // Markup is revalidated every time; only the hashed assets it names are
src/web/pages/security.ts +14
@@ -14,6 +14,7 @@ import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1414 import { backupOverdue, trafficAttention, TRAFFIC_WORDS } from "../../flow/kpi_words.ts";
1515 import { readBackup } from "../../flow/kpis.ts";
1616 import type { Ctx } from "../../http/context.ts";
17 import { violationsByDirective } from "../../http/csp_report.ts";
1718 import { perfRecord } from "../../perf/live.ts";
1819 import { getSignup } from "../../settings/signup.ts";
1920 import { trafficHistory } from "../../perf/traffic_store.ts";
@@ -237,6 +238,18 @@ function reportRow(dataDir: string, nowMs: number): Html {
237238 );
238239 }
239240
241 /** What browsers said the Content-Security-Policy blocked this week, by directive; nothing when nothing was. */

Both doors: this line is only on the page. Add the same weekly counts to the terminal's security screen (src/tui/screens/controls.ts), admin only, with a test.

242 function cspRow(db: Database, nowMs: number): Html | null {
243 const week = violationsByDirective(db, 7, new Date(nowMs));
244 if (week.length === 0) return null;
245 return statusRow(
246 "Blocked by the page policy, 7 days",
247 html`${week.map((w, i) => html`${i > 0 ? ", " : ""}<span class="mono">${w.directive}</span> ${w.n}`)}`,
248 null,
249 "csp",
250 );
251 }
252
240253 export function securityPage(ctx: Ctx): Response {
241254 if (!ctx.auth.user) return signInFirst(ctx);
242255 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
@@ -283,6 +296,7 @@ export function securityPage(ctx: Ctx): Response {
283296 : html`${drill.verdict === "green" ? badge("Passed", { tone: "success" }) : badge("Failed", { tone: "danger" })} Last ran ${time(drill.at)}.`,
284297 )}
285298 ${reportRow(dataDir, nowMs)}
299 ${cspRow(db, nowMs)}
286300 ${statusRow("Admin accounts", html`${admins.map((a, i) => html`${i > 0 ? ", " : ""}${a.username}${a.is_agent === 1 ? " (agent)" : ""}${a.suspended_at === null ? "" : " (suspended)"}`)}`)}
287301 ${statusRow("Admin-scope tokens", none(adminTokens.length, adminTokens.length === 1 ? "token" : "tokens"))}
288302 ${statusRow(`Keys and tokens added, ${WINDOW_DAYS} days`, none(added.length, "added"))}
test/csp-report.test.ts added +141
@@ -0,0 +1,141 @@
1 // CSP violations are reported to the forge, counted by directive, and shown on the security page (#10759).
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createSession } from "../src/core/sessions.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { CSP_REPORT_MAX, parseCspReport, recordViolation, violationsByDirective } from "../src/http/csp_report.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { cspRows, entry as controlsEntry } from "../src/tui/screens/controls.ts";
12
13 let dir: string;
14 let app: App;
15 let servers: Servers;
16 let base: string;
17 let cookie = "";
18
19 beforeAll(async () => {
20 dir = await mkdtemp(join(tmpdir(), "csp-report-"));
21 app = createApp({ dataDir: dir, port: 0 });
22 servers = await serve(app);
23 base = `http://127.0.0.1:${servers.port}`;
24 const made = await createUser(app.db, { origin: "seed", trusted: true, password: "a-good-passphrase", username: "alice", email: "[email protected]", isAdmin: true });
25 if (!made.ok) throw new Error("no user");
26 cookie = `forge_session=${createSession(app.db, made.value.id).token}`;
27 });
28 afterAll(async () => {
29 await servers.stop();
30 await rm(dir, { recursive: true, force: true });
31 });
32
33 const rows = () => app.db.query("SELECT day, directive, blocked, path, n FROM csp_violation ORDER BY directive").all() as Record<string, unknown>[];
34 const send = (body: string, type = "application/csp-report", headers: Record<string, string> = {}) =>
35 fetch(`${base}/csp-report`, { method: "POST", headers: { "content-type": type, "x-forwarded-for": "10.9.9.9", ...headers }, body });
36 const legacy = (o: Record<string, unknown>) => JSON.stringify({ "csp-report": o });
37
38 describe("POST /csp-report", () => {
39 test("a valid report is counted with only its directive, host and path", async () => {
40 const body = legacy({
41 "document-uri": `${base}/alice/secret/issues?token=abc`,
42 "violated-directive": "script-src-elem 'self'",
43 "effective-directive": "script-src-elem",
44 "blocked-uri": "https://evil.example:8443/x.js?q=private",
45 "original-policy": "default-src 'none'",
46 });
47 expect((await send(body)).status).toBe(204);
48 expect((await send(body)).status).toBe(204);
49 expect(rows()).toEqual([
50 { day: new Date().toISOString().slice(0, 10), directive: "script-src-elem", blocked: "evil.example:8443", path: "/alice/secret/issues", n: 2 },
51 ]);
52 });
53
54 test("the Reporting API's shape is counted too", async () => {
55 const body = JSON.stringify([
56 { type: "csp-violation", body: { documentURL: `${base}/`, effectiveDirective: "style-src-attr", blockedURL: "inline" } },
57 ]);
58 expect((await send(body, "application/reports+json")).status).toBe(204);
59 expect(rows().find((r) => r.directive === "style-src-attr")).toMatchObject({ blocked: "inline", path: "/", n: 1 });
60 });
61
62 test("oversized, malformed, wrong-type or foreign reports are 4xx and store nothing", async () => {
63 const before = JSON.stringify(rows());
64 const big = legacy({ "document-uri": `${base}/`, "violated-directive": "img-src", "blocked-uri": "https://x.example/" + "a".repeat(CSP_REPORT_MAX) });
65 expect((await send(big)).status).toBe(413);
66 expect((await send("{not json")).status).toBe(400);
67 expect((await send(JSON.stringify({ hello: 1 }))).status).toBe(400);
68 expect((await send(legacy({ "document-uri": `${base}/`, "violated-directive": "<script>", "blocked-uri": "inline" }))).status).toBe(400);
69 expect((await send(legacy({ "document-uri": "not a url", "violated-directive": "img-src", "blocked-uri": "inline" }))).status).toBe(400);
70 const ok = legacy({ "document-uri": `${base}/`, "violated-directive": "img-src", "blocked-uri": "inline" });
71 expect((await send(ok, "text/plain")).status).toBe(415);
72 expect((await send(ok, "application/csp-report", { origin: "https://elsewhere.example" })).status).toBe(403);
73 expect(JSON.stringify(rows())).toBe(before);
74 });
75
76 test("an anonymous caller is rate limited", async () => {
77 const ok = legacy({ "document-uri": `${base}/`, "violated-directive": "font-src", "blocked-uri": "data" });
78 const statuses: number[] = [];
79 for (let i = 0; i < 35; i++) statuses.push((await send(ok, "application/csp-report", { "x-forwarded-for": "10.7.7.7" })).status);
80 expect(statuses).toContain(429);
81 });
82 });
83
84 describe("the policy names the endpoint", () => {
85 test("a normal page carries report-uri and Reporting-Endpoints without loosening", async () => {
86 const res = await fetch(`${base}/explore`);
87 const csp = res.headers.get("content-security-policy") ?? "";
88 expect(csp).toContain("report-uri /csp-report");
89 expect(csp).toContain("report-to csp");
90 expect(csp).toContain("default-src 'none'");
91 expect(csp).not.toContain("unsafe");
92 expect(res.headers.get("reporting-endpoints")).toBe('csp="/csp-report"');
93 });
94
95 test("a non-document answer's floor policy stays bare: an app's host could not count a relative report", async () => {
96 const res = await fetch(`${base}/api/v1/nothing-here`);
97 expect(res.headers.get("content-security-policy")).toBe("default-src 'none'; frame-ancestors 'none'; base-uri 'none'");
98 });
99 });
100
101 describe("the security page", () => {
102 test("one quiet line by directive this week, and nothing when there are none", async () => {
103 const page = async () => (await fetch(`${base}/settings/security`, { headers: { cookie } })).text();
104 app.db.run("DELETE FROM csp_violation");
105 expect(await page()).not.toContain('id="csp"');
106 const now = new Date();
107 recordViolation(app.db, { directive: "img-src", blocked: "x.example", path: "/" }, now);
108 recordViolation(app.db, { directive: "img-src", blocked: "y.example", path: "/a" }, now);
109 recordViolation(app.db, { directive: "frame-src", blocked: "z.example", path: "/" }, new Date(now.getTime() - 10 * 86_400_000));
110 expect(violationsByDirective(app.db, 7, now)).toEqual([{ directive: "img-src", n: 2 }]);
111 const html = await page();
112 expect(html).toContain('id="csp"');
113 expect(html).toMatch(/<span class="mono">img-src<\/span> 2/);
114 expect(html).not.toContain("frame-src");
115 });
116
117 test("a parse never keeps a query string", () => {
118 const got = parseCspReport(legacy({ "document-uri": "https://f.example/p?x=1#h", "violated-directive": "img-src", "blocked-uri": "https://h.example/i.png?k=v" }));
119 expect(got).toEqual([{ directive: "img-src", blocked: "h.example", path: "/p" }]);
120 });
121 });
122
123 describe("the terminal's security controls screen", () => {
124 test("lists the week's counts by directive, and nothing when there are none", () => {
125 app.db.run("DELETE FROM csp_violation");
126 const now = Date.now();
127 expect(cspRows(app.db, now)).toEqual([]);
128 recordViolation(app.db, { directive: "script-src-elem", blocked: "inline", path: "/" }, new Date(now));
129 recordViolation(app.db, { directive: "script-src-elem", blocked: "inline", path: "/" }, new Date(now));
130 recordViolation(app.db, { directive: "frame-src", blocked: "z.example", path: "/" }, new Date(now - 10 * 86_400_000));
131 expect(cspRows(app.db, now)).toEqual([{ title: "Blocked by the page policy: script-src-elem", secondary: "", meta: "2 in 7 days" }]);
132 });
133
134 test("the screen is a human admin's only", () => {
135 const session = (user: unknown) => ({ user }) as never;
136 expect(controlsEntry.offered(session({ isAdmin: true, kind: "human" }))).toBe(true);
137 expect(controlsEntry.offered(session({ isAdmin: true, kind: "agent" }))).toBe(false);
138 expect(controlsEntry.offered(session({ isAdmin: false, kind: "human" }))).toBe(false);
139 expect(controlsEntry.offered(session(undefined))).toBe(false);
140 });
141 });
test/forge-looks.test.ts +1
@@ -48,6 +48,7 @@ const NOT_A_PAGE: Record<string, string> = {
4848 "/recover": "an account-recovery form, not a page of the forge",
4949 "/verify": "answers 400 without the emailed code",
5050 "/logout": "a mutation, answers 404 to a GET",
51 "/csp-report": "a mutation: where browsers report what the page policy blocked",
5152 "/theme": "a mutation, answers 404 to a GET",
5253 "/tell": "answers 404 to a GET",
5354 "/asks": "redirects to /#needs-you",
test/map-seed-timing.test.ts +8 −1
@@ -80,7 +80,14 @@ afterAll(async () => {
8080 await rm(dataDir, { recursive: true, force: true });
8181 });
8282
83 /** The snapshot is keyed by the minute: start a cold/warm pair well clear of the next one. */
84 const clearOfMinute = async (): Promise<void> => {
85 const left = 60_000 - (Date.now() % 60_000);
86 if (left < 5_000) await Bun.sleep(left + 50);
87 };
88
8389 test("warm, the seed's map answers from its snapshot with the bytes of a cold build", async () => {
90 await clearOfMinute();
8491 mapSnapshots(app.db).clear();
8592 const cold = await get();
8693 const data = JSON.parse(cold.body) as { issues: unknown[]; edges: unknown[]; pillars: { merged?: number }[] };
@@ -96,7 +103,7 @@ test("warm, the seed's map answers from its snapshot with the bytes of a cold bu
96103 expectWork(await statements(app.db, async () => void (await get())), { max: 9 });
97104 expect(mapSnapshots(app.db).hits).toBeGreaterThan(warmHits);
98105 expect(warm.timing).toContain("asktolive");
99 });
106 }, 20_000);
100107
101108 test("a plan is on the next read", async () => {
102109 await get();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs