From f7fa6f9c144681f527e4496057705518920e6c8c Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 27 Jul 2026 14:45:28 +0100 Subject: [PATCH 01/25] fix(csp): close the wss allow-list gap and stop the report flood MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report-only CSP has been posting violations straight to Sentry's security endpoint, which put ~70k events across ~1.8k users into peanut-ui in a week and buried real signal. Those reports never pass through the Sentry browser SDK, so no beforeSend/shouldIgnoreError filter could ever have touched them — the only place to filter is an endpoint we own, so reports now go to a same-origin collector that forwards to Sentry minus the noise. The noise is overwhelmingly repetition: Sentry groups CSP issues by directive + blocked origin, so one missing allow-list entry generated 14k identical events. The collector forwards the first sighting of each group unconditionally — a genuinely new violation still creates its issue and fires its alert — and samples the repeats. Separately, connect-src was missing wss://api.peanut.me. CSP treats wss: as a scheme distinct from https:, so neither https://api.peanut.me nor https://*.peanut.me authorized the charges websocket every logged-in session opens. That single gap is 84% of the violations still firing, and it would have broken the socket for every user the moment the policy was promoted to enforcing. The Sumsub WebSDK's liveness socket had the identical scheme trap, and the Bridge terms-of-service iframe was missing from frame-src, which would have dead-ended KYC. --- next.config.js | 69 ++++---- src/app/api/csp-report/route.ts | 90 +++++++++++ src/utils/__tests__/csp-report.utils.test.ts | 145 +++++++++++++++++ src/utils/csp-report.utils.ts | 156 +++++++++++++++++++ 4 files changed, 431 insertions(+), 29 deletions(-) create mode 100644 src/app/api/csp-report/route.ts create mode 100644 src/utils/__tests__/csp-report.utils.test.ts create mode 100644 src/utils/csp-report.utils.ts diff --git a/next.config.js b/next.config.js index 087e8d0a30..70a56edb0d 100644 --- a/next.config.js +++ b/next.config.js @@ -6,29 +6,15 @@ const withBundleAnalyzer = const redirectsConfig = require('./redirects.json') /** - * Sentry's CSP-report ingest endpoint, derived from the browser DSN - * (`://@/`). Returns null when the - * DSN is absent or malformed, in which case the policy still ships — it just - * has nowhere to report, which is better than emitting a broken `report-uri`. + * Same-origin collector for violation reports (src/app/api/csp-report). It + * derives Sentry's ingest URL from the DSN and forwards there, dropping the + * noise Sentry would otherwise group into issues nobody can act on. * - * Protocol and any path prefix are preserved: self-hosted Sentry is commonly - * mounted under a sub-path, and flattening one would silently post reports to - * an endpoint that doesn't exist. + * Reporting straight to Sentry — which is what this used to do — is + * unfilterable: the browser POSTs violations itself, so they never pass through + * the Sentry SDK and `beforeSend` never sees them. */ -function sentryCspReportUri() { - const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN - if (!dsn) return null - try { - const { protocol, host, username, pathname } = new URL(dsn) - const segments = pathname.split('/').filter(Boolean) - const projectId = segments.pop() - if (!host || !username || !projectId) return null - const prefix = segments.length ? `/${segments.join('/')}` : '' - return `${protocol}//${host}${prefix}/api/${projectId}/security/?sentry_key=${username}` - } catch { - return null - } -} +const CSP_REPORT_PATH = '/api/csp-report' /** * Chain RPC endpoints, which have two independent sources that must both be @@ -71,7 +57,6 @@ const chainRpcHosts = [ * provider serves one subdomain per network. */ function contentSecurityPolicyReportOnly() { - const reportUri = sentryCspReportUri() const directives = [ "default-src 'self'", // PostHog is same-origin via the /relay rewrite, so it needs no entry here. @@ -83,20 +68,37 @@ function contentSecurityPolicyReportOnly() { "connect-src 'self'", 'https://api.peanut.me', 'https://*.peanut.me', + // CSP treats wss: as a scheme distinct from https:, so the two + // entries above do NOT cover the charges websocket + // (NEXT_PUBLIC_PEANUT_WS_URL — wss://api.peanut.me in production, + // wss://api.staging.peanut.me on staging). This gap is what makes + // /card the single loudest violation in Sentry today, and it would + // break the socket for every user the moment the policy is enforced. + 'wss://api.peanut.me', + 'wss://*.peanut.me', 'https://*.ingest.sentry.io', 'https://*.ingest.us.sentry.io', - 'https://www.google-analytics.com', + // Wildcarded because GA4 shards collection by region: EU traffic + // beacons to region1.google-analytics.com, not just www. The same + // sharding is already visible on analytics.google.com below. + 'https://*.google-analytics.com', // GA4 beacons to a region-specific host, and GTM's audience/conversion - // pings go to doubleclick and www.google.com. + // pings go to doubleclick and www.google.com. The GTM container + // itself is fetched as well as executed, so it needs a connect-src + // entry on top of the script-src one. 'https://analytics.google.com', 'https://*.analytics.google.com', 'https://stats.g.doubleclick.net', 'https://www.google.com', + 'https://www.googletagmanager.com', 'https://rpc.zerodev.app', 'https://*.g.alchemy.com', 'https://rpc.ankr.com', 'https://assets.coingecko.com', 'https://coin-images.coingecko.com', + // Token metadata lookup in TransactionDetailsReceipt — a different + // CoinGecko host from the two image CDNs above. + 'https://api.coingecko.com', 'https://api.frankfurter.app', 'https://dolarapi.com', 'https://ipapi.co', @@ -104,11 +106,22 @@ function contentSecurityPolicyReportOnly() { 'https://*.crisp.chat', 'wss://client.relay.crisp.chat', 'https://*.sumsub.com', + // Same scheme trap as api.peanut.me above: the Sumsub WebSDK opens a + // websocket for liveness/video-ident signalling, which the https + // entry does not authorize. Silent in the reports only because + // liveness is a rare path — it would still break under enforcement. + 'wss://*.sumsub.com', 'https://widget.manteca.dev', 'https://*.onesignal.com', ...chainRpcHosts, ].join(' '), - "frame-src 'self' https://client.crisp.chat https://*.sumsub.com https://widget.manteca.dev https://mpago.la", + // Bridge is the terms-of-service iframe BridgeTosStep renders through + // IframeWrapper — enforcing without it would dead-end KYC. Wildcarded + // like *.sumsub.com because the URL is chosen by the provider at + // runtime (compliance.bridge.xyz today) rather than by us. Crisp is + // wildcarded to match connect-src: the widget pulls attachments from + // assets.crisp.chat, not just client. + "frame-src 'self' https://*.crisp.chat https://*.sumsub.com https://widget.manteca.dev https://mpago.la https://*.bridge.xyz", "worker-src 'self' blob:", "object-src 'none'", "base-uri 'self'", @@ -119,16 +132,14 @@ function contentSecurityPolicyReportOnly() { // Reporting-Endpoints header below) is what replaces it in Chromium. // Shipping only one would undercount violations and promote the policy on // a partial picture. - if (reportUri) directives.push(`report-uri ${reportUri}`, `report-to ${CSP_REPORT_GROUP}`) + directives.push(`report-uri ${CSP_REPORT_PATH}`, `report-to ${CSP_REPORT_GROUP}`) return directives.join('; ') } const CSP_REPORT_GROUP = 'csp-endpoint' function reportingEndpointsHeader() { - const reportUri = sentryCspReportUri() - if (!reportUri) return [] - return [{ key: 'Reporting-Endpoints', value: `${CSP_REPORT_GROUP}="${reportUri}"` }] + return [{ key: 'Reporting-Endpoints', value: `${CSP_REPORT_GROUP}="${CSP_REPORT_PATH}"` }] } // Get git commit hash at build time diff --git a/src/app/api/csp-report/route.ts b/src/app/api/csp-report/route.ts new file mode 100644 index 0000000000..e62ee69c24 --- /dev/null +++ b/src/app/api/csp-report/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server' + +import { + cspReportGroupKey, + normalizeCspReports, + sentryCspIngestUrl, + shouldIgnoreCspReport, +} from '@/utils/csp-report.utils' + +export const dynamic = 'force-dynamic' + +/** + * Collector for the report-only CSP's violation reports. + * + * The policy used to point `report-uri` / `report-to` straight at Sentry's + * security endpoint, which put ~65k events across ~1.3k users into the + * peanut-ui project in four days and buried real signal. Those reports bypass + * the Sentry browser SDK entirely, so no `beforeSend` filter could touch them — + * the only place to filter is an endpoint we own. + * + * This forwards to Sentry exactly as before, minus two classes of pure noise: + * repeats of a violation Sentry has already grouped (the dominant class — one + * missing allow-list entry produced 14k events on its own) and reports injected + * by browser extensions. + */ + +/** Both wire formats, plus plain JSON from anything replaying a report. */ +const ACCEPTED_CONTENT_TYPES = ['application/csp-report', 'application/reports+json', 'application/json'] + +/** + * Sentry groups CSP issues by directive + blocked origin, so every duplicate + * past the first adds quota, not information. Forward the first sighting of + * each group unconditionally — a genuinely new violation always creates its + * issue and fires its alert — then sample the repeats hard. + * + * The Set is per-serverless-instance and resets on cold start, which just means + * an occasional extra first-sighting. Same trade-off the health route's + * in-memory Discord cooldown already accepts. + */ +const DUPLICATE_SAMPLE_RATE = 0.01 +const SEEN_GROUPS_MAX = 500 +const seenGroups = new Set() + +const FORWARD_TIMEOUT_MS = 3000 + +function shouldForward(groupKey: string): boolean { + if (!seenGroups.has(groupKey)) { + // Bound the memory: a flood of distinct groups must not grow forever. + if (seenGroups.size >= SEEN_GROUPS_MAX) seenGroups.clear() + seenGroups.add(groupKey) + return true + } + return Math.random() < DUPLICATE_SAMPLE_RATE +} + +export async function POST(request: NextRequest) { + // Always 204, whatever happens. A non-2xx here makes browsers retry and + // log console errors, which would be a second, self-inflicted noise source. + const noContent = new NextResponse(null, { status: 204 }) + + try { + const contentType = request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() ?? '' + if (!ACCEPTED_CONTENT_TYPES.includes(contentType)) return noContent + + const ingestUrl = sentryCspIngestUrl(process.env.NEXT_PUBLIC_SENTRY_DSN) + if (!ingestUrl) return noContent + + const reports = normalizeCspReports(await request.json()) + + const forwardable = reports + .filter((report) => !shouldIgnoreCspReport(report)) + .filter((report) => shouldForward(cspReportGroupKey(report))) + + await Promise.allSettled( + forwardable.map((report) => + fetch(ingestUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/csp-report' }, + body: JSON.stringify({ 'csp-report': report }), + signal: AbortSignal.timeout(FORWARD_TIMEOUT_MS), + }) + ) + ) + } catch { + // Malformed body, unreachable Sentry, timeout — a dropped violation + // report is never worth surfacing an error to the browser. + } + + return noContent +} diff --git a/src/utils/__tests__/csp-report.utils.test.ts b/src/utils/__tests__/csp-report.utils.test.ts new file mode 100644 index 0000000000..233be83bae --- /dev/null +++ b/src/utils/__tests__/csp-report.utils.test.ts @@ -0,0 +1,145 @@ +import { + cspReportGroupKey, + normalizeCspReports, + sentryCspIngestUrl, + shouldIgnoreCspReport, + type CspReport, +} from '../csp-report.utils' + +describe('shouldIgnoreCspReport', () => { + it.each([ + 'chrome-extension://abcdefghijklmnop/inject.js', + 'moz-extension://1234-5678/content.js', + 'safari-web-extension://ABCD/script.js', + 'resource://gre/modules/Foo.jsm', + 'about:blank', + ])('ignores extension/browser-internal blocked-uri %s', (blockedUri) => { + expect(shouldIgnoreCspReport({ 'blocked-uri': blockedUri })).toBe(true) + }) + + it('ignores a report whose source-file is an extension, even when the blocked-uri looks real', () => { + expect( + shouldIgnoreCspReport({ + 'blocked-uri': 'https://evil-tracker.example/beacon', + 'source-file': 'chrome-extension://abcdefghijklmnop/inject.js', + }) + ).toBe(true) + }) + + it('keeps the wss gap that this hotfix closes', () => { + expect( + shouldIgnoreCspReport({ + 'blocked-uri': 'wss://api.peanut.me', + 'effective-directive': 'connect-src', + }) + ).toBe(false) + }) + + it.each(['inline', 'eval', 'data', 'blob'])( + 'keeps keyword blocked-uri %s — genuine signal about how loose script-src still is', + (blockedUri) => { + expect(shouldIgnoreCspReport({ 'blocked-uri': blockedUri })).toBe(false) + } + ) + + it('keeps a real third-party host we simply forgot to allow-list', () => { + expect(shouldIgnoreCspReport({ 'blocked-uri': 'https://arb1.arbitrum.io/rpc' })).toBe(false) + }) + + it('does not treat a missing blocked-uri as noise', () => { + expect(shouldIgnoreCspReport({})).toBe(false) + }) +}) + +describe('normalizeCspReports', () => { + it('reads the legacy report-uri payload', () => { + expect( + normalizeCspReports({ + 'csp-report': { 'blocked-uri': 'wss://api.peanut.me', 'effective-directive': 'connect-src' }, + }) + ).toEqual([{ 'blocked-uri': 'wss://api.peanut.me', 'effective-directive': 'connect-src' }]) + }) + + it('reads a Reporting-API batch and maps it onto the legacy shape', () => { + const [report] = normalizeCspReports([ + { + type: 'csp-violation', + body: { + blockedURL: 'wss://api.peanut.me', + documentURL: 'https://peanut.me/card', + effectiveDirective: 'connect-src', + disposition: 'report', + }, + }, + ]) + + expect(report['blocked-uri']).toBe('wss://api.peanut.me') + expect(report['document-uri']).toBe('https://peanut.me/card') + expect(report['effective-directive']).toBe('connect-src') + expect(report['violated-directive']).toBe('connect-src') + }) + + it('drops non-CSP entries from a Reporting-API batch', () => { + expect( + normalizeCspReports([ + { type: 'deprecation', body: { id: 'SomeApi' } }, + { type: 'csp-violation', body: { blockedURL: 'inline', effectiveDirective: 'script-src' } }, + ]) + ).toHaveLength(1) + }) + + it.each([null, undefined, 'not json', 42, {}, { 'csp-report': 'nonsense' }])( + 'returns nothing for the malformed payload %p rather than throwing', + (payload) => { + expect(normalizeCspReports(payload)).toEqual([]) + } + ) +}) + +describe('cspReportGroupKey', () => { + it('collapses different paths on one origin into a single group', () => { + const a: CspReport = { 'effective-directive': 'connect-src', 'blocked-uri': 'https://arb1.arbitrum.io/rpc' } + const b: CspReport = { 'effective-directive': 'connect-src', 'blocked-uri': 'https://arb1.arbitrum.io/other' } + + expect(cspReportGroupKey(a)).toBe(cspReportGroupKey(b)) + }) + + it('separates different directives on the same origin', () => { + expect( + cspReportGroupKey({ 'effective-directive': 'connect-src', 'blocked-uri': 'https://a.example' }) + ).not.toBe(cspReportGroupKey({ 'effective-directive': 'script-src', 'blocked-uri': 'https://a.example' })) + }) + + it('handles keyword blocked-uris that are not URLs', () => { + expect(cspReportGroupKey({ 'effective-directive': 'script-src', 'blocked-uri': 'inline' })).toBe( + 'script-src|inline' + ) + }) + + it('falls back to violated-directive when the effective one is absent', () => { + expect(cspReportGroupKey({ 'violated-directive': 'connect-src', 'blocked-uri': 'inline' })).toBe( + 'connect-src|inline' + ) + }) +}) + +describe('sentryCspIngestUrl', () => { + it('derives the security endpoint from a browser DSN', () => { + expect(sentryCspIngestUrl('https://abc123@o1.ingest.us.sentry.io/4505827431415808')).toBe( + 'https://o1.ingest.us.sentry.io/api/4505827431415808/security/?sentry_key=abc123' + ) + }) + + it('preserves a sub-path, as self-hosted Sentry needs', () => { + expect(sentryCspIngestUrl('https://abc123@sentry.internal/sentry/42')).toBe( + 'https://sentry.internal/sentry/api/42/security/?sentry_key=abc123' + ) + }) + + it.each([undefined, '', 'not-a-dsn', 'https://o1.ingest.sentry.io/123'])( + 'returns null for the unusable DSN %p', + (dsn) => { + expect(sentryCspIngestUrl(dsn)).toBeNull() + } + ) +}) diff --git a/src/utils/csp-report.utils.ts b/src/utils/csp-report.utils.ts new file mode 100644 index 0000000000..dffad2cbb8 --- /dev/null +++ b/src/utils/csp-report.utils.ts @@ -0,0 +1,156 @@ +/** + * CSP violation report handling. + * + * Violation reports are POSTed by the browser straight to the policy's + * `report-uri` / `report-to` endpoint. They never travel through the Sentry + * browser SDK, so `beforeSend` / `shouldIgnoreError` in sentry.utils.ts cannot + * see or drop them — which is why the noise filter lives here, behind the + * /api/csp-report collector, instead of alongside the other Sentry filters. + */ + +/** + * Legacy `report-uri` payload (`application/csp-report`). Still what Firefox + * and Safari send today. Kept as the canonical internal shape because it is + * also the shape Sentry's security endpoint ingests. + */ +export interface CspReport { + 'blocked-uri'?: string + 'document-uri'?: string + 'effective-directive'?: string + 'violated-directive'?: string + 'source-file'?: string + [key: string]: unknown +} + +/** One entry of a Reporting-API (`report-to`) `application/reports+json` batch. */ +interface ReportingApiEntry { + type?: string + body?: Record +} + +/** + * Schemes that only ever appear because a browser extension (or the browser + * itself) injected a script, style or request into our page. They are + * inherently unfixable: an extension's origin is per-install, so no allow-list + * entry can ever cover them, and nothing we ship causes them. + * + * Sentry's own security endpoint already drops most of this class server-side + * (no `chrome-extension://` report has reached the project), so this is + * defense-in-depth rather than the main lever — it keeps the guarantee ours + * instead of depending on a Sentry project setting nobody remembers exists. + * The bulk of the noise is repeated reports of one violation, which + * cspReportGroupKey + the collector's de-duplication handle generically — + * including injected hosts like Google Translate's fonts.gstatic.com that + * arrive over plain https and no scheme check could catch. + */ +const EXTENSION_SCHEMES = [ + 'chrome-extension:', + 'moz-extension:', + 'safari-extension:', + 'safari-web-extension:', + 'ms-browser-extension:', + 'webkit-masked-url:', + 'resource:', // Firefox internal pages/scripts + 'chrome:', // Chromium internal pages/scripts + 'about:', // about:blank / about:srcdoc injections +] + +function isUnfixableOrigin(value: unknown): boolean { + if (typeof value !== 'string') return false + const lower = value.toLowerCase() + return EXTENSION_SCHEMES.some((scheme) => lower.startsWith(scheme)) +} + +/** + * True when a report is noise we can never act on. Deliberately narrow: the + * keyword blocked-uris (`inline`, `eval`, `data`, `blob`) are NOT filtered — + * those are genuine signal about how loose script-src still is, and they are + * exactly what has to be driven to zero before the policy can be enforced. + */ +export function shouldIgnoreCspReport(report: CspReport): boolean { + return isUnfixableOrigin(report['blocked-uri']) || isUnfixableOrigin(report['source-file']) +} + +/** Reporting-API body (camelCase) → the legacy hyphenated shape. */ +function fromReportingApi(body: Record): CspReport { + return { + 'blocked-uri': body.blockedURL as string | undefined, + 'document-uri': body.documentURL as string | undefined, + 'effective-directive': body.effectiveDirective as string | undefined, + // Sentry keys its CSP grouping off violated-directive; the Reporting + // API dropped the field, so mirror the effective one. + 'violated-directive': body.effectiveDirective as string | undefined, + 'original-policy': body.originalPolicy, + 'source-file': body.sourceFile as string | undefined, + 'line-number': body.lineNumber, + 'column-number': body.columnNumber, + 'script-sample': body.sample, + 'status-code': body.statusCode, + disposition: body.disposition, + referrer: body.referrer, + } +} + +/** + * Accept either wire format and return the reports in the legacy shape. + * Unrecognised payloads yield an empty list rather than throwing — a malformed + * report is never worth a 5xx back to the browser. + */ +export function normalizeCspReports(payload: unknown): CspReport[] { + if (!payload || typeof payload !== 'object') return [] + + // `report-to` — a batch of reports, only some of which are CSP violations. + if (Array.isArray(payload)) { + return (payload as ReportingApiEntry[]) + .filter((entry) => entry?.type === 'csp-violation' && entry.body && typeof entry.body === 'object') + .map((entry) => fromReportingApi(entry.body as Record)) + } + + // `report-uri` — a single report under the `csp-report` key. + const legacy = (payload as Record)['csp-report'] + if (legacy && typeof legacy === 'object' && !Array.isArray(legacy)) return [legacy as CspReport] + + return [] +} + +/** + * Grouping key used to decide whether a report is the first of its kind. + * Mirrors how Sentry groups CSP issues (directive + blocked origin), so "first + * of its kind here" means "would create a new Sentry issue". + */ +export function cspReportGroupKey(report: CspReport): string { + const directive = report['effective-directive'] || report['violated-directive'] || 'unknown' + const blocked = report['blocked-uri'] || 'unknown' + // Only the origin matters for grouping; paths and query strings vary per + // request and would otherwise make every single report look distinct. + let origin = blocked + try { + origin = new URL(blocked).origin + } catch { + // Keyword blocked-uris (`inline`, `eval`, …) are not URLs — use as-is. + } + return `${directive}|${origin}` +} + +/** + * Sentry's CSP ingest endpoint, derived from the browser DSN + * (`://@/`). Returns null when the + * DSN is absent or malformed, in which case reports are simply dropped. + * + * Protocol and any path prefix are preserved: self-hosted Sentry is commonly + * mounted under a sub-path, and flattening one would silently post reports to + * an endpoint that doesn't exist. + */ +export function sentryCspIngestUrl(dsn: string | undefined): string | null { + if (!dsn) return null + try { + const { protocol, host, username, pathname } = new URL(dsn) + const segments = pathname.split('/').filter(Boolean) + const projectId = segments.pop() + if (!host || !username || !projectId) return null + const prefix = segments.length ? `/${segments.join('/')}` : '' + return `${protocol}//${host}${prefix}/api/${projectId}/security/?sentry_key=${username}` + } catch { + return null + } +} From 2a5fda9999a03d9e7e047cf8ce909a80218412d2 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 27 Jul 2026 14:51:03 +0100 Subject: [PATCH 02/25] fix(csp): make the Reporting-Endpoints URL absolute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium prefers report-to and ignores report-uri whenever both are present, so an endpoint value it declines to parse would cost us every Chromium report — silently, with no error anywhere to notice it by. The spec says an endpoint URL is resolved against the document, but MDN and Chrome's own docs both describe the value as absolute. An absolute URL is valid under either reading, so stop betting on the ambiguity. report-uri keeps the relative path, which is unambiguously a URI-reference and stays correct on preview origins too. --- next.config.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/next.config.js b/next.config.js index 70a56edb0d..8fb30a7e08 100644 --- a/next.config.js +++ b/next.config.js @@ -16,6 +16,24 @@ const redirectsConfig = require('./redirects.json') */ const CSP_REPORT_PATH = '/api/csp-report' +/** + * `report-uri` takes a URI-reference, so the relative path above is valid and + * stays correct on every origin (prod, staging, preview URLs alike). + * + * `Reporting-Endpoints` is not so clearly specified: the Reporting API says an + * endpoint is a URL resolved against the document, but MDN and Chrome's own + * docs both describe the value as an absolute one. Getting this wrong fails + * silently AND expensively — Chromium prefers `report-to` and ignores + * `report-uri` whenever both are present, so an endpoint it declines to parse + * would cost us every Chromium report without a single error anywhere. Emit an + * absolute URL, which is valid under either reading. Mirrors BASE_URL in + * src/constants/general.consts.ts. + */ +function cspReportEndpoint() { + const base = (process.env.NEXT_PUBLIC_BASE_URL || 'https://peanut.me').replace(/\/$/, '') + return `${base}${CSP_REPORT_PATH}` +} + /** * Chain RPC endpoints, which have two independent sources that must both be * allowed: `rpcUrls` in src/constants/general.consts.ts (our own viem clients) @@ -139,7 +157,7 @@ function contentSecurityPolicyReportOnly() { const CSP_REPORT_GROUP = 'csp-endpoint' function reportingEndpointsHeader() { - return [{ key: 'Reporting-Endpoints', value: `${CSP_REPORT_GROUP}="${CSP_REPORT_PATH}"` }] + return [{ key: 'Reporting-Endpoints', value: `${CSP_REPORT_GROUP}="${cspReportEndpoint()}"` }] } // Get git commit hash at build time From cfbce8ee41f2bcd8b468e382639f70940c9ae12a Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 27 Jul 2026 14:58:41 +0100 Subject: [PATCH 03/25] fix(csp): annotate the collector's return type Flagged by the code-analysis bot: the exported route handler had no return type annotation. --- src/app/api/csp-report/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/csp-report/route.ts b/src/app/api/csp-report/route.ts index e62ee69c24..b845b4f4a7 100644 --- a/src/app/api/csp-report/route.ts +++ b/src/app/api/csp-report/route.ts @@ -53,7 +53,7 @@ function shouldForward(groupKey: string): boolean { return Math.random() < DUPLICATE_SAMPLE_RATE } -export async function POST(request: NextRequest) { +export async function POST(request: NextRequest): Promise { // Always 204, whatever happens. A non-2xx here makes browsers retry and // log console errors, which would be a second, self-inflicted noise source. const noContent = new NextResponse(null, { status: 204 }) From bd13a2daf3e362fc06bd61ae099f87a3c1d01a66 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 27 Jul 2026 15:07:10 +0100 Subject: [PATCH 04/25] fix(csp): don't let de-duplication swallow a new Sentry issue, and cap fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found reviewing the collector against Sentry's actual ingest behaviour. The group key was coarser than Sentry's csp:v1 grouping strategy, which drops the blocked URI and keys on the keyword when script-src is violated by 'unsafe-inline' or 'unsafe-eval'. Sentry raises two issues there; the key collapsed them into one, so whichever arrived second looked like a duplicate and was sampled away — its issue possibly never created. Those two keywords are the top of this policy's "tighten before enforcing" list, so that was exactly the signal the filter must not eat. The forward loop was also uncapped. The endpoint is unauthenticated and a Reporting-API payload is an array, so one POST could fan out arbitrarily many events — and Sentry bills CSP reports against the *error* quota under a per-key rate limit, making an uncapped fan-out a way to starve real exception reporting rather than merely noisy. --- src/app/api/csp-report/route.ts | 21 +++++++-- src/utils/__tests__/csp-report.utils.test.ts | 47 ++++++++++++++++++++ src/utils/csp-report.utils.ts | 19 +++++++- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/app/api/csp-report/route.ts b/src/app/api/csp-report/route.ts index b845b4f4a7..69aff685a0 100644 --- a/src/app/api/csp-report/route.ts +++ b/src/app/api/csp-report/route.ts @@ -18,10 +18,12 @@ export const dynamic = 'force-dynamic' * the Sentry browser SDK entirely, so no `beforeSend` filter could touch them — * the only place to filter is an endpoint we own. * - * This forwards to Sentry exactly as before, minus two classes of pure noise: - * repeats of a violation Sentry has already grouped (the dominant class — one - * missing allow-list entry produced 14k events on its own) and reports injected - * by browser extensions. + * This forwards to Sentry exactly as before, minus repeats of a violation + * Sentry has already grouped — one missing allow-list entry produced 14k + * identical events on its own, so de-duplication is the entire lever here. The + * extension-scheme filter in csp-report.utils.ts is a cheap extra that saves an + * outbound request; Sentry's own default inbound filter already drops that + * class server-side, so it is not doing the real work. */ /** Both wire formats, plus plain JSON from anything replaying a report. */ @@ -43,6 +45,16 @@ const seenGroups = new Set() const FORWARD_TIMEOUT_MS = 3000 +/** + * Hard cap on forwards per request. This endpoint is unauthenticated and a + * Reporting-API batch is an array, so without a cap one POST could fan out + * arbitrarily many events. That matters more than it looks: Sentry bills CSP + * reports against the *error* quota under a per-DSN-key rate limit, so an + * uncapped fan-out could exhaust the quota that real application exceptions + * depend on. A genuine browser batch is a handful of reports. + */ +const MAX_FORWARDS_PER_REQUEST = 20 + function shouldForward(groupKey: string): boolean { if (!seenGroups.has(groupKey)) { // Bound the memory: a flood of distinct groups must not grow forever. @@ -70,6 +82,7 @@ export async function POST(request: NextRequest): Promise { const forwardable = reports .filter((report) => !shouldIgnoreCspReport(report)) .filter((report) => shouldForward(cspReportGroupKey(report))) + .slice(0, MAX_FORWARDS_PER_REQUEST) await Promise.allSettled( forwardable.map((report) => diff --git a/src/utils/__tests__/csp-report.utils.test.ts b/src/utils/__tests__/csp-report.utils.test.ts index 233be83bae..23fe82eda7 100644 --- a/src/utils/__tests__/csp-report.utils.test.ts +++ b/src/utils/__tests__/csp-report.utils.test.ts @@ -116,6 +116,53 @@ describe('cspReportGroupKey', () => { ) }) + // Sentry's csp:v1 strategy keys these two on the keyword rather than the + // blocked URI, so it raises two separate issues. If the group key collapsed + // them, the second would be sampled away as a duplicate and its issue might + // never be created — in the one category this policy most needs to see. + it('separates unsafe-inline from unsafe-eval, matching how Sentry groups them', () => { + const inline = cspReportGroupKey({ + 'effective-directive': 'script-src', + 'violated-directive': "script-src 'unsafe-inline'", + 'blocked-uri': 'inline', + }) + const evaluated = cspReportGroupKey({ + 'effective-directive': 'script-src', + 'violated-directive': "script-src 'unsafe-eval'", + 'blocked-uri': 'eval', + }) + + expect(inline).toBe('script-src|unsafe-inline') + expect(evaluated).toBe('script-src|unsafe-eval') + expect(inline).not.toBe(evaluated) + }) + + it('groups one keyword consistently even when browsers report a different blocked-uri', () => { + expect( + cspReportGroupKey({ + 'effective-directive': 'script-src', + 'violated-directive': "script-src 'unsafe-inline'", + 'blocked-uri': 'inline', + }) + ).toBe( + cspReportGroupKey({ + 'effective-directive': 'script-src', + 'violated-directive': "script-src 'unsafe-inline'", + 'blocked-uri': 'self', + }) + ) + }) + + it('leaves a normal script-src host violation keyed on its origin', () => { + expect( + cspReportGroupKey({ + 'effective-directive': 'script-src', + 'violated-directive': 'script-src', + 'blocked-uri': 'https://cdn.example/x.js', + }) + ).toBe('script-src|https://cdn.example') + }) + it('falls back to violated-directive when the effective one is absent', () => { expect(cspReportGroupKey({ 'violated-directive': 'connect-src', 'blocked-uri': 'inline' })).toBe( 'connect-src|inline' diff --git a/src/utils/csp-report.utils.ts b/src/utils/csp-report.utils.ts index dffad2cbb8..8ba9054dd0 100644 --- a/src/utils/csp-report.utils.ts +++ b/src/utils/csp-report.utils.ts @@ -74,7 +74,9 @@ export function shouldIgnoreCspReport(report: CspReport): boolean { /** Reporting-API body (camelCase) → the legacy hyphenated shape. */ function fromReportingApi(body: Record): CspReport { return { - 'blocked-uri': body.blockedURL as string | undefined, + // blockedURL is the spec field; blockedURI is what some Chromium + // versions still emit. + 'blocked-uri': (body.blockedURL ?? body.blockedURI) as string | undefined, 'document-uri': body.documentURL as string | undefined, 'effective-directive': body.effectiveDirective as string | undefined, // Sentry keys its CSP grouping off violated-directive; the Reporting @@ -120,6 +122,21 @@ export function normalizeCspReports(payload: unknown): CspReport[] { */ export function cspReportGroupKey(report: CspReport): string { const directive = report['effective-directive'] || report['violated-directive'] || 'unknown' + + // Sentry's csp:v1 strategy drops the blocked URI entirely and keys on the + // keyword when script-src is violated by 'unsafe-inline' / 'unsafe-eval', + // so it raises TWO issues where a URI-based key sees one. Mirror that: + // otherwise whichever of the pair arrives second looks like a duplicate, + // gets sampled away, and its issue may never be created — and those two + // are the top of this policy's "tighten before enforcing" list, precisely + // the signal the de-duplication must never swallow. + const violated = String(report['violated-directive'] ?? '') + if (directive === 'script-src') { + for (const keyword of ['unsafe-inline', 'unsafe-eval']) { + if (violated.includes(keyword)) return `${directive}|${keyword}` + } + } + const blocked = report['blocked-uri'] || 'unknown' // Only the origin matters for grouping; paths and query strings vary per // request and would otherwise make every single report look distinct. From 27bdf7d78fb83e9cf395afc6006ee7e0a0c0f383 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 27 Jul 2026 15:15:14 +0100 Subject: [PATCH 05/25] fix(csp): keep the report endpoint root-relative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the absolute Reporting-Endpoints URL from 2a5fda9. That change was made on the strength of MDN describing the value as absolute, but the Reporting API spec resolves an endpoint "with base URL set to response's url" (W3C Reporting §3.3) — root-relative is valid and is resolved against whichever origin served the page. Relative is also strictly safer here. An absolute URL has to come from NEXT_PUBLIC_BASE_URL, which is unset on preview deploys, so previews would have pointed at production's collector — cross-origin, and Chromium ignores report-uri whenever report-to is present, so those reports would have been dropped rather than falling back. --- next.config.js | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/next.config.js b/next.config.js index 8fb30a7e08..adf45c0375 100644 --- a/next.config.js +++ b/next.config.js @@ -16,23 +16,14 @@ const redirectsConfig = require('./redirects.json') */ const CSP_REPORT_PATH = '/api/csp-report' -/** - * `report-uri` takes a URI-reference, so the relative path above is valid and - * stays correct on every origin (prod, staging, preview URLs alike). - * - * `Reporting-Endpoints` is not so clearly specified: the Reporting API says an - * endpoint is a URL resolved against the document, but MDN and Chrome's own - * docs both describe the value as an absolute one. Getting this wrong fails - * silently AND expensively — Chromium prefers `report-to` and ignores - * `report-uri` whenever both are present, so an endpoint it declines to parse - * would cost us every Chromium report without a single error anywhere. Emit an - * absolute URL, which is valid under either reading. Mirrors BASE_URL in - * src/constants/general.consts.ts. - */ -function cspReportEndpoint() { - const base = (process.env.NEXT_PUBLIC_BASE_URL || 'https://peanut.me').replace(/\/$/, '') - return `${base}${CSP_REPORT_PATH}` -} +// Root-relative on purpose, for `report-uri` and `Reporting-Endpoints` alike. +// `report-uri` takes a URI-reference; the Reporting API resolves its endpoint +// "with base URL set to response's url" (W3C Reporting §3.3), so both land on +// whichever origin actually served the page. An absolute URL would have to be +// built from an env var and would silently point preview deploys at +// production's collector — cross-origin, so Chromium would drop those reports +// entirely, since it prefers `report-to` and ignores `report-uri` when both +// are present. Don't "fix" this to an absolute URL. /** * Chain RPC endpoints, which have two independent sources that must both be @@ -157,7 +148,7 @@ function contentSecurityPolicyReportOnly() { const CSP_REPORT_GROUP = 'csp-endpoint' function reportingEndpointsHeader() { - return [{ key: 'Reporting-Endpoints', value: `${CSP_REPORT_GROUP}="${cspReportEndpoint()}"` }] + return [{ key: 'Reporting-Endpoints', value: `${CSP_REPORT_GROUP}="${CSP_REPORT_PATH}"` }] } // Get git commit hash at build time From 87f7f4baa9c0050df2e82597f56e17936fbe8ba3 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 27 Jul 2026 15:32:23 +0100 Subject: [PATCH 06/25] fix(csp): cap before de-duplicating, and group the whole script-src family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from CodeRabbit review. The fan-out cap ran after the de-duplication filter, but shouldForward records every group it inspects as seen. Truncating afterwards meant groups past the cap were marked seen without ever being sent, so their real first sighting was lost and later repeats were sampled away as duplicates — the exact failure the first-sighting guarantee exists to prevent. Cap the candidates first, then de-duplicate. The keyword grouping also only matched a bare `script-src`, but browsers report the sub-directive they actually checked: inline