From c0fbae41558a5a7d024b13ddcd86a77b29288fb6 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 21 Jul 2026 16:06:40 +0100 Subject: [PATCH 1/3] feat(security): report-only CSP with a script-src allow-list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app ships no script-src today, so an XSS anywhere runs unconstrained and can exfiltrate to any origin. Enforcing a guessed allow-list would blank the app, so this ships REPORT-ONLY: it collects violations from real traffic until the list is known complete, then gets promoted to the enforcing header. Reports go to Sentry via a report-uri derived from the browser DSN; the policy still ships (without reporting) when the DSN is absent. object-src 'none' and base-uri 'self' are added to the ENFORCING policy now — the app embeds no plugins and sets no , so neither can break a working page. Known-loose and to be tightened before promotion: 'unsafe-inline' and 'unsafe-eval' in script-src (Next's bootstrap and the wallet SDKs), and a connect-src that cannot enumerate every env-driven chain RPC. --- next.config.js | 80 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/next.config.js b/next.config.js index 53c2ad6b51..41c7f1f361 100644 --- a/next.config.js +++ b/next.config.js @@ -5,6 +5,78 @@ const withBundleAnalyzer = const redirectsConfig = require('./redirects.json') +/** + * Sentry's CSP-report ingest endpoint, derived from the browser DSN + * (`https://@/`). 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`. + */ +function sentryCspReportUri() { + const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN + if (!dsn) return null + try { + const { host, username, pathname } = new URL(dsn) + const projectId = pathname.replace(/^\//, '') + if (!host || !username || !projectId) return null + return `https://${host}/api/${projectId}/security/?sentry_key=${username}` + } catch { + return null + } +} + +/** + * First-draft CSP, shipped REPORT-ONLY. + * + * Nothing here is enforced yet: the app currently has no script-src at all, so + * an XSS anywhere is unconstrained. Guessing the allow-list and enforcing it + * would blank the app; instead this collects violation reports from real + * traffic until the list is known to be complete, then it gets promoted to the + * enforcing `Content-Security-Policy` header. + * + * Known-loose parts, to tighten before promotion: + * - `'unsafe-inline'` / `'unsafe-eval'` in script-src: Next's inline bootstrap + * and the wallet SDKs need them today. Moving to nonces is its own change. + * - connect-src can't enumerate every chain RPC (they come from env and vary by + * network), so the report stream is what completes this list. + */ +function contentSecurityPolicyReportOnly() { + const reportUri = sentryCspReportUri() + 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", + "style-src 'self' 'unsafe-inline' https://client.crisp.chat", + "img-src 'self' data: blob: https:", + "font-src 'self' data: https://client.crisp.chat", + [ + "connect-src 'self'", + 'https://api.peanut.me', + 'https://*.peanut.me', + 'https://*.ingest.sentry.io', + 'https://*.ingest.us.sentry.io', + 'https://www.google-analytics.com', + 'https://rpc.zerodev.app', + 'https://*.g.alchemy.com', + 'https://rpc.ankr.com', + 'https://assets.coingecko.com', + 'https://coin-images.coingecko.com', + 'https://api.frankfurter.app', + 'https://dolarapi.com', + 'https://client.crisp.chat', + 'wss://client.relay.crisp.chat', + 'https://*.sumsub.com', + 'https://widget.manteca.dev', + ].join(' '), + "frame-src 'self' https://client.crisp.chat https://*.sumsub.com https://widget.manteca.dev https://mpago.la", + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ] + if (reportUri) directives.push(`report-uri ${reportUri}`) + return directives.join('; ') +} + // Get git commit hash at build time let gitCommitHash = 'unknown' try { @@ -186,7 +258,13 @@ let nextConfig = { }, // Security headers - prevents clickjacking and other attacks // Using frame-ancestors instead of X-Frame-Options to allow specific domains - { key: 'Content-Security-Policy', value: "frame-ancestors 'self' https://hugo0.com" }, + // object-src/base-uri are safe to enforce today: the app embeds no + // plugins and sets no , so neither can break a working page. + { + key: 'Content-Security-Policy', + value: "frame-ancestors 'self' https://hugo0.com; object-src 'none'; base-uri 'self'", + }, + { key: 'Content-Security-Policy-Report-Only', value: contentSecurityPolicyReportOnly() }, { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, ], From 331f20687be7efbade8f52ef22a542083f62fad7 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 21 Jul 2026 17:31:36 +0100 Subject: [PATCH 2/3] fix(review): preserve DSN protocol and path in the CSP report URI The report URI hardcoded https and dropped everything but the last path segment, so a path-prefixed DSN (self-hosted Sentry under a sub-path) posted reports to an endpoint that doesn't exist, and an http DSN was silently upgraded. Verified against a standard ingest DSN, a path-prefixed one, and a plain http localhost DSN. --- next.config.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/next.config.js b/next.config.js index 41c7f1f361..6d19b3063c 100644 --- a/next.config.js +++ b/next.config.js @@ -7,18 +7,24 @@ const redirectsConfig = require('./redirects.json') /** * Sentry's CSP-report ingest endpoint, derived from the browser DSN - * (`https://@/`). 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`. + * (`://@/`). 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`. + * + * 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. */ function sentryCspReportUri() { const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN if (!dsn) return null try { - const { host, username, pathname } = new URL(dsn) - const projectId = pathname.replace(/^\//, '') + const { protocol, host, username, pathname } = new URL(dsn) + const segments = pathname.split('/').filter(Boolean) + const projectId = segments.pop() if (!host || !username || !projectId) return null - return `https://${host}/api/${projectId}/security/?sentry_key=${username}` + const prefix = segments.length ? `/${segments.join('/')}` : '' + return `${protocol}//${host}${prefix}/api/${projectId}/security/?sentry_key=${username}` } catch { return null } From e3b07788e5a678ee421f002d675d4391027856a5 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 10:42:40 +0100 Subject: [PATCH 3/3] feat(review): deliver CSP reports via report-to as well as report-uri report-uri alone is deprecated and Chromium is where most violations will come from once report-to is the only mechanism; shipping both (plus the Reporting-Endpoints header the report-to group resolves against) keeps the violation stream complete across engines, so the eventual promotion to enforcing isn't decided on partial data. --- next.config.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/next.config.js b/next.config.js index 6d19b3063c..b48d2cd5a7 100644 --- a/next.config.js +++ b/next.config.js @@ -79,10 +79,23 @@ function contentSecurityPolicyReportOnly() { "base-uri 'self'", "form-action 'self'", ] - if (reportUri) directives.push(`report-uri ${reportUri}`) + // Both delivery mechanisms on purpose: `report-uri` is deprecated but what + // Firefox/Safari actually send today, `report-to` (backed by the + // 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}`) 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}"` }] +} + // Get git commit hash at build time let gitCommitHash = 'unknown' try { @@ -271,6 +284,7 @@ let nextConfig = { value: "frame-ancestors 'self' https://hugo0.com; object-src 'none'; base-uri 'self'", }, { key: 'Content-Security-Policy-Report-Only', value: contentSecurityPolicyReportOnly() }, + ...reportingEndpointsHeader(), { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, ],