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

Merged opened this

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

Merged

scout/forge:scout/11447-a-page-s-uncaught-script-errors-are-coun → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/11447-a-page-s-uncaught-script-errors-are-coun was merged into main as be14185.

Review

Sign in to review this change.
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
55 `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.
66
77 `/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 @@
11 import { noteRead, telemetryReader } from "../../../actions/telemetry.ts";
22 import { systemWithReaders } from "../../../system/telemetry.ts";
3 import { scriptErrorCounts } from "../../script_errors.ts";
34 import { violationsByDirective, windowStart } from "../../csp_report.ts";
45 import { countRead } from "../../../system/telemetry_reads.ts";
56 import { stallHour, TOP_ROUTES } from "../../../metrics/stalls.ts";
@@ -9,7 +10,7 @@ import { good, bad, type Tool } from "../shared.ts";
910
1011 export const tool: Tool = {
1112 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",
1314 description:
1415 "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read; `kind: csp`: 24 h of CSP violations (admin).",
1516 inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp"] } } },
@@ -19,7 +20,7 @@ export const tool: Tool = {
1920 if (args["kind"] !== "csp") return bad("invalid", "kind is csp, or absent for the machine");
2021 if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads its CSP violations");
2122 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) });
2324 }
2425 const reader = telemetryReader(ctx.app, user);
2526 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";
3939 import { allFlowsPage } from "../web/pages/flows-all.ts";
4040 import { measureSubmit } from "./measure.ts";
4141 import { cspReportSubmit } from "./csp_report.ts";
42 import { scriptErrorSubmit } from "./script_errors.ts";
4243 import { stripeHook } from "./stripe_hook.ts";
4344 import { settingsRoutes } from "./settings_routes.ts";
4445 import { pluginRoutes } from "./plugin_routes.ts";
@@ -834,6 +835,8 @@ export function buildRouter(): Router<Handler> {
834835 router.add("GET", "/sw.js", serviceWorker);
835836 // The page-view beacon's report (http/measure.ts): numbers only, no IP kept.
836837 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));
837840 router.add("POST", "/_funnel", funnelSubmit);
838841 // What a page's Content-Security-Policy blocked (http/csp_report.ts): counts only.
839842 router.add("POST", "/csp-report", cspReportSubmit);
src/tui/screens/controls.ts +12 −2
@@ -3,12 +3,13 @@
33 * /settings/security/controls (`checks/controls.ts`), each safeguard and when
44 * it was last tested, then what the page policy blocked this week by
55 * 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.
77 */
88
99 import type { Database } from "bun:sqlite";
1010 import { controlStandings } from "../../checks/controls.ts";
1111 import { violationsByDirective } from "../../http/csp_report.ts";
12 import { scriptErrorCounts } from "../../http/script_errors.ts";
1213 import type { Ctx, Screen } from "../app/app.ts";
1314 import { binding } from "../app/keys.ts";
1415 import type { Session } from "../session.ts";
@@ -37,10 +38,19 @@ export function cspRows(db: Database, nowMs: number): ListRow[] {
3738 }));
3839 }
3940
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
4050 export function controlsScreen(f: Session, ctx: Ctx): Screen {
4151 const state = new ListState();
4252 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)];
4454 return {
4555 title: "Security controls",
4656 keys: [binding(["j", "k"], "move cursor")],
src/web/asset.ts +1 −1
@@ -207,7 +207,7 @@ export function spriteAsset(): FileAsset {
207207 * plus everything that is a file, an API or an edge hand-off.
208208 */
209209 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",
211211 "/*/*/raw/*", "/*/*/media/*", "/*/*/archive/*", "/*/*/app/*/open",
212212 "/logout*", "/signout*", "/sign-out*", "/signin*", "/sign-in*", "/login*",
213213 "/claim*", "/first-admin*", "/verify*", "/go/*", "/-/*", "/apps/open*",
src/web/beacon.ts +10 −3
@@ -8,8 +8,12 @@
88 * being measured belongs to the page that was loaded. Nothing
99 * is sent under Do-Not-Track or with the `forge_measure=off` cookie; the
1010 * 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 *
outdated

This comment runs into the referrer sentence on the same line; reflow it into its own sentence.

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_`
1317 * fields, and a clicked host that is in the named set (`perf/sources.ts`).
1418 */
1519 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(
2327 function s(){if(x)return;x=1;var v=performance.getEntriesByType("navigation")[0]||{};
2428 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}))}
2529 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)})
2734 }(navigator,document,window)`;
src/web/pages/security.ts +16
@@ -15,6 +15,7 @@ import { backupOverdue, trafficAttention, TRAFFIC_WORDS } from "../../flow/kpi_w
1515 import { readBackup } from "../../flow/kpis.ts";
1616 import type { Ctx } from "../../http/context.ts";
1717 import { violationsByDirective } from "../../http/csp_report.ts";
18 import { scriptErrorCounts } from "../../http/script_errors.ts";
1819 import { perfRecord } from "../../perf/live.ts";
1920 import { getSignup } from "../../settings/signup.ts";
2021 import { trafficHistory } from "../../perf/traffic_store.ts";
@@ -250,6 +251,20 @@ function cspRow(db: Database, nowMs: number): Html | null {
250251 );
251252 }
252253
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
253268 export function securityPage(ctx: Ctx): Response {
254269 if (!ctx.auth.user) return signInFirst(ctx);
255270 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
@@ -297,6 +312,7 @@ export function securityPage(ctx: Ctx): Response {
297312 )}
298313 ${reportRow(dataDir, nowMs)}
299314 ${cspRow(db, nowMs)}
315 ${scriptErrorRow(db, nowMs)}
300316 ${statusRow("Admin accounts", html`${admins.map((a, i) => html`${i > 0 ? ", " : ""}${a.username}${a.is_agent === 1 ? " (agent)" : ""}${a.suspended_at === null ? "" : " (suspended)"}`)}`)}
301317 ${statusRow("Admin-scope tokens", none(adminTokens.length, adminTokens.length === 1 ? "token" : "tokens"))}
302318 ${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> = {
6666 "/sign-in": "redirects to /signin",
6767 "/mcp": "a protocol endpoint, answers 405 to a GET",
6868 "/_measure": "the page-load beacon, a POST endpoint",
69 "/_script-error": "the beacon's uncaught-script-error door, a POST endpoint",
6970 "/apple-touch-icon.png": "an icon, not a page",
7071 "/apple-touch-icon-precomposed.png": "an icon, not a page",
7172 "/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";
77 import { createToken } from "../src/core/tokens.ts";
88 import { createUser } from "../src/core/users.ts";
99 import { recordViolation, violationsByDirective } from "../src/http/csp_report.ts";
10 import { recordScriptError } from "../src/http/script_errors.ts";
1011 import { serve, type Servers } from "../src/http/server.ts";
1112
1213 let dir = "";
@@ -68,7 +69,10 @@ test("a posted report is read back by directive, with its count and newest block
6869 { directive: "script-src-elem", n: 3, newest: "b.example" },
6970 { directive: "img-src", n: 1, newest: "data" },
7071 ],
72 scriptErrors: [],
7173 });
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) }]);
7276 // The same function the security page reads.
7377 expect(got.body.directives).toEqual(violationsByDirective(app.db, 24));
7478 });
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";
66 import { createSession } from "../src/core/sessions.ts";
77 import { createUser } from "../src/core/users.ts";
88 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";
1011 import { CONTROLS, readControlChecks } from "../src/checks/controls.ts";
1112
1213 let dir = "";
@@ -101,3 +102,15 @@ test("the terminal rows match the page", () => {
101102 expect(rows.map((r) => r.title)).toEqual(CONTROLS.map((c) => c.name));
102103 expect(rows.find((r) => r.title === "Pod security")?.meta).toBe("No check recorded");
103104 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs