diff --git a/index.html b/index.html
index 90770cf8..d74ef1b9 100644
--- a/index.html
+++ b/index.html
@@ -37,6 +37,10 @@
production bundle, so the previous cdn.jsdelivr.net /
cdnjs.cloudflare.com allowances have been dropped to keep CSP tight.
+ Note: crash reporting (src/lib/monitoring.ts, isNoise) drops errors
+ thrown from inline code in a page, relying on there being none of
+ ours. Allowing an inline script here means revisiting that rule.
+
style-src:
'self' – Tailwind CSS bundle
'unsafe-inline' – Tailwind utility classes applied as inline styles by React
diff --git a/public/_headers b/public/_headers
index d3414dae..60438710 100644
--- a/public/_headers
+++ b/public/_headers
@@ -28,6 +28,8 @@
# detaches it (mirrored by headers.delete() in _worker.js for
# worker-served routes).
+# CSP: no inline scripts. Crash reporting (src/lib/monitoring.ts, isNoise)
+# drops errors thrown from inline code in a page as injected.
/*
! Access-Control-Allow-Origin
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
diff --git a/public/_worker.js b/public/_worker.js
index 95118e6c..ebda9fb1 100644
--- a/public/_worker.js
+++ b/public/_worker.js
@@ -36,6 +36,8 @@ const SECURITY_HEADERS = {
'storage-access=()', 'usb=()', 'web-share=(self)', 'window-management=()',
'xr-spatial-tracking=()',
].join(', '),
+ // No inline scripts: crash reporting (src/lib/monitoring.ts, isNoise)
+ // drops errors thrown from inline code in a page as injected.
'Content-Security-Policy': [
"default-src 'none'",
"script-src 'self' https://gc.zgo.at",
diff --git a/src/lib/monitoring.ts b/src/lib/monitoring.ts
index cfdff581..12ff01d6 100644
--- a/src/lib/monitoring.ts
+++ b/src/lib/monitoring.ts
@@ -51,6 +51,22 @@ export interface VitalsReport {
// ── Pure helpers (unit-tested) ──────────────────────────────────────────────
+// Only the live site reports: a production build served anywhere else (a
+// preview deploy, `vite preview`, a QA machine) would put its crashes and
+// timings on the real board and in the real p75s.
+const PRODUCTION_HOST = /^(?:www\.)?fundermatch\.org$/;
+
+/** A URL on the live site that is a page, not a script (a frame's :line:col may follow). */
+function isOurPage(url: string | undefined): boolean {
+ if (!url) return false;
+ try {
+ const { hostname, pathname } = new URL(url);
+ return PRODUCTION_HOST.test(hostname) && !/\.[cm]?js(?::\d+)*$/i.test(pathname);
+ } catch {
+ return false;
+ }
+}
+
/**
* Errors that aren't ours to fix: browser extensions, the opaque
* cross-origin "Script error.", ResizeObserver's benign loop warning, the
@@ -76,6 +92,12 @@ export function isNoise(error: unknown, message: string, stack: string, chunkGav
// addEventListener appears below our own frames in real app crashes.
const topFrame = stack.split('\n').find((line) => FRAME.test(line) && /\w+:\/\//.test(line)) ?? '';
if (/(?:chrome|moz|safari(?:-web)?)-extension:\/\//i.test(topFrame)) return true;
+ // Injected into the page itself (a browser's or extension's user script,
+ // e.g. "global code@https://fundermatch.org/results:1:16"): its top frame
+ // is one of our pages, not a script. Our CSP allows no inline scripts
+ // (index.html, public/_headers, public/_worker.js). Unless the error
+ // screen showed: what the visitor saw is worth a card.
+ if (!screenShown && isOurPage(/https?:\/\/[^\s()]+/i.exec(topFrame)?.[0])) return true;
// Lost connections and cancelled requests, in each browser's wording: the
// network, not our code. Unless the error screen showed: a page that breaks
// when a request fails is a bug worth a card.
@@ -142,11 +164,6 @@ export function buildCrashReport(
// ── Sending ─────────────────────────────────────────────────────────────────
-// Only the live site reports: a production build served anywhere else (a
-// preview deploy, `vite preview`, a QA machine) would put its crashes and
-// timings on the real board and in the real p75s.
-const PRODUCTION_HOST = /^(?:www\.)?fundermatch\.org$/;
-
function enabled(): boolean {
return !import.meta.env.DEV && typeof window !== 'undefined' && PRODUCTION_HOST.test(window.location.hostname);
}
diff --git a/tests/monitoring.test.mjs b/tests/monitoring.test.mjs
index a76d698f..07a00a04 100644
--- a/tests/monitoring.test.mjs
+++ b/tests/monitoring.test.mjs
@@ -112,6 +112,49 @@ test('noise: extensions, opaque cross-origin errors, ResizeObserver', () => {
assert.equal(isNoise(new TypeError('x is undefined'), 'x is undefined', ' at f (https://fundermatch.org/assets/a.js:1:1)'), false);
});
+const shownLike = (msg) => {
+ const e = new TypeError(msg);
+ e.stack = 'global code@https://fundermatch.org/results:1:16';
+ return e;
+};
+
+test('noise: code injected inline into our page by the browser or an extension', () => {
+ // The bug board's /results crash (Brave on macOS): the only frame is
+ // inline code in the page itself. Our CSP allows no inline scripts, so
+ // nothing of ours runs there.
+ const msg = "undefined is not an object (evaluating 'window.webkit.messageHandlers')";
+ assert.equal(isNoise(new TypeError(msg), msg, 'global code@https://fundermatch.org/results:1:16'), true);
+ assert.equal(buildCrashReport('error', new TypeError(msg), '/results', 'b', '', false, { name: 'TypeError', message: msg, stack: 'global code@https://fundermatch.org/results:1:16' }), null);
+ // Chrome's frame wording, the home page, www, and a page with a trailing slash.
+ assert.equal(isNoise(new TypeError(msg), msg, `TypeError: ${msg}\n at https://fundermatch.org/results:1:16`), true);
+ assert.equal(isNoise(new TypeError(msg), msg, 'global code@https://fundermatch.org/:3:7'), true);
+ assert.equal(isNoise(new TypeError(msg), msg, ' at f (https://www.fundermatch.org/search/:1:1)'), true);
+ // An error event with no error object, as installMonitoring builds it.
+ assert.equal(buildCrashReport('error', { name: 'TypeError', message: msg, stack: ' at https://fundermatch.org/results:1:16' }, '/results', 'b'), null);
+ // Our bundle's frames are ours, even with an inline frame further down.
+ assert.equal(isNoise(new TypeError(msg), msg, 'f@https://fundermatch.org/assets/index-jLhmJpd-.js:1:2'), false);
+ assert.equal(isNoise(new TypeError(msg), msg, 'f@https://fundermatch.org/assets/a.mjs:1:2\nglobal code@https://fundermatch.org/results:1:16'), false);
+ // An explicit default port is still our bundle, or still our page.
+ assert.equal(isNoise(new TypeError(msg), msg, 'f@https://fundermatch.org:443/assets/index-x.js:1:2'), false);
+ assert.equal(isNoise(new TypeError(msg), msg, 'global code@https://fundermatch.org:443/results:1:16'), true);
+ // Another site's extensionless script URL isn't our page.
+ assert.equal(isNoise(new TypeError(msg), msg, ' at f (https://maps.googleapis.com/maps/api/js?key=k:1:1)'), false);
+ // Nor is a look-alike host.
+ assert.equal(isNoise(new TypeError(msg), msg, 'global code@https://fundermatch.org.evil.test/results:1:1'), false);
+ // A same-origin script file outside /assets/ (a CDN-injected one) isn't a page.
+ assert.equal(isNoise(new TypeError(msg), msg, 'f@https://fundermatch.org/cdn-cgi/scripts/main.js:3:1'), false);
+ // An unhandled rejection from injected code is noise too.
+ assert.equal(buildCrashReport('rejection', shownLike(msg), '/results', 'b'), null);
+ // A crash that brought up the error screen is still worth a card.
+ assert.equal(isNoise(new TypeError(msg), msg, 'global code@https://fundermatch.org/results:1:16', false, true), false);
+ assert.equal(buildCrashReport('boundary', shownLike(msg), '/results', 'b').name, 'TypeError');
+ // Safari's failed entry/chunk import, pinned on the document: still a
+ // chunk-load error, reported once the automatic reload didn't help.
+ const load = { name: 'TypeError', message: 'Importing a module script failed.', stack: ' at https://fundermatch.org/results:0:0' };
+ assert.equal(buildCrashReport('error', load, '/results', 'b'), null);
+ assert.equal(buildCrashReport('error', load, '/results', 'b', '', true).name, 'TypeError');
+});
+
test('noise: the Outlook Safe Links crawler\'s string rejections', () => {
for (const msg of ['Object Not Found Matching Id:3, MethodName:update, ParamCount:4', 'Object Not Found Matching Id:12, MethodName:simulateEvent, ParamCount:1']) {
assert.equal(isNoise(msg, msg, ''), true, msg);