Skip to content

fix(csp): close report-only allow-list gaps for the SW, RPC tail and Google Ads - #2564

Open
innolope-dev wants to merge 1 commit into
devfrom
fix/csp-allowlist-gaps-sw-rpc
Open

fix(csp): close report-only allow-list gaps for the SW, RPC tail and Google Ads#2564
innolope-dev wants to merge 1 commit into
devfrom
fix/csp-allowlist-gaps-sw-rpc

Conversation

@innolope-dev

Copy link
Copy Markdown
Collaborator

Summary

Five gaps in the report-only connect-src/script-src/style-src/frame-src allow-list. Nothing breaks today — the enforcing header is still only frame-ancestors/object-src/base-uri, and everything here is disposition: report. Each of these would break something the moment the policy is promoted to enforcing.

Companion to #2563, which fixes why the report-only signal is currently unreadable (stale documents replaying superseded policies). Independent of it — different files, no conflict.

Every host below was read out of a source, not guessed: Google's own published domain list, viem/chains, and the Typeform SDK bundle.

1. Google Ads remarketing — 187 domains, and why it can't be a wildcard

GA4 sends its audience/remarketing beacon (/ads/ga-audiences) to the user's own country Google domainwww.google.com.ar for a user in Argentina, www.google.it in Italy — picked by Google at runtime from the user's locale, not by us.

CSP host-sources allow * only as the leftmost label. So there is no wildcard that spans TLDs:

  • https://*.google.com does not match google.com.ar — different registrable domain.
  • https://www.google.* is not valid CSP grammar.

Enumeration is the only option that keeps remarketing working under enforcement. All 187 domains live in csp-google-domains.js, taken verbatim from Google's own list at https://www.google.com/supported_domains, with the fetch date and refresh command recorded in the file. Only the www. host is allowed — the only form present in the violation data.

Observed live under the current policy (so these are real gaps, not stale-client noise): PEANUT-UI-R8F (.com.ar, 54 users), PEANUT-UI-R8T (.com.bd, 35 users), PEANUT-UI-R8Z (.it, 8 users) — all still firing.

This supersedes the former lone https://www.google.com entry, which is the first element of the generated list.

Tradeoff worth a conscious decision: the policy header grows from ~2.6 KB to ~6.5 KB, on every response. Comfortably within browser and Vercel limits, but it is real bytes on every request. That is the unavoidable price of the enumeration; the alternative is remarketing silently breaking per-country at enforcement.

2. Service worker — pbs.twimg.com

The landing page's testimonial avatars. They render as <img>, so img-src https: would normally cover them — but serwist's runtime cache re-fetches them from inside sw.js, and in a service worker every fetch() is connect-src regardless of what the response is used for. The reports carry document-uri: https://peanut.me/sw.js for exactly this reason; /sw.js is served by the same /:path* header rule, so it inherits this policy.

Same mechanism behind the existing www.googletagmanager.com and api.onesignal.com violations reported from sw.js / OneSignalSDKWorker.js — those hosts were already allow-listed, so they needed no change.

3. RPC tail — 10 hosts

getPublicClient (src/app/actions/clients.ts) hands any chain absent from rpcUrls a bare http(), and viem then uses that chain's own default endpoint. That path is reachable from client code via fetchTokenSymbol on the token selector, whose chain list is API-driven — so it covers the whole of chainRegistry.consts.ts, not just the ten chains wired into wagmi.config.

Taking the registry as the boundary rather than picking "popular" by feel: it is exactly the set a user can select. Gnosis, Celo and Linea were already covered; the remaining ten, each resolved from viem/chains:

chain viem default
43114 Avalanche api.avax.network
5000 Mantle rpc.mantle.xyz
999 HyperEVM rpc.hyperliquid.xyz
8217 Kaia public-en.node.kaia.io
57073 Ink rpc-gel.inkonchain.com, rpc-qnd.inkonchain.com
747474 Katana rpc.katana.network
9745 Plasma rpc.plasma.to
988 Stable rpc.stable.xyz
4217 Tempo rpc.presto.tempo.xyz

Silent in the report-only data because these chains are rarely picked — not because they are unreachable. Same shape as the wss:// gap, which stayed invisible until #2519 went looking for it.

4. Typeform — frame-src / style-src / connect-src

