From 4b3516d8b68fdcfb65792a7cb7184475c0302fc7 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 29 Jul 2026 10:36:55 +0100 Subject: [PATCH] fix(csp): close report-only allow-list gaps for the SW, RPC tail and Google Ads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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. --- csp-google-domains.js | 212 ++++++++++++++++++++++++++++++++++++++++++ next.config.js | 72 +++++++++++--- 2 files changed, 271 insertions(+), 13 deletions(-) create mode 100644 csp-google-domains.js diff --git a/csp-google-domains.js b/csp-google-domains.js new file mode 100644 index 000000000..648f09485 --- /dev/null +++ b/csp-google-domains.js @@ -0,0 +1,212 @@ +/** + * Google's country domains, listed for the Google Ads remarketing beacon. + * + * GA4 sends its audience/remarketing ping (`/ads/ga-audiences`) to the user's + * OWN country Google domain — www.google.com.ar for a user in Argentina, + * www.google.it in Italy — picked by Google at runtime from the user's locale + * rather than by us. CSP host-sources allow `*` only as the leftmost label, so + * there is no wildcard that spans TLDs: `*.google.com` does not match + * google.com.ar, and `www.google.*` is not valid grammar. Every domain has to + * be listed, or remarketing silently breaks for users in that country the + * moment the policy is enforced. + * + * Only the `www.` host is allowed, which is the only form observed in the + * violation reports and keeps this as tight as an enumeration can be. + * + * Sourced verbatim from Google's own list, 187 entries, fetched + * 2026-07-29. Refresh with: + * curl -s https://www.google.com/supported_domains + */ +const googleCountryDomainSuffixes = [ + 'com', + 'ad', + 'ae', + 'com.af', + 'com.ag', + 'al', + 'am', + 'co.ao', + 'com.ar', + 'as', + 'at', + 'com.au', + 'az', + 'ba', + 'com.bd', + 'be', + 'bf', + 'bg', + 'com.bh', + 'bi', + 'bj', + 'com.bn', + 'com.bo', + 'com.br', + 'bs', + 'bt', + 'co.bw', + 'by', + 'com.bz', + 'ca', + 'cd', + 'cf', + 'cg', + 'ch', + 'ci', + 'co.ck', + 'cl', + 'cm', + 'cn', + 'com.co', + 'co.cr', + 'com.cu', + 'cv', + 'com.cy', + 'cz', + 'de', + 'dj', + 'dk', + 'dm', + 'com.do', + 'dz', + 'com.ec', + 'ee', + 'com.eg', + 'es', + 'com.et', + 'fi', + 'com.fj', + 'fm', + 'fr', + 'ga', + 'ge', + 'gg', + 'com.gh', + 'com.gi', + 'gl', + 'gm', + 'gr', + 'com.gt', + 'gy', + 'com.hk', + 'hn', + 'hr', + 'ht', + 'hu', + 'co.id', + 'ie', + 'co.il', + 'im', + 'co.in', + 'iq', + 'is', + 'it', + 'je', + 'com.jm', + 'jo', + 'co.jp', + 'co.ke', + 'com.kh', + 'ki', + 'kg', + 'co.kr', + 'com.kw', + 'kz', + 'la', + 'com.lb', + 'li', + 'lk', + 'co.ls', + 'lt', + 'lu', + 'lv', + 'com.ly', + 'co.ma', + 'md', + 'me', + 'mg', + 'mk', + 'ml', + 'com.mm', + 'mn', + 'com.mt', + 'mu', + 'mv', + 'mw', + 'com.mx', + 'com.my', + 'co.mz', + 'com.na', + 'com.ng', + 'com.ni', + 'ne', + 'nl', + 'no', + 'com.np', + 'nr', + 'nu', + 'co.nz', + 'com.om', + 'com.pa', + 'com.pe', + 'com.pg', + 'com.ph', + 'com.pk', + 'pl', + 'pn', + 'com.pr', + 'ps', + 'pt', + 'com.py', + 'com.qa', + 'ro', + 'ru', + 'rw', + 'com.sa', + 'com.sb', + 'sc', + 'se', + 'com.sg', + 'sh', + 'si', + 'sk', + 'com.sl', + 'sn', + 'so', + 'sm', + 'sr', + 'st', + 'com.sv', + 'td', + 'tg', + 'co.th', + 'com.tj', + 'tl', + 'tm', + 'tn', + 'to', + 'com.tr', + 'tt', + 'com.tw', + 'co.tz', + 'com.ua', + 'co.ug', + 'co.uk', + 'com.uy', + 'co.uz', + 'com.vc', + 'co.ve', + 'co.vi', + 'com.vn', + 'vu', + 'ws', + 'rs', + 'co.za', + 'co.zm', + 'co.zw', + 'cat', +] + +module.exports = { + googleAdsRemarketingHosts: googleCountryDomainSuffixes.map((suffix) => `https://www.google.${suffix}`), +} diff --git a/next.config.js b/next.config.js index b435a32a4..194f07609 100644 --- a/next.config.js +++ b/next.config.js @@ -4,6 +4,7 @@ const withBundleAnalyzer = process.env.ANALYZE === 'true' ? require('@next/bundle-analyzer')({ enabled: true }) : (config) => config const redirectsConfig = require('./redirects.json') +const { googleAdsRemarketingHosts } = require('./csp-google-domains') /** * Same-origin collector for violation reports (src/app/api/csp-report). It @@ -26,12 +27,21 @@ const CSP_REPORT_PATH = '/api/csp-report' // are present. Don't "fix" this to an absolute URL. /** - * Chain RPC endpoints, which have two independent sources that must both be - * allowed: `rpcUrls` in src/constants/general.consts.ts (our own viem clients) - * and viem's per-chain defaults, which wagmi's bare `http()` transports fall - * back to for external-wallet flows. Neither is importable here — next.config - * is CommonJS and general.consts.ts is TS — so this list is a hand-kept mirror. - * Re-check it when either source gains a chain. + * Chain RPC endpoints, which have three independent sources that must all be + * allowed: `rpcUrls` in src/constants/general.consts.ts (our own viem clients), + * viem's per-chain defaults, which wagmi's bare `http()` transports fall back to + * for external-wallet flows, and — the one this list kept missing — viem's + * defaults for every EVM chain in src/constants/chainRegistry.consts.ts. + * + * That third source is the whole selectable-chain list, not just the ten wired + * into wagmi.config: `getPublicClient` (src/app/actions/clients.ts) hands any + * chain absent from `rpcUrls` a bare `http()`, and it is reached from client + * code via `fetchTokenSymbol` on the token selector, whose chain list comes from + * the API. So picking a token on Avalanche or Mantle talks to that chain's viem + * default from the browser. + * + * None of these are importable here — next.config is CommonJS, the rest is TS — + * so this list is a hand-kept mirror. Re-check it when any source gains a chain. */ const chainRpcHosts = [ 'https://*.core.chainstack.com', @@ -48,6 +58,20 @@ const chainRpcHosts = [ 'https://rpc.linea.build', 'https://forno.celo.org', 'https://*.rpc.thirdweb.com', + // viem defaults for the remaining EVM chains in the registry. Silent in the + // report-only data because these are rarely-picked chains, not because they + // are unreachable — the same reason the wss:// gap stayed invisible until + // it was looked for (#2519). + 'https://api.avax.network', // 43114 Avalanche + 'https://rpc.hyperliquid.xyz', // 999 HyperEVM + 'https://rpc-gel.inkonchain.com', // 57073 Ink + 'https://rpc-qnd.inkonchain.com', // 57073 Ink (second default) + 'https://rpc.katana.network', // 747474 Katana + 'https://rpc.mantle.xyz', // 5000 Mantle + 'https://rpc.plasma.to', // 9745 Plasma + 'https://rpc.stable.xyz', // 988 Stable + 'https://rpc.presto.tempo.xyz', // 4217 Tempo + 'https://public-en.node.kaia.io', // 8217 Kaia ] /** @@ -69,8 +93,11 @@ function contentSecurityPolicyReportOnly() { const directives = [ "default-src 'self'", // PostHog is same-origin via the /relay rewrite, so it needs no entry here. - "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://client.crisp.chat https://static.sumsub.com https://cdn.onesignal.com https://api.onesignal.com", - "style-src 'self' 'unsafe-inline' https://client.crisp.chat", + // jsdelivr serves the mermaid bundle for /dev/kyc-flows. Dev-only route, + // but it is a real violation on a real deployment. + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://client.crisp.chat https://static.sumsub.com https://cdn.onesignal.com https://api.onesignal.com https://cdn.jsdelivr.net", + // Typeform injects a stylesheet from embed.typeform.com/next/css/. + "style-src 'self' 'unsafe-inline' https://client.crisp.chat https://embed.typeform.com", "img-src 'self' data: blob: https:", "font-src 'self' data: https://client.crisp.chat", [ @@ -92,14 +119,18 @@ function contentSecurityPolicyReportOnly() { // 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. The GTM container - // itself is fetched as well as executed, so it needs a connect-src - // entry on top of the script-src one. + // pings go to doubleclick. 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', + // Every Google country domain, because the remarketing beacon goes + // to the user's own — see csp-google-domains.js for why this cannot + // be a wildcard. Supersedes the former lone 'https://www.google.com' + // entry, which is the first element of this list. + ...googleAdsRemarketingHosts, 'https://rpc.zerodev.app', 'https://*.g.alchemy.com', 'https://rpc.ankr.com', @@ -122,6 +153,16 @@ function contentSecurityPolicyReportOnly() { 'wss://*.sumsub.com', 'https://widget.manteca.dev', 'https://*.onesignal.com', + // Testimonial avatars on the landing page. They render as , 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 regardless of what the response is used + // for. The violations 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. + 'https://pbs.twimg.com', + // Only reached in the SDK's single-embed mode; see frame-src below. + 'https://api.typeform.com', ...chainRpcHosts, ].join(' '), // Bridge is the terms-of-service iframe BridgeTosStep renders through @@ -130,7 +171,12 @@ function contentSecurityPolicyReportOnly() { // 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", + // form.typeform.com is the SDK's DEFAULT_DOMAIN — the iframe the feedback + // Widget in Global/Layout renders. Currently unreachable (its Modal is + // never opened), so it produces no violations today; allow-listed because + // re-arming it is a one-line change and the failure would be a blank + // iframe under enforcement. + "frame-src 'self' https://*.crisp.chat https://*.sumsub.com https://widget.manteca.dev https://mpago.la https://*.bridge.xyz https://form.typeform.com", "worker-src 'self' blob:", "object-src 'none'", "base-uri 'self'",