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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10759-content-security-policy-violations-are-r → main 5 commits fork 2 threads · 2 unresolved
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[] = [ | ||
| 65 | 65 | "board", |
| 66 | 66 | "boards", |
| 67 | 67 | "contribute", |
| 68 | "csp-report", | |
| 68 | 69 | "deploy-to", |
| 69 | 70 | "pinned", |
| 70 | 71 | "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"; | ||
| 34 | 34 | import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; |
| 35 | 35 | import { allFlowsPage } from "../web/pages/flows-all.ts"; |
| 36 | 36 | import { measureSubmit } from "./measure.ts"; |
| 37 | import { cspReportSubmit } from "./csp_report.ts"; | |
| 37 | 38 | import { settingsRoutes } from "./settings_routes.ts"; |
| 38 | 39 | import { pluginRoutes } from "./plugin_routes.ts"; |
| 39 | 40 | import { pinnedRoutes } from "./pinned_routes.ts"; |
| @@ -803,6 +804,8 @@ export function buildRouter(): Router<Handler> { | ||
| 803 | 804 | router.add("GET", "/sw.js", serviceWorker); |
| 804 | 805 | // The page-view beacon's report (http/measure.ts): numbers only, no IP kept. |
| 805 | 806 | 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); | |
| 806 | 809 | router.add("GET", "/", home); |
| 807 | 810 | // A spawned child's claim link: GET shows, POST takes it (spawn/claim.ts). |
| 808 | 811 | router.add("GET", "/claim", claimFragmentPage); |
src/tui/screens/controls.ts +16 −2
| @@ -1,10 +1,14 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Security controls in the terminal, a forge admin's: the same lines as |
| 3 | 3 | * /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. | |
| 5 | 7 | */ |
| 6 | 8 | |
| 9 | import type { Database } from "bun:sqlite"; | |
| 7 | 10 | import { controlStandings } from "../../checks/controls.ts"; |
| 11 | import { violationsByDirective } from "../../http/csp_report.ts"; | |
| 8 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 9 | 13 | import { binding } from "../app/keys.ts"; |
| 10 | 14 | import type { Session } from "../session.ts"; |
| @@ -24,9 +28,19 @@ export function controlRows(dataDir: string, nowMs: number): ListRow[] { | ||
| 24 | 28 | })); |
| 25 | 29 | } |
| 26 | 30 | |
| 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 | ||
| 27 | 40 | export function controlsScreen(f: Session, ctx: Ctx): Screen { |
| 28 | 41 | 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)]; | |
| 30 | 44 | return { |
| 31 | 45 | title: "Security controls", |
| 32 | 46 | keys: [binding(["j", "k"], "move cursor")], |
src/web/layout.ts +4 −1
| @@ -66,6 +66,7 @@ import { quotaFull } from "../spawn/quota.ts"; | ||
| 66 | 66 | import { icon, inlineIcons, pointAtSprite, themeGlyph } from "./icons.ts"; |
| 67 | 67 | import { btn, copyField, hintAttr, kbd, menu, type MenuItem } from "./ui.ts"; |
| 68 | 68 | import { SITE_LINE } from "./site_line.ts"; |
| 69 | import { CSP_REPORT_DIRECTIVES, CSP_REPORTING_ENDPOINTS } from "../http/csp_report.ts"; | |
| 69 | 70 | |
| 70 | 71 | /** One row of the command palette: title, href, category. */ |
| 71 | 72 | export interface PaletteItem { |
| @@ -927,7 +928,9 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 927 | 928 | // The hashed stylesheet and scripts are 'self'; the two inline <style>s |
| 928 | 929 | // are named by hash. connect-src 'self' lets the live event stream |
| 929 | 930 | // (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, | |
| 931 | 934 | "referrer-policy": "same-origin", |
| 932 | 935 | "x-content-type-options": "nosniff", |
| 933 | 936 | // 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"; | ||
| 14 | 14 | import { backupOverdue, trafficAttention, TRAFFIC_WORDS } from "../../flow/kpi_words.ts"; |
| 15 | 15 | import { readBackup } from "../../flow/kpis.ts"; |
| 16 | 16 | import type { Ctx } from "../../http/context.ts"; |
| 17 | import { violationsByDirective } from "../../http/csp_report.ts"; | |
| 17 | 18 | import { perfRecord } from "../../perf/live.ts"; |
| 18 | 19 | import { getSignup } from "../../settings/signup.ts"; |
| 19 | 20 | import { trafficHistory } from "../../perf/traffic_store.ts"; |
| @@ -237,6 +238,18 @@ function reportRow(dataDir: string, nowMs: number): Html { | ||
| 237 | 238 | ); |
| 238 | 239 | } |
| 239 | 240 | |
| 241 | /** What browsers said the Content-Security-Policy blocked this week, by directive; nothing when nothing was. */ | |
| 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 | ||
| 240 | 253 | export function securityPage(ctx: Ctx): Response { |
| 241 | 254 | if (!ctx.auth.user) return signInFirst(ctx); |
| 242 | 255 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); |
| @@ -283,6 +296,7 @@ export function securityPage(ctx: Ctx): Response { | ||
| 283 | 296 | : html`${drill.verdict === "green" ? badge("Passed", { tone: "success" }) : badge("Failed", { tone: "danger" })} Last ran ${time(drill.at)}.`, |
| 284 | 297 | )} |
| 285 | 298 | ${reportRow(dataDir, nowMs)} |
| 299 | ${cspRow(db, nowMs)} | |
| 286 | 300 | ${statusRow("Admin accounts", html`${admins.map((a, i) => html`${i > 0 ? ", " : ""}${a.username}${a.is_agent === 1 ? " (agent)" : ""}${a.suspended_at === null ? "" : " (suspended)"}`)}`)} |
| 287 | 301 | ${statusRow("Admin-scope tokens", none(adminTokens.length, adminTokens.length === 1 ? "token" : "tokens"))} |
| 288 | 302 | ${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> = { | ||
| 48 | 48 | "/recover": "an account-recovery form, not a page of the forge", |
| 49 | 49 | "/verify": "answers 400 without the emailed code", |
| 50 | 50 | "/logout": "a mutation, answers 404 to a GET", |
| 51 | "/csp-report": "a mutation: where browsers report what the page policy blocked", | |
| 51 | 52 | "/theme": "a mutation, answers 404 to a GET", |
| 52 | 53 | "/tell": "answers 404 to a GET", |
| 53 | 54 | "/asks": "redirects to /#needs-you", |
test/map-seed-timing.test.ts +8 −1
| @@ -80,7 +80,14 @@ afterAll(async () => { | ||
| 80 | 80 | await rm(dataDir, { recursive: true, force: true }); |
| 81 | 81 | }); |
| 82 | 82 | |
| 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 | ||
| 83 | 89 | test("warm, the seed's map answers from its snapshot with the bytes of a cold build", async () => { |
| 90 | await clearOfMinute(); | |
| 84 | 91 | mapSnapshots(app.db).clear(); |
| 85 | 92 | const cold = await get(); |
| 86 | 93 | 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 | ||
| 96 | 103 | expectWork(await statements(app.db, async () => void (await get())), { max: 9 }); |
| 97 | 104 | expect(mapSnapshots(app.db).hits).toBeGreaterThan(warmHits); |
| 98 | 105 | expect(warm.timing).toContain("asktolive"); |
| 99 | }); | |
| 106 | }, 20_000); | |
| 100 | 107 | |
| 101 | 108 | test("a plan is on the next read", async () => { |
| 102 | 109 | await get(); |
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.