The feedback Widget in Global/Layout needs form.typeform.com (the SDK's DEFAULT_DOMAIN, i.e. the iframe), embed.typeform.com (injected stylesheet from /next/css/) and api.typeform.com (single-embed fetch). Directives confirmed by reading the SDK bundle.

Currently unreachable — its Modal is never opened, so it produces zero violations — but re-arming it is a one-line change, and the failure mode under enforcement is a blank iframe.

5. jsdelivr — script-src

cdn.jsdelivr.net serves the mermaid bundle for /dev/kyc-flows. Dev-only route, but a real violation on a real deployment.

Deliberately NOT added

www.googleadservices.com and googleads.g.doubleclick.net — the other two standard Google Ads remarketing hosts. Neither appears anywhere in 14 days of violation data; the current setup beacons via the GA4 ga-audiences path only. Adding them would be speculative widening, against the precedent #2519 set. Add them if a Google Ads remarketing tag is ever added to GTM.

Verification

  • pnpm build succeeds.
  • Served the production build and asserted against the real response header: all 187 Google domains present, and every one of pbs.twimg.com, api.typeform.com, api.avax.network, rpc.mantle.xyz, public-en.node.kaia.io, cdn.jsdelivr.net, embed.typeform.com, form.typeform.com appears exactly once.
  • Rendered policy parses into all 13 expected directives; connect-src has 241 sources with zero duplicates.
  • prettier --check clean on both files.

Risks

  • Larger response header on every request (see the tradeoff note above).
  • The Google domain list is a point-in-time snapshot. Google adds/retires country domains rarely; the refresh command is in the file header. A missing future domain degrades remarketing in that country only — it cannot break the app.

…Google Ads

Five gaps, all of which would break something the moment the report-only
policy is promoted to enforcing. None break anything today.

Google Ads remarketing. GA4 sends its audience beacon (/ads/ga-audiences) to
the user's OWN country Google domain — www.google.com.ar in Argentina,
www.google.it in Italy — chosen by Google at runtime from the user's locale.
CSP host-sources allow * only as the leftmost label, so no wildcard spans
TLDs: *.google.com does not match google.com.ar, and www.google.* is not
valid grammar. All 187 domains are therefore enumerated, taken verbatim from
Google's own https://www.google.com/supported_domains, in their own module so
next.config stays readable. This supersedes the lone www.google.com entry,
which is the first element of that list. Header grows ~2.6KB -> ~6.5KB; that
is the unavoidable cost of keeping remarketing alive under enforcement.

Service worker. pbs.twimg.com serves the landing page's testimonial avatars.
They render as <img>, so img-src would normally cover them — but serwist's
runtime cache re-fetches them from inside sw.js, and in a service worker every
fetch() is connect-src whatever the response is used for. The reports arrive
with document-uri https://peanut.me/sw.js for exactly this reason; sw.js is
served by the same /:path* header rule, so it inherits this policy.

RPC tail. getPublicClient hands any chain absent from rpcUrls a bare http(),
and viem then uses that chain's own default endpoint. That path is reachable
from client code via fetchTokenSymbol on the token selector, whose chain list
is API-driven, so it covers the whole chainRegistry, not just the ten chains
wired into wagmi.config. Ten hosts added, one per uncovered EVM chain, each
read out of viem/chains rather than guessed. Silent in the reports because
these chains are rarely picked — the same reason the wss:// gap stayed
invisible until #2519 went looking for it.

Typeform and jsdelivr. The feedback Widget needs form.typeform.com (iframe),
embed.typeform.com (injected stylesheet) and api.typeform.com (single-embed
fetch). It is currently unreachable — its Modal is never opened — so it
produces no violations, but re-arming it is a one-line change. jsdelivr serves
the mermaid bundle on /dev/kyc-flows, a real violation on a dev-only route.

Deliberately NOT added: www.googleadservices.com and googleads.g.doubleclick.net.
They are the other two standard Google Ads hosts but appear nowhere in 14 days
of violation data, so adding them would be speculative widening. Add them if a
Google Ads remarketing tag is ever added to GTM.
@innolope-dev innolope-dev self-assigned this Jul 29, 2026
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jul 29, 2026 9:43am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: be1dcc14-9af9-4b93-8e53-3c379f61f764

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2306 ran, 0 failed, 0 skipped, 41.0s

📊 Coverage (unit)

metric %
statements 60.9%
branches 44.1%
functions 50.1%
lines 61.4%
⏱ 10 slowest test cases
time test
3.8s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.1s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.5s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.4s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.4s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/utils/__tests__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
0.2s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › keeps stickers off the username pill (final pass respects the keep-out)
0.2s src/utils/__tests__/auth-token.test.ts › returns the token hydrated from Preferences after authReady
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@github-actions

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6656.05 → 6656.05 (0)
Findings: 0 net (+0 new, -0 resolved)

innolope-dev added a commit that referenced this pull request Jul 29, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant