fix(csp): close the wss allow-list gap and stop the Sentry report flood - #2519
Conversation
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.
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughCSP report-only headers now target a same-origin collector. The new route normalizes and filters legacy and Reporting API payloads, de-duplicates and samples violations, then forwards selected reports to Sentry. WebSocket CSP allowlists include ChangesCSP reporting pipeline
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Browser
participant CSPReportRoute
participant normalizeCspReports
participant Sentry
Browser->>CSPReportRoute: POST CSP report
CSPReportRoute->>normalizeCspReports: normalize payload
normalizeCspReports-->>CSPReportRoute: normalized reports
CSPReportRoute->>Sentry: forward sampled reports
CSPReportRoute-->>Browser: HTTP 204
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Code-analysis diffPainscore total: 6284.43 → 6301.48 (+17.05) 🆕 New findings (6)
📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
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.
Flagged by the code-analysis bot: the exported route handler had no return type annotation.
…p fan-out 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.
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.
|
@coderabbitai full review |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/utils/csp-report.utils.ts (1)
123-150: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
directive === 'script-src'misses Chrome/Firefox's granularscript-src-elem/script-src-attrreporting.Per the CSP spec/
CSPViolationReport.effectiveDirective, Chrome and Firefox report the specific sub-directive that was checked (script-src-elemfor inline<script>blocks,script-src-attrfor inline event handlers) rather than the generalscript-src, whenever those sub-directives aren't explicitly declared — which appears to be the case for this policy. Real-world CSP reports confirm this (e.g. Sentry issue reports commonly show"effectiveDirective": "script-src-elem"for inline-script violations). Onlyeval()violations remain reported under plainscript-src.That means the
directive === 'script-src'keyword branch will rarely fire forunsafe-inlineviolations in Chrome/Firefox — it'll typically fall through to the blocked-uri-based key instead. The practical fallout is limited today becauseblocked-uriis the literal keyword (inline/eval), so inline and eval still end up in different groups by coincidence — but the code's documented intent ("mirrors how Sentry groups CSP issues") is less precise than the tests suggest, since every test here uses the idealized literal'script-src'value rather than the value browsers actually send.Consider matching the whole
script-src*family instead of an exact match:♻️ Proposed fix
- if (directive === 'script-src') { + if (directive === 'script-src' || directive.startsWith('script-src-')) { for (const keyword of ['unsafe-inline', 'unsafe-eval']) { if (violated.includes(keyword)) return `${directive}|${keyword}` } }It'd also be worth adding a test case with
'effective-directive': 'script-src-elem'to lock in the intended behavior, since none of the current tests exercise the value browsers actually emit.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/utils/csp-report.utils.ts` around lines 123 - 150, Update cspReportGroupKey so the unsafe-inline/unsafe-eval keyword handling applies to the entire script-src directive family, including script-src-elem and script-src-attr, rather than only an exact script-src match. Preserve the existing keyword-specific grouping and fallback behavior, and add coverage for a script-src-elem report.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/app/api/csp-report/route.ts`:
- Around line 82-85: Move the MAX_FORWARDS_PER_REQUEST cap in the forwardable
pipeline so it is applied before shouldForward(cspReportGroupKey(report)) runs,
while preserving the shouldIgnoreCspReport filter and existing ordering. Ensure
shouldForward only mutates seenGroups for reports that remain within the
forwarding candidate limit.
---
Nitpick comments:
In `@src/utils/csp-report.utils.ts`:
- Around line 123-150: Update cspReportGroupKey so the unsafe-inline/unsafe-eval
keyword handling applies to the entire script-src directive family, including
script-src-elem and script-src-attr, rather than only an exact script-src match.
Preserve the existing keyword-specific grouping and fallback behavior, and add
coverage for a script-src-elem report.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 60663317-03c3-4ec5-badb-841c630b09a3
📒 Files selected for processing (4)
next.config.jssrc/app/api/csp-report/route.tssrc/utils/__tests__/csp-report.utils.test.tssrc/utils/csp-report.utils.ts
…amily 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 <script> arrives as script-src-elem and inline handlers as script-src-attr, with only eval left under plain script-src. script-src-elem is the second-largest directive in the current reports, so this branch was mostly not firing.
Numbers predated the Sentry query: ~70k events / ~1.8k users over 7d, not 65k/1.3k over 4d.
…ty scripts Our own script-src contains 'unsafe-inline' and 'unsafe-eval', and Firefox/Safari echo the entire source list back in violated-directive. The keyword branch matched that text without checking the violation was local, so EVERY script-src report — including a genuinely blocked third-party script — keyed as the ubiquitous inline violation and got sampled away at 1%. That is the exact issue-loss the de-duplication was written to prevent, in the directive that matters most for XSS. Gate the branch on a local blocked-uri and match the quoted keyword, as Sentry's csp:v1 strategy does. Normalize the directive to its first token too: Firefox omits effective-directive, so keys were embedding the whole policy and churning on every edit. Also, forwarding through our own route made Sentry attribute every event to one Vercel egress IP running undici, deleting the browser/user dimension these reports are read for — pass the original User-Agent and X-Forwarded-For through. De-duplicate within a batch so repeats of one violation cannot crowd a new group out of the per-request cap, and evict the oldest seen group instead of dumping all 500 at the ceiling.
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai full review |
✅ Action performedFull review finished. Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 19 minutes. |
|
@coderabbitai full review |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/app/api/csp-report/route.ts`:
- Around line 49-57: Add request-level throttling to the CSP report handler
using the existing in-memory cooldown or rate-limit pattern, keyed per client
IP, before any Sentry forwarding occurs. Keep MAX_FORWARDS_PER_REQUEST for
batch-size limiting and ensure repeated unauthenticated requests from the same
IP are rejected or suppressed during the limiter window.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: ca54d986-69b3-4fc8-9bfc-7726954ac606
📒 Files selected for processing (4)
next.config.jssrc/app/api/csp-report/route.tssrc/utils/__tests__/csp-report.utils.test.tssrc/utils/csp-report.utils.ts
…s, PIX exit, verification tasks Cherry-picks the open native-facing work onto the release branch: - KYC now routes every native entry point through the Sumsub Cordova SDK instead of the WebSDK (peanut-ui #2562) — the one change here that genuinely requires a new binary rather than an OTA. - Deferred deep linking survives the store install, so a link followed before install lands on its destination on first launch (#2560, TASK-20772). - PIX deposit "Done" exits to home from both the completed and processing states instead of dropping the user into a new deposit (#2548). - Pending Bridge verification tasks card on home, dismissible and resurfaced under Unlocked regions (#2549). - Native demo/passkey follow-ups: the awaited token clear in clearAuthState plus the two callers it changed (#2517 — the rest shipped in 1.0.40). - Documents left running on a superseded deployment now reload (#2563). - Android safe zone sized from natively measured insets. Not carried: the CSP allow-list gaps (#2564). It builds on the #2519 collector rework — a /api/csp-report route and ten commits that are not on this branch — and the policy is report-only, so the gaps cost report fidelity, not behaviour.
Summary
Two problems, one root area: the report-only CSP is flooding Sentry, and it has a genuine allow-list gap that would break production the moment the policy is enforced.
~70k CSP events across ~1.8k users in 7 days made the 20 newest
peanut-uiissues all CSP reports. 99.3% of that volume is production (peanut.me), not staging — the "culprit" shown in Sentry's issue list is only the latest event's URL, which is why staging looks over-represented at a glance. Staging is 0.49%.a) How CSP reports reach Sentry
Not the browser SDK.
next.config.jsemittedreport-uri/report-topointing straight at Sentry's/api/<projectId>/security/ingest endpoint, derived from the public DSN. The browser POSTs violations itself, so these events never pass throughSentry.init—beforeSendand the existingshouldIgnoreErrorhelper are structurally unable to see them. (Worth stating plainly, since "filter it inshouldIgnoreError" is the obvious first instinct and it cannot work.)The only place to filter is an endpoint we own, so
report-uri/Reporting-Endpointsnow point at a same-origin collector,POST /api/csp-report, which forwards to the exact same Sentry endpoint minus the noise.b) The real gap —
wss://connect-srchadhttps://api.peanut.meandhttps://*.peanut.me. CSP treatswss:as a scheme distinct fromhttps:, so neither authorized the charges websocket (NEXT_PUBLIC_PEANUT_WS_URL) that every logged-in session opens. It is 84% of the violations still firing (2,377 of 2,834 in the last 24h; 6,177 events / 1,780 users over 7d, still escalating) and it would have broken the socket for every user on promotion to enforcing.Everything added below is evidence-backed — either a live Sentry violation or a confirmed call site. Nothing speculative.
connect-srcwss://api.peanut.me,wss://*.peanut.meconnect-srcwss://*.sumsub.comconnect-srchttps://api.coingecko.comTransactionDetailsReceipt.tsx:162token-metadata fallback. Only the two image CDNs were allowed.connect-srchttps://www.googletagmanager.comscript-srcbut notconnect-src; gtag XHRs back to it. 44 events / 21 users per day.connect-srchttps://www.google-analytics.com→https://*.google-analytics.comregion1.…). Same sharding is already observed onanalytics.google.com. The wildcard coverswww., so this is a simplification, not a widening.frame-srchttps://*.bridge.xyzBridgeTosStep→IframeWrapperrenders a Bridge-hosted ToS URL (compliance.bridge.xyz, 18 events / 11 users). Wildcarded like the existing*.sumsub.combecause the URL is chosen by the provider at runtime, not by us.frame-srchttps://client.crisp.chat→https://*.crisp.chatassets.crisp.chatwas blocked; matches whatconnect-srcalready does.Verified NOT needed (so the policy isn't widened for nothing): WalletConnect/Reown is not wired in at all —
wagmi.config.tsxpasses noconnectors, no@walletconnect/@reowndependency, andNEXT_PUBLIC_WC_PROJECT_IDhas zero references. PostHog, Sentry and the ZeroDev passkey server are all same-origin via existing rewrites. Every chain RPC (ours and viem's per-chain defaults for all 10 configured chains) is already covered.c) Stopping the flood
The noise is overwhelmingly repetition, not variety: Sentry groups CSP issues by directive + blocked origin, so one missing entry produced 14,378 identical events. So the collector:
(directive, blocked-origin)group unconditionally — a genuinely new violation always creates its issue and fires its alert, so no future signal is hidden — then samples repeats at 1%.chrome-extension://reports have ever reached the project). Keeping it in code means the guarantee is ours rather than a Sentry project setting nobody remembers exists.Deliberately not filtered, because they are the signal the report-only phase exists to collect:
inline,eval,data:,blob:— these are what has to reach zero beforescript-srccan be enforced.Extension-injected violations that arrive over plain
https(Google/Brave Translate'sfonts.gstatic.com, a coupon extension'simages.simplycodes.com,connect.facebook.net) are not allow-listed — they aren't ours. No scheme check can catch them, but the per-group de-duplication reduces each to ~1 event, which is why the generic mechanism beats a hand-maintained host denylist.Projected effect: from ~2,800 events/day to roughly one event per distinct violation.
d) Base branch — and the companion action
Targeted
main, because that is where the flood is (99.3% of volume) and this is a hotfix. Prod gets both the wss fix and the collector on merge.A
main→devback-merge is required and closes the staging side at the same time.devis missing PR #2479's entire allow-list (chain RPC hosts, OneSignal, the Google hosts, ipapi, justaname) because that fix also went straight tomain— so staging currently runs the old policy. The standard post-release back-merge carries #2479 and this PR todevin one go; no second PR needed. Flagging it explicitly since it is the only thing standing between staging and the same gaps.Risks
204whatever happens (a non-2xx would make browsers retry and log console errors — a second, self-inflicted noise source), everything is wrapped in try/catch, and the forward has a 3s timeout. Worst case is losing violation reports, which are already report-only diagnostics.frame-ancestors/object-src/base-uri) is untouched; the policy this PR edits is stillContent-Security-Policy-Report-Only, so there is zero user impact today — the value is that enforcement later won't break the websocket, KYC, or the ToS iframe.Set(per-serverless-instance, bounded at 500 groups), so an occasional extra first-sighting gets through. Same trade-off the health route's in-memory Discord cooldown already documents.Design notes / accepted trade-offs
De-duplication mirrors Sentry's
csp:v1grouping, including its one special case. Sentry drops the blocked URI and keys on the keyword whenscript-srcis violated by'unsafe-inline'/'unsafe-eval', raising two issues where a URI-based key sees one. An earlier revision of the group key collapsed them, so whichever arrived second would have been sampled away and its issue possibly never created — in exactly the category this policy exists to eliminate. Fixed and covered by tests.The forward loop is capped at 20 per request. The endpoint is unauthenticated and a Reporting-API payload is an array. Sentry bills CSP reports against the error quota under a per-DSN-key rate limit, so an uncapped fan-out is a way to starve real exception reporting, not just a noise problem.
report-uriandReporting-Endpointsare both root-relative. The Reporting API resolves an endpoint "with base URL set to response's url" (W3C Reporting §3.3), so a relative path lands on whichever origin served the page. An absolute URL would have to come fromNEXT_PUBLIC_BASE_URL, which is unset on preview deploys — previews would have pointed at production's collector, cross-origin, and Chromium ignoresreport-uriwheneverreport-tois present, so those reports would be dropped rather than falling back. There is a comment innext.config.jssaying not to "fix" this to an absolute URL.Sentry attributes forwarded reports to the server, not the browser. Relay reads the user agent and IP from the forwarding request, and its own edge sets
X-Sentry-Forwarded-For, which outranks anything we could send. So the "users affected" count on CSP issues after this lands is not a real user count — don't read it as one. The blocked URI, directive and document URL (the fields that actually drive the fix) are unaffected.Why not
shouldIgnoreError: structurally impossible, as above. The new filter is a pure function insrc/utils/csp-report.utils.tswith 32 unit tests; the route is a thin transport over it.sentryCspReportUri()moved rather than duplicated — out ofnext.config.js(CommonJS) into the collector assentryCspIngestUrl(dsn), so the DSN→ingest-URL derivation still lives in exactly one place.next.config.jsnow just points at a static same-origin path.Alternative considered and rejected: a dedicated Sentry project for CSP reports. Smaller diff, but it only moves the 70k events (still billed, still needing an out-of-repo project + env var in three environments) instead of eliminating them.
www.google.<ccTLD>long tail (~50 events/day across 45 ccTLDs) is left alone: CSP host-sources allow subdomain wildcards but not TLD wildcards, so it is literally inexpressible. Analytics-only, no functional risk. De-dup reduces it to ~45 one-off events.Follow-ups (not in this PR — pre-existing, out of blast radius)
/dev/kyc-flowsloads Mermaid fromcdn.jsdelivr.net, and/dev/routes are reachable in prod. Better fixed by dropping the CDN or blocking/dev/in prod than by weakeningscript-src.dolarapi.com(only reached from'use server'code),rpc.ankr.com(every Ankr URL is commented out),www.google.com(onlywindow.opennavigation; reCAPTCHA isn't wired).NEXT_PUBLIC_ONESIGNAL_WEBHOOKis blank in.env.exampleand fetched from the service worker — its deployed origin couldn't be determined from the repo. If it points anywhere other thanapi.peanut.meit needs an entry. No violation has been reported for it.QA
npx jest src/utils/__tests__/csp-report.utils.test.ts— 35/35 pass, covering both wire formats (application/csp-reportand the Reporting-APIreports+jsonbatch), the group key, malformed payloads, and DSN parsing.next.config.jsand diffed the directives —report-uri /api/csp-report,report-to csp-endpoint,Reporting-Endpoints: csp-endpoint="/api/csp-report", and the newwss:/frame-srcentries all present.@capacitor/*packages (declared inpackage.json, absent from this machine's sharednode_modules) — same pre-existing cause as the 10 local typecheck errors, none in changed files. CI installs them.Post-deploy check (needed — a green CI cannot prove this). Sentry's Relay returns 200 before it parses the body and discards anything it can't read as an
Invalidoutcome, so a broken forward would look identical to a working one from our side. After deploy, confirm a CSP event actually lands in thepeanut-uiproject (and thatwss://api.peanut.mestops appearing). That is the only real proof the pipeline works end to end.Screenshots: N/A (no UI change — HTTP headers and a report collector).
Summary by CodeRabbit
New Features
Bug Fixes
Tests