diff --git a/next.config.js b/next.config.js index 087e8d0a30..adf45c0375 100644 --- a/next.config.js +++ b/next.config.js @@ -6,29 +6,24 @@ 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' + +// 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 @@ -71,7 +66,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 +77,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 +115,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 +141,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..3dfcbd5d2d --- /dev/null +++ b/src/app/api/csp-report/route.ts @@ -0,0 +1,138 @@ +import { NextRequest, NextResponse } from 'next/server' + +import { + cspReportGroupKey, + normalizeCspReports, + sentryCspIngestUrl, + shouldIgnoreCspReport, + type CspReport, +} 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 ~70k events across ~1.8k users into the + * peanut-ui project in a week 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 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. */ +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 + +/** + * 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. + // Evict the oldest rather than clearing — a Set iterates in insertion + // order, so this drops one stale group instead of dumping all 500 and + // letting every still-active violation re-forward at once. + if (seenGroups.size >= SEEN_GROUPS_MAX) { + const oldest = seenGroups.values().next().value + if (oldest !== undefined) seenGroups.delete(oldest) + } + seenGroups.add(groupKey) + return true + } + return Math.random() < DUPLICATE_SAMPLE_RATE +} + +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 }) + + 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 seenInBatch = new Set() + const forwardable: CspReport[] = [] + for (const report of reports) { + if (shouldIgnoreCspReport(report)) continue + + const groupKey = cspReportGroupKey(report) + // One slot per distinct group: a batch is usually dominated by + // repeats of a single violation, and 20 copies of it must not + // crowd a genuinely new group out of the per-request cap. + if (seenInBatch.has(groupKey)) continue + seenInBatch.add(groupKey) + + // Cap BEFORE shouldForward, never after: shouldForward records + // each group as seen, so admitting more than we can send would + // mark groups seen that were never actually sent — their real + // first sighting lost, every later repeat sampled away. + if (seenInBatch.size > MAX_FORWARDS_PER_REQUEST) break + + if (shouldForward(groupKey)) forwardable.push(report) + } + + // Sentry derives the event's browser and request context from the + // headers of whoever POSTs to its security endpoint. That used to be + // the browser itself; now it is us, so pass the originals through or + // every CSP event is attributed to one Vercel egress IP running + // undici — deleting the "which browser / how many users" dimension + // these reports are read for. + const forwardedHeaders: Record = { 'Content-Type': 'application/csp-report' } + const userAgent = request.headers.get('user-agent') + if (userAgent) forwardedHeaders['User-Agent'] = userAgent + const forwardedFor = request.headers.get('x-forwarded-for') + if (forwardedFor) forwardedHeaders['X-Forwarded-For'] = forwardedFor + + await Promise.allSettled( + forwardable.map((report) => + fetch(ingestUrl, { + method: 'POST', + headers: forwardedHeaders, + 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..941f4dc811 --- /dev/null +++ b/src/utils/__tests__/csp-report.utils.test.ts @@ -0,0 +1,228 @@ +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' + ) + }) + + // 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': 'self', + }) + const evaluated = cspReportGroupKey({ + 'effective-directive': 'script-src', + 'violated-directive': "script-src 'unsafe-eval'", + 'blocked-uri': 'self', + }) + + expect(inline).toBe('script-src|unsafe-inline') + expect(evaluated).toBe('script-src|unsafe-eval') + expect(inline).not.toBe(evaluated) + }) + + // What browsers actually emit: the specific sub-directive that was checked. + // Inline