Skip to content
78 changes: 49 additions & 29 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,24 @@ const withBundleAnalyzer =
const redirectsConfig = require('./redirects.json')

/**
* Sentry's CSP-report ingest endpoint, derived from the browser DSN
* (`<protocol>://<publicKey>@<host><path>/<projectId>`). 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
Expand Down Expand Up @@ -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.
Expand All @@ -83,32 +77,60 @@ 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',
'https://api.justaname.id',
'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'",
Expand All @@ -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
Expand Down
138 changes: 138 additions & 0 deletions src/app/api/csp-report/route.ts
Original file line number Diff line number Diff line change
@@ -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<string>()

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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<NextResponse> {
// 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<string>()
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<string, string> = { '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
}
Loading
Loading