Public: anyone can read this; the crew can change it.
A page's uncaught script errors are counted like CSP violations, so a stranger's /claim names its own console errors #11451
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11447-a-page-s-uncaught-script-errors-are-coun → main 5 commits fork 3 threads · 3 unresolved
14 files
changed +324
−9
docs/features/csp-reports.md +10
| @@ -5,3 +5,13 @@ Every page the forge renders carries `report-uri /csp-report` and `report-to csp | ||
| 5 | 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 hour in `csp_violation` (its `day` column holds `YYYY-MM-DDTHH`), so a window's count holds only reports from the window's whole hours, never a report older than it. That is at most 500 rows a UTC 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 | 6 | |
| 7 | 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. |
| 8 | ||
| 9 | # Script errors | |
| 10 | ||
| 11 | The page-view beacon also reports uncaught script errors (`error` and `unhandledrejection`) to `POST /_script-error` (`src/http/script_errors.ts`), so a page that throws is seen, not guessed (#11447). | |
| 12 | ||
| 13 | - **Reported:** the page's path, the same-origin script's path (no query string, never another origin's) and line, and the message's first line cut to 120 characters. Up to 5 per page view. Under Do-Not-Track or the `forge_measure=off` cookie the beacon registers no listener and the server drops the report too. | |
| 14 | - **Refused:** anything over 2 KB (counted by bytes read), another shape, a cross-origin `Origin`, an unknown page. Nothing is stored for those. | |
| 15 | - **Kept:** one count per page, place, message and UTC hour, with bounded rows a day, for 30 days. | |
| 16 | - **Shown:** `/settings/security` ("Script errors on pages, 7 days"), the terminal's Security controls screen (one row per error, below the policy rows), and the MCP `system` tool's `csp` kind. All admin only. | |
| 17 | - **Not for spawned apps:** their copy of the beacon posts to the app's own `/_script-error`, which the forge proxy does not carry, so errors on a spawned app's pages are not counted. Only the forge's own pages are. |
src/core/migration-pins/20261111160000-script-errors.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 0ea5a044558cd48075fc44d1a5f3a2a9f4fa610fe44611abe18224c2d1c207f9 |
src/core/migrations/20261111160000-script-errors.ts added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | // Uncaught script errors the page-view beacon reports (#11447): one count per | |
| 2 | // UTC hour, page, script path, line and message. Bounded per day by | |
| 3 | // `recordScriptError` (src/http/script_errors.ts). Additive. | |
| 4 | export default ` | |
| 5 | CREATE TABLE script_error ( | |
| 6 | day TEXT NOT NULL, | |
| 7 | page TEXT NOT NULL, | |
| 8 | src TEXT NOT NULL, | |
| 9 | line INTEGER NOT NULL, | |
| 10 | msg TEXT NOT NULL, | |
| 11 | n INTEGER NOT NULL DEFAULT 0, | |
| 12 | seen INTEGER NOT NULL DEFAULT 0, | |
| 13 | PRIMARY KEY (day, page, src, line, msg) | |
| 14 | ) WITHOUT ROWID; | |
| 15 | `; |
src/http/mcp/tools/system.ts +3 −2
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { noteRead, telemetryReader } from "../../../actions/telemetry.ts"; |
| 2 | 2 | import { systemWithReaders } from "../../../system/telemetry.ts"; |
| 3 | import { scriptErrorCounts } from "../../script_errors.ts"; | |
| 3 | 4 | import { violationsByDirective, windowStart } from "../../csp_report.ts"; |
| 4 | 5 | import { countRead } from "../../../system/telemetry_reads.ts"; |
| 5 | 6 | import { stallHour, TOP_ROUTES } from "../../../metrics/stalls.ts"; |
| @@ -9,7 +10,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 9 | 10 | |
| 10 | 11 | export const tool: Tool = { |
| 11 | 12 | name: "system", |
| 12 | doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route or `job:<name>` as {cause, ms, count}, `readings`: the last 20 minutes' worst loop lag as {at, ms, what} beside `barMs` (100) and `over`, and `readers`: who polls the telemetry and how fast; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, as the security page reads them; a forge admin only", | |
| 13 | doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route or `job:<name>` as {cause, ms, count}, `readings`: the last 20 minutes' worst loop lag as {at, ms, what} beside `barMs` (100) and `over`, and `readers`: who polls the telemetry and how fast; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, and `scriptErrors` `{ page, where, message, n, last }`, as the security page reads them; a forge admin only", | |
| 13 | 14 | description: |
| 14 | 15 | "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read; `kind: csp`: 24 h of CSP violations (admin).", |
| 15 | 16 | inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp"] } } }, |
| @@ -19,7 +20,7 @@ export const tool: Tool = { | ||
| 19 | 20 | if (args["kind"] !== "csp") return bad("invalid", "kind is csp, or absent for the machine"); |
| 20 | 21 | if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads its CSP violations"); |
| 21 | 22 | const now = new Date(); |
| 22 | return good({ hours: 24, since: windowStart(24, now).toISOString(), directives: violationsByDirective(ctx.app.db, 24, now) }); | |
| 23 | return good({ hours: 24, since: windowStart(24, now).toISOString(), directives: violationsByDirective(ctx.app.db, 24, now), scriptErrors: scriptErrorCounts(ctx.app.db, 24, now) }); | |
| 23 | 24 | } |
| 24 | 25 | const reader = telemetryReader(ctx.app, user); |
| 25 | 26 | if (!reader) |
src/http/script_errors.ts added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | /** | |
| 2 | * POST /_script-error: where the beacon says a page threw (#11447), so a real | |
| 3 | * visit names what breaks. Same-origin only, opted-out visitors stored | |
| 4 | * nothing, at most 2 KB, an exact shape `{page, src, line, msg}` or a 4xx: | |
| 5 | * `page` a served route's path, `src` empty or a path (no origin, no query), | |
| 6 | * `line` a small integer, `msg` cut to its first line and 120 characters. | |
| 7 | * One count per page + script + line + message per UTC hour; rows a day are | |
| 8 | * bounded (`DAY_ROWS`, the rest fold into one), days past `KEEP_DAYS` are | |
| 9 | * dropped as new ones arrive, anonymous callers are rate limited. Always an | |
| 10 | * empty answer; nothing is read back except by the admin's security page. | |
| 11 | */ | |
| 12 | ||
| 13 | import type { Database } from "bun:sqlite"; | |
| 14 | import type { App } from "../app.ts"; | |
| 15 | import { RateLimiter } from "../core/ratelimit.ts"; | |
| 16 | import { optedOut } from "../web/measure.ts"; | |
| 17 | import type { Ctx } from "./context.ts"; | |
| 18 | import { readBounded } from "./limits.ts"; | |
| 19 | import { clientBucket } from "./peer.ts"; | |
| 20 | import type { RouteOf } from "./measure.ts"; | |
| 21 | import { windowStart } from "./csp_report.ts"; | |
| 22 | ||
| 23 | export const SCRIPT_ERROR_PATH = "/_script-error"; | |
| 24 | export const SCRIPT_ERROR_MAX = 2 * 1024; | |
| 25 | export const MESSAGE_MAX = 120; | |
| 26 | const PATH_MAX = 200; | |
| 27 | const DAY_ROWS = 500; | |
| 28 | const KEEP_DAYS = 30; | |
| 29 | const OVERFLOW = "(more)"; | |
| 30 | const PER_CLIENT = 30; | |
| 31 | const PER_FORGE = 600; | |
| 32 | ||
| 33 | export interface ScriptError { | |
| 34 | readonly page: string; | |
| 35 | readonly src: string; | |
| 36 | readonly line: number; | |
| 37 | readonly msg: string; | |
| 38 | } | |
| 39 | ||
| 40 | const limiter = (perMinute: number) => new RateLimiter({ perMinute, adminPerMinute: perMinute, enabled: true }); | |
| 41 | const limits = new WeakMap<App, { client: RateLimiter; forge: RateLimiter }>(); | |
| 42 | const answer = (status: number, headers?: Record<string, string>): Response => | |
| 43 | new Response(null, { status, headers: { "cache-control": "no-store", ...headers } }); | |
| 44 | ||
| 45 | const isPath = (v: unknown): v is string => | |
| 46 | typeof v === "string" && v.length <= PATH_MAX && /^\/[^?#\s\\]*$/.test(v) && !v.startsWith("//"); | |
| 47 | ||
| 48 | /** The error a body names, or "malformed". */ | |
| 49 | export function parseScriptError(body: string): ScriptError | "malformed" { | |
| 50 | let data: unknown; | |
| 51 | try { | |
| 52 | data = JSON.parse(body); | |
| 53 | } catch { | |
| 54 | return "malformed"; | |
| 55 | } | |
| 56 | if (typeof data !== "object" || data === null || Array.isArray(data)) return "malformed"; | |
| 57 | const v = data as Record<string, unknown>; | |
| 58 | if (Object.keys(v).length !== 4) return "malformed"; | |
| 59 | const { page, src, line, msg } = v; | |
| 60 | if (!isPath(page)) return "malformed"; | |
| 61 | if (src !== "" && !isPath(src)) return "malformed"; | |
| 62 | if (typeof line !== "number" || !Number.isInteger(line) || line < 0 || line > 10_000_000) return "malformed"; | |
| 63 | if (typeof msg !== "string") return "malformed"; | |
| 64 | // eslint-disable-next-line no-control-regex | |
| 65 | const first = (msg.split(/[\r\n]/)[0] ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, MESSAGE_MAX); | |
| 66 | return first === "" ? "malformed" : { page, src: src as string, line, msg: first }; | |
| 67 | } | |
| 68 | ||
| 69 | const hourOf = (ms: number): string => new Date(ms).toISOString().slice(0, 13); | |
| 70 | ||
| 71 | /** Count one error in its UTC hour; past `DAY_ROWS` distinct rows a UTC day, the rest share one. */ | |
| 72 | export function recordScriptError(db: Database, e: ScriptError, now: Date = new Date()): void { | |
| 73 | const seen = now.getTime(); | |
| 74 | const hour = hourOf(seen); | |
| 75 | const day = hour.slice(0, 10); | |
| 76 | const bump = db.query<unknown, [string, string, string, number, string, number]>( | |
| 77 | "UPDATE script_error SET n = n + 1, seen = ?6 WHERE day = ?1 AND page = ?2 AND src = ?3 AND line = ?4 AND msg = ?5", | |
| 78 | ); | |
| 79 | if (bump.run(hour, e.page, e.src, e.line, e.msg, seen).changes > 0) return; | |
| 80 | const rows = | |
| 81 | db.query<{ n: number }, [string, string]>("SELECT COUNT(*) AS n FROM script_error WHERE day >= ?1 AND day <= ?2").get(`${day}T00`, `${day}T23`)?.n ?? 0; | |
| 82 | const key = rows < DAY_ROWS ? e : { page: "", src: "", line: 0, msg: OVERFLOW }; | |
| 83 | db.query( | |
| 84 | `INSERT INTO script_error (day, page, src, line, msg, n, seen) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6) | |
| 85 | ON CONFLICT (day, page, src, line, msg) DO UPDATE SET n = n + 1, seen = ?6`, | |
| 86 | ).run(hour, key.page, key.src, key.line, key.msg, seen); | |
| 87 | if (rows === 0) { | |
| 88 | const cutoff = new Date(seen - KEEP_DAYS * 86_400_000).toISOString().slice(0, 10); | |
| 89 | db.query("DELETE FROM script_error WHERE day < ?1").run(cutoff); | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | export interface ScriptErrorCount { | |
| 94 | readonly page: string; | |
| 95 | /** The script's path and line, `/a.js:3`; the page's own path when the error was inline. */ | |
| 96 | readonly where: string; | |
| 97 | readonly message: string; | |
| 98 | readonly n: number; | |
| 99 | /** ISO time of the last count. */ | |
| 100 | readonly last: string; | |
| 101 | } | |
| 102 | ||
| 103 | /** Errors counted in the last `hours` (whole UTC hours, as the CSP counts), most counted first. */ | |
| 104 | export function scriptErrorCounts(db: Database, hours = 7 * 24, now: Date = new Date(), limit = 20): ScriptErrorCount[] { | |
| 105 | const from = hourOf(windowStart(hours, now).getTime()); | |
| 106 | return db | |
| 107 | .query<{ page: string; src: string; line: number; msg: string; n: number; seen: number }, [string, number]>( | |
| 108 | `SELECT page, src, line, msg, SUM(n) AS n, MAX(seen) AS seen FROM script_error | |
| 109 | WHERE day >= ?1 GROUP BY page, src, line, msg ORDER BY n DESC, seen DESC LIMIT ?2`, | |
| 110 | ) | |
| 111 | .all(from, limit) | |
| 112 | .map((r) => ({ | |
| 113 | page: r.page, | |
| 114 | where: r.src === "" ? (r.line > 0 ? `inline:${r.line}` : "inline") : `${r.src}:${r.line}`, | |
| 115 | message: r.msg, | |
| 116 | n: r.n, | |
| 117 | last: new Date(r.seen).toISOString(), | |
| 118 | })); | |
| 119 | } | |
| 120 | ||
| 121 | export async function scriptErrorSubmit(ctx: Ctx, routeOf: RouteOf): Promise<Response> { | |
| 122 | const { request, app } = ctx; | |
| 123 | if (request.headers.get("origin") !== ctx.url.origin) return answer(403); | |
| 124 | if (optedOut(request)) return answer(204); | |
| 125 | ||
| 126 | let pair = limits.get(app); | |
| 127 | if (pair === undefined) { | |
| 128 | pair = { client: limiter(PER_CLIENT), forge: limiter(PER_FORGE) }; | |
| 129 | limits.set(app, pair); | |
| 130 | } | |
| 131 | for (const verdict of [pair.client.take(clientBucket(request), "ro"), pair.forge.take("forge", "ro")]) { | |
| 132 | if (!verdict.allowed) return answer(429, { "retry-after": String(Math.max(1, verdict.retryAfter)) }); | |
| 133 | } | |
| 134 | ||
| 135 | const bytes = await readBounded(request, SCRIPT_ERROR_MAX); | |
| 136 | if (bytes === "too_large") return answer(413); | |
| 137 | const found = parseScriptError(new TextDecoder().decode(bytes)); | |
| 138 | if (found === "malformed" || routeOf(found.page) === undefined) return answer(400); | |
| 139 | recordScriptError(app.db, found); | |
| 140 | return answer(204); | |
| 141 | } |
src/http/server.ts +3
| @@ -39,6 +39,7 @@ import { plansSet } from "../web/settings/plans.ts"; | ||
| 39 | 39 | import { allFlowsPage } from "../web/pages/flows-all.ts"; |
| 40 | 40 | import { measureSubmit } from "./measure.ts"; |
| 41 | 41 | import { cspReportSubmit } from "./csp_report.ts"; |
| 42 | import { scriptErrorSubmit } from "./script_errors.ts"; | |
| 42 | 43 | import { stripeHook } from "./stripe_hook.ts"; |
| 43 | 44 | import { settingsRoutes } from "./settings_routes.ts"; |
| 44 | 45 | import { pluginRoutes } from "./plugin_routes.ts"; |
| @@ -834,6 +835,8 @@ export function buildRouter(): Router<Handler> { | ||
| 834 | 835 | router.add("GET", "/sw.js", serviceWorker); |
| 835 | 836 | // The page-view beacon's report (http/measure.ts): numbers only, no IP kept. |
| 836 | 837 | router.add("POST", "/_measure", (ctx) => measureSubmit(ctx, routeOf)); |
| 838 | // An uncaught script error the beacon saw (http/script_errors.ts): counts only. | |
| 839 | router.add("POST", "/_script-error", (ctx) => scriptErrorSubmit(ctx, routeOf)); | |
| 837 | 840 | router.add("POST", "/_funnel", funnelSubmit); |
| 838 | 841 | // What a page's Content-Security-Policy blocked (http/csp_report.ts): counts only. |
| 839 | 842 | router.add("POST", "/csp-report", cspReportSubmit); |
src/tui/screens/controls.ts +12 −2
| @@ -3,12 +3,13 @@ | ||
| 3 | 3 | * /settings/security/controls (`checks/controls.ts`), each safeguard and when |
| 4 | 4 | * it was last tested, then what the page policy blocked this week by |
| 5 | 5 | * directive (the security page's "Blocked by the page policy" line), absent |
| 6 | * when nothing was. Read-only. | |
| 6 | * when nothing was, then the uncaught script errors pages reported (#11447). Read-only. | |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { controlStandings } from "../../checks/controls.ts"; |
| 11 | 11 | import { violationsByDirective } from "../../http/csp_report.ts"; |
| 12 | import { scriptErrorCounts } from "../../http/script_errors.ts"; | |
| 12 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 14 | import { binding } from "../app/keys.ts"; |
| 14 | 15 | import type { Session } from "../session.ts"; |
| @@ -37,10 +38,19 @@ export function cspRows(db: Database, nowMs: number): ListRow[] { | ||
| 37 | 38 | })); |
| 38 | 39 | } |
| 39 | 40 | |
| 41 | /** One row per page, place and message of the uncaught script errors browsers reported in the last 7 days; none when none. */ | |
| 42 | export function scriptErrorRows(db: Database, nowMs: number): ListRow[] { | |
| 43 | return scriptErrorCounts(db, 7 * 24, new Date(nowMs)).map((r) => ({ | |
| 44 | title: `Script error on ${r.page}: ${r.message}`, | |
| 45 | secondary: r.where, | |
| 46 | meta: `${r.n} in 7 days`, | |
| 47 | })); | |
| 48 | } | |
| 49 | ||
| 40 | 50 | export function controlsScreen(f: Session, ctx: Ctx): Screen { |
| 41 | 51 | const state = new ListState(); |
| 42 | 52 | const now = Date.now(); |
| 43 | const rows = [...controlRows(f.app.config.dataDir, now), ...cspRows(f.app.db, now)]; | |
| 53 | const rows = [...controlRows(f.app.config.dataDir, now), ...cspRows(f.app.db, now), ...scriptErrorRows(f.app.db, now)]; | |
| 44 | 54 | return { |
| 45 | 55 | title: "Security controls", |
| 46 | 56 | keys: [binding(["j", "k"], "move cursor")], |
src/web/asset.ts +1 −1
| @@ -207,7 +207,7 @@ export function spriteAsset(): FileAsset { | ||
| 207 | 207 | * plus everything that is a file, an API or an edge hand-off. |
| 208 | 208 | */ |
| 209 | 209 | export const NO_PREFETCH_PATHS = [ |
| 210 | "/assets/*", "/api/*", "/_plat/*", "/_measure*", "/mcp*", "/oauth/*", "/sw.js", | |
| 210 | "/assets/*", "/api/*", "/_plat/*", "/_measure*", "/_script-error", "/mcp*", "/oauth/*", "/sw.js", | |
| 211 | 211 | "/*/*/raw/*", "/*/*/media/*", "/*/*/archive/*", "/*/*/app/*/open", |
| 212 | 212 | "/logout*", "/signout*", "/sign-out*", "/signin*", "/sign-in*", "/login*", |
| 213 | 213 | "/claim*", "/first-admin*", "/verify*", "/go/*", "/-/*", "/apps/open*", |
src/web/beacon.ts +10 −3
| @@ -8,8 +8,12 @@ | ||
| 8 | 8 | * being measured belongs to the page that was loaded. Nothing |
| 9 | 9 | * is sent under Do-Not-Track or with the `forge_measure=off` cookie; the |
| 10 | 10 | * server checks both again. A metric it could not measure is 0 (or absent). |
| 11 | * It also sends the referrer, the query string and the host of the last external | |
| 12 | * link clicked; the server keeps only the referrer's host, the three `utm_` | |
| 11 | * It also sends up to 5 uncaught script errors a page view (`error`, | |
| 12 | * `unhandledrejection`) to /_script-error: page, same-origin script path | |
| 13 | * (no query) and line, message cut to 120 (#11447). | |
| 14 | * | |
| 15 | * The measurement also carries the referrer, the query string and the host of | |
| 16 | * the last external link clicked; the server keeps only the referrer's host, the three `utm_` | |
| 13 | 17 | * fields, and a clicked host that is in the named set (`perf/sources.ts`). |
| 14 | 18 | */ |
| 15 | 19 | export const beaconJs = `!function(n,d,w){ |
| @@ -23,5 +27,8 @@ d.addEventListener("click",function(e){var t=e.target,k=t&&t.closest&&t.closest( | ||
| 23 | 27 | function s(){if(x)return;x=1;var v=performance.getEntriesByType("navigation")[0]||{}; |
| 24 | 28 | n.sendBeacon("/_measure",JSON.stringify({path:a,ttfb:v.responseStart||0,load:v.loadEventEnd||0,lcp:l,inp:i,cls:c,ref:d.referrer.slice(0,300),q:location.search.slice(0,300),out:u}))} |
| 25 | 29 | d.addEventListener("visibilitychange",function(){d.visibilityState=="hidden"&&s()}); |
| 26 | w.addEventListener("pagehide",s) | |
| 30 | w.addEventListener("pagehide",s); | |
| 31 | var m=0;function r(f,l,g){if(m>4)return;try{var h=f&&new URL(f,location.href);if(h&&h.origin!=location.origin)return;m++;n.sendBeacon("/_script-error",JSON.stringify({page:a,src:h&&h.pathname!=a?h.pathname:"",line:l|0,msg:String(g).slice(0,120)}))}catch(_){}} | |
| 32 | w.addEventListener("error",function(e){r(e.filename,e.lineno,e.message)}); | |
| 33 | w.addEventListener("unhandledrejection",function(e){var z=e.reason;r("",0,z&&z.message||z)}) | |
| 27 | 34 | }(navigator,document,window)`; |
src/web/pages/security.ts +16
| @@ -15,6 +15,7 @@ import { backupOverdue, trafficAttention, TRAFFIC_WORDS } from "../../flow/kpi_w | ||
| 15 | 15 | import { readBackup } from "../../flow/kpis.ts"; |
| 16 | 16 | import type { Ctx } from "../../http/context.ts"; |
| 17 | 17 | import { violationsByDirective } from "../../http/csp_report.ts"; |
| 18 | import { scriptErrorCounts } from "../../http/script_errors.ts"; | |
| 18 | 19 | import { perfRecord } from "../../perf/live.ts"; |
| 19 | 20 | import { getSignup } from "../../settings/signup.ts"; |
| 20 | 21 | import { trafficHistory } from "../../perf/traffic_store.ts"; |
| @@ -250,6 +251,20 @@ function cspRow(db: Database, nowMs: number): Html | null { | ||
| 250 | 251 | ); |
| 251 | 252 | } |
| 252 | 253 | |
| 254 | /** Uncaught script errors browsers reported this week: page, where, message, count, last seen; nothing when none. */ | |
The terminal controls screen has no matching row; the terminal must show these to an admin as the page does (and live like other writes). Add it in src/tui/screens/controls.ts with a test. |
||
| 255 | function scriptErrorRow(db: Database, nowMs: number): Html | null { | |
| 256 | const rows = scriptErrorCounts(db, 7 * 24, new Date(nowMs)); | |
| 257 | if (rows.length === 0) return null; | |
| 258 | return statusRow( | |
| 259 | "Script errors on pages, 7 days", | |
| 260 | html`<ul class="plain">${rows.map( | |
| 261 | (r) => html`<li><span class="mono">${r.page}</span> <span class="mono">${r.where}</span> ${r.message} <span class="muted">${r.n}×, ${time(r.last)}</span></li>`, | |
| 262 | )}</ul>`, | |
| 263 | null, | |
| 264 | "script-errors", | |
| 265 | ); | |
| 266 | } | |
| 267 | ||
| 253 | 268 | export function securityPage(ctx: Ctx): Response { |
| 254 | 269 | if (!ctx.auth.user) return signInFirst(ctx); |
| 255 | 270 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); |
| @@ -297,6 +312,7 @@ export function securityPage(ctx: Ctx): Response { | ||
| 297 | 312 | )} |
| 298 | 313 | ${reportRow(dataDir, nowMs)} |
| 299 | 314 | ${cspRow(db, nowMs)} |
| 315 | ${scriptErrorRow(db, nowMs)} | |
| 300 | 316 | ${statusRow("Admin accounts", html`${admins.map((a, i) => html`${i > 0 ? ", " : ""}${a.username}${a.is_agent === 1 ? " (agent)" : ""}${a.suspended_at === null ? "" : " (suspended)"}`)}`)} |
| 301 | 317 | ${statusRow("Admin-scope tokens", none(adminTokens.length, adminTokens.length === 1 ? "token" : "tokens"))} |
| 302 | 318 | ${statusRow(`Keys and tokens added, ${WINDOW_DAYS} days`, none(added.length, "added"))} |
test/forge-looks.test.ts +1
| @@ -66,6 +66,7 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 66 | 66 | "/sign-in": "redirects to /signin", |
| 67 | 67 | "/mcp": "a protocol endpoint, answers 405 to a GET", |
| 68 | 68 | "/_measure": "the page-load beacon, a POST endpoint", |
| 69 | "/_script-error": "the beacon's uncaught-script-error door, a POST endpoint", | |
| 69 | 70 | "/apple-touch-icon.png": "an icon, not a page", |
| 70 | 71 | "/apple-touch-icon-precomposed.png": "an icon, not a page", |
| 71 | 72 | "/events": "a live event stream that never ends", |
test/mcp-csp-violations.test.ts +4
| @@ -7,6 +7,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 7 | 7 | import { createToken } from "../src/core/tokens.ts"; |
| 8 | 8 | import { createUser } from "../src/core/users.ts"; |
| 9 | 9 | import { recordViolation, violationsByDirective } from "../src/http/csp_report.ts"; |
| 10 | import { recordScriptError } from "../src/http/script_errors.ts"; | |
| 10 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | 12 | |
| 12 | 13 | let dir = ""; |
| @@ -68,7 +69,10 @@ test("a posted report is read back by directive, with its count and newest block | ||
| 68 | 69 | { directive: "script-src-elem", n: 3, newest: "b.example" }, |
| 69 | 70 | { directive: "img-src", n: 1, newest: "data" }, |
| 70 | 71 | ], |
| 72 | scriptErrors: [], | |
| 71 | 73 | }); |
| 74 | recordScriptError(app.db, { page: "/claim", src: "/a.js", line: 2, msg: "boom" }); | |
| 75 | expect((await csp(admin)).body.scriptErrors).toEqual([{ page: "/claim", where: "/a.js:2", message: "boom", n: 1, last: expect.any(String) }]); | |
| 72 | 76 | // The same function the security page reads. |
| 73 | 77 | expect(got.body.directives).toEqual(violationsByDirective(app.db, 24)); |
| 74 | 78 | }); |
test/script-errors.test.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | // Uncaught script errors the beacon reports (#11447): counted, bounded, same-origin. | |
| 2 | import { afterAll, beforeAll, 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 { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { scriptErrorCounts } from "../src/http/script_errors.ts"; | |
| 9 | import { beaconJs } from "../src/web/beacon.ts"; | |
| 10 | ||
| 11 | let dir = ""; | |
| 12 | let app: App; | |
| 13 | let servers: Servers; | |
| 14 | beforeAll(async () => { | |
| 15 | dir = await mkdtemp(join(tmpdir(), "script-errors-")); | |
| 16 | app = createApp({ dataDir: dir, port: 0 }); | |
| 17 | servers = await serve(app); | |
| 18 | }, 30_000); | |
| 19 | afterAll(async () => { | |
| 20 | await servers.stop(); | |
| 21 | app.close(); | |
| 22 | await rm(dir, { recursive: true, force: true }); | |
| 23 | }); | |
| 24 | ||
| 25 | let ip = 0; | |
| 26 | const send = (body: string, headers: Record<string, string> = {}) => | |
| 27 | fetch(`${servers.url}/_script-error`, { | |
| 28 | method: "POST", | |
| 29 | headers: { origin: servers.url, "x-forwarded-for": `10.9.0.${++ip}`, ...headers }, | |
| 30 | body, | |
| 31 | }); | |
| 32 | const rows = () => app.db.query<{ n: number }, []>("SELECT COALESCE(SUM(n),0) AS n FROM script_error").get()!.n; | |
| 33 | const good = { page: "/", src: "/a.js", line: 3, msg: "x is not defined" }; | |
| 34 | ||
| 35 | test("a well-formed report is counted once per hour key, with the floor headers", async () => { | |
| 36 | const a = await send(JSON.stringify(good)); | |
| 37 | expect(a.status).toBe(204); | |
| 38 | expect(a.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 39 | await send(JSON.stringify(good)); | |
| 40 | const c = scriptErrorCounts(app.db); | |
| 41 | expect(c).toHaveLength(1); | |
| 42 | expect(c[0]).toMatchObject({ page: "/", where: "/a.js:3", message: "x is not defined", n: 2 }); | |
| 43 | }); | |
| 44 | ||
| 45 | test("only the first line, cut to 120 characters, is kept", async () => { | |
| 46 | await send(JSON.stringify({ ...good, line: 4, msg: `${"y".repeat(300)}\nsecret second line` })); | |
| 47 | const c = scriptErrorCounts(app.db).find((r) => r.where === "/a.js:4")!; | |
| 48 | expect(c.message).toBe("y".repeat(120)); | |
| 49 | }); | |
| 50 | ||
| 51 | test("oversized, wrong-type, cross-origin and malformed reports store nothing", async () => { | |
| 52 | const before = rows(); | |
| 53 | expect((await send("x".repeat(3000))).status).toBe(413); | |
| 54 | expect((await send(JSON.stringify({ ...good, line: "3" }))).status).toBe(400); | |
| 55 | expect((await send(JSON.stringify({ ...good, src: "https://evil.example/a.js" }))).status).toBe(400); | |
| 56 | expect((await send(JSON.stringify({ ...good, src: "/a.js?q=1" }))).status).toBe(400); | |
| 57 | expect((await send(JSON.stringify({ ...good, extra: 1 }))).status).toBe(400); | |
Add a case: with doNotTrack "1" (and with the forge_measure=off cookie) no error listener is registered and nothing is sent; box 1 claims this. |
||
| 58 | expect((await send(JSON.stringify({ ...good, page: "/no/such/route/at/all" }))).status).toBe(400); | |
| 59 | expect((await send(JSON.stringify(good), { origin: "https://evil.example" })).status).toBe(403); | |
| 60 | expect((await send(JSON.stringify(good), { dnt: "1" })).status).toBe(204); | |
| 61 | expect(rows()).toBe(before); | |
| 62 | }); | |
| 63 | ||
| 64 | test("the beacon sends at most 5 per page view, same-origin scripts only, and stays under 2 KB", () => { | |
| 65 | expect(beaconJs.length).toBeLessThan(2048); | |
| 66 | const sent: string[] = []; | |
| 67 | const handlers: Record<string, (e: unknown) => void> = {}; | |
| 68 | const w = { addEventListener: (t: string, f: (e: unknown) => void) => (handlers[t] = f), PerformanceObserver: class { observe() {} } }; | |
| 69 | const d = { cookie: "", referrer: "", addEventListener() {} }; | |
| 70 | const n = { doNotTrack: "0", sendBeacon: (_u: string, b: string) => sent.push(b) }; | |
| 71 | const g = globalThis as any; | |
| 72 | const registered = (nav: object, doc: object) => { | |
| 73 | for (const k of Object.keys(handlers)) delete handlers[k]; | |
| 74 | new Function("navigator", "document", "window", beaconJs)(nav, doc, w); | |
| 75 | return Object.keys(handlers); | |
| 76 | }; | |
| 77 | const saved = { location: g.location, performance: g.performance }; | |
| 78 | g.location = { pathname: "/claim", origin: "https://f.test", href: "https://f.test/claim", search: "", hostname: "f.test" }; | |
| 79 | try { | |
| 80 | expect(registered({ ...n, doNotTrack: "1" }, d)).toEqual([]); | |
| 81 | expect(registered(n, { ...d, cookie: "a=1; forge_measure=off" })).toEqual([]); | |
| 82 | expect(sent).toHaveLength(0); | |
| 83 | expect(registered(n, d)).toContain("error"); | |
| 84 | for (let i = 0; i < 9; i++) handlers.error!({ filename: "https://f.test/a.js?v=1", lineno: i, message: "boom" }); | |
| 85 | expect(sent).toHaveLength(5); | |
| 86 | expect(JSON.parse(sent[0]!)).toEqual({ page: "/claim", src: "/a.js", line: 0, msg: "boom" }); | |
| 87 | const before = sent.length; | |
| 88 | handlers.error!({ filename: "https://other.test/a.js", lineno: 1, message: "Script error." }); | |
| 89 | expect(sent).toHaveLength(before); | |
| 90 | } finally { | |
| 91 | g.location = saved.location; | |
| 92 | } | |
| 93 | }); |
test/security-controls.test.ts +14 −1
| @@ -6,7 +6,8 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 6 | 6 | import { createSession } from "../src/core/sessions.ts"; |
| 7 | 7 | import { createUser } from "../src/core/users.ts"; |
| 8 | 8 | import { serve, type Servers } from "../src/http/server.ts"; |
| 9 | import { controlRows } from "../src/tui/screens/controls.ts"; | |
| 9 | import { recordScriptError } from "../src/http/script_errors.ts"; | |
| 10 | import { controlRows, scriptErrorRows, entry } from "../src/tui/screens/controls.ts"; | |
| 10 | 11 | import { CONTROLS, readControlChecks } from "../src/checks/controls.ts"; |
| 11 | 12 | |
| 12 | 13 | let dir = ""; |
| @@ -101,3 +102,15 @@ test("the terminal rows match the page", () => { | ||
| 101 | 102 | expect(rows.map((r) => r.title)).toEqual(CONTROLS.map((c) => c.name)); |
| 102 | 103 | expect(rows.find((r) => r.title === "Pod security")?.meta).toBe("No check recorded"); |
| 103 | 104 | }); |
| 105 | ||
| 106 | test("the terminal lists script errors, for an admin only", () => { | |
| 107 | expect(scriptErrorRows(app.db, Date.now())).toEqual([]); | |
| 108 | recordScriptError(app.db, { page: "/", src: "/a.js", line: 3, msg: "boom" }); | |
| 109 | const rows = scriptErrorRows(app.db, Date.now()); | |
| 110 | expect(rows).toHaveLength(1); | |
| 111 | expect(rows[0]).toMatchObject({ title: "Script error on /: boom", secondary: "/a.js:3", meta: "1 in 7 days" }); | |
| 112 | const as = (isAdmin: boolean, kind = "human") => entry.offered({ user: { isAdmin, kind } } as never); | |
| 113 | expect(as(true)).toBe(true); | |
| 114 | expect(as(false)).toBe(false); | |
| 115 | expect(as(true, "agent")).toBe(false); | |
| 116 | }); |
new above
offline
This comment runs into the referrer sentence on the same line; reflow it into its own sentence.