Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions public/_worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 22 additions & 5 deletions src/lib/monitoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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);
}
Expand Down
43 changes: 43 additions & 0 deletions tests/monitoring.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading