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);