Repository navigation
security: tighten frontend CSP headers - #759
Conversation
|
@Emelie-Dev Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
@Emelie-Dev is attempting to deploy a commit to the miracle656's projects Team on Vercel. A member of the Team first needs to authorize it. |
Miracle656
left a comment
There was a problem hiding this comment.
Thanks — the wallet half is exactly what #705 asked for: unsafe-inline and unsafe-eval are gone, wasm-unsafe-eval is kept for stellar-sdk, and connect-src is an explicit list. Two things to fix before this can go in.
1. The docs CSP would take the docs site down. I built frontend/docs from this branch and served it:
content-security-policy: ... script-src 'self' 'nonce-YmM0…' 'strict-dynamic' ...
scripts with nonce: 0
scripts total: 13
Nextra's docs are statically generated on the pages router, so a per-request nonce from middleware never reaches the pre-rendered HTML. strict-dynamic makes browsers ignore 'self', so all 13 scripts would be blocked and the site would load with no JavaScript.
Options, any is fine:
- drop the nonce and
strict-dynamicfor docs and keepscript-src 'self'(plus'unsafe-inline'if the build emits inline bootstrap) — the docs site holds no keys, so this is a reasonable place to stop; or - propagate the nonce properly for the pages router via a custom
_documentandgetServerSideProps, which also forces every page to render dynamically.
Please paste the same two counts from a local next start in the PR when it's fixed — a scripts-with-nonce count equal to scripts-total, or a policy without strict-dynamic.
2. The wallet connect-src is missing two hosts the browser actually calls, so these features would break silently:
https://api.soroswap.finance—app/swap/page.tsxis a client component andlib/soroswap.tscalls the aggregator from the browser. Without this, every swap quote fails.https://open.er-api.com—lib/currency.tsfetches FX rates for local-currency display.
Worth a quick audit of the rest of the list against what the app fetches client-side, rather than only what's in env vars.
Nothing else from me — the structure (middleware for the nonce, static headers in next.config) is right, and dropping connect-src https: is the valuable part.
Miracle656
left a comment
There was a problem hiding this comment.
Re-read at fc584524. Both points from last time are fixed. One new thing blocks it, in a file this PR doesn't touch.
1. The docs CSP — addressed
frontend/docs/middleware.js now sends script-src 'self' 'unsafe-inline' with no nonce and no 'strict-dynamic'. That's the first of the two options I offered, and it's the right one here: Nextra's pages-router output is pre-rendered, so a per-request nonce could never have reached it, and the docs site holds no keys. Nothing left to count — a policy without strict-dynamic was the alternative acceptance criterion.
2. The missing wallet connect-src hosts — addressed
https://api.soroswap.finance and https://open.er-api.com are both in the list now, so swap quotes and FX rates survive.
I did the wider audit you'd expect from the "worth a quick audit of the rest" line, enumerating every https:// host reachable from frontend/wallet/lib, app and components:
rpc.lightsail.network,soroban-rpc.mainnet.stellar.gateway.fm,mainnet.sorobanrpc.comandrpc.ankr.comare not needed — they live inlib/rpcFailover.ts, which onlyapp/api/rpc/mainnet/route.tsimports, so those calls are server-side and never leave the browser.global.transak.comgoes throughwindow.open(url, '_blank'), not a fetch, so it's unaffected.ipfs.iois onlyresolveUri()building an image URL, andimg-srcallowshttps:.stellar.expertis a link.
So the list looks complete to me.
Blocking — the inline theme script loses its nonce
frontend/wallet/app/layout.tsx:61:
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){var t=localStorage.getItem('veil_theme');document.documentElement.setAttribute('data-theme',t==='light'?'light':'dark');})();`,
}}
/>
</head>That's a hand-written inline script with no nonce attribute, and the new wallet policy is script-src 'self' 'nonce-…' 'wasm-unsafe-eval' — 'unsafe-inline' is gone, which is the point of the PR. Next adds the nonce to the scripts it emits, not to one written in JSX, so the browser blocks this one. It runs before first paint to apply the stored theme, so the symptom is that every user who chose light mode gets dark, on every load, with a flash. It's the only hand-written inline script in the wallet — dangerouslySetInnerHTML appears once more in app/agent/page.tsx for markup, which script-src doesn't govern.
The layout is a server component and middleware already sets x-nonce on the request, so:
import { headers } from 'next/headers'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const nonce = (await headers()).get('x-nonce') ?? undefined
…
<script nonce={nonce} dangerouslySetInnerHTML={{ … }} />I started proving this out locally and had to abandon the build for unrelated reasons, so I'm reporting it as read rather than as run. Which brings me to the thing I'd still like from you: the same two counts I asked for on docs, but for the wallet, from a local next build && next start:
scripts with nonce: N
scripts total: N
Equal numbers on / and /dashboard. That's what tells us Next's own hydration and chunk scripts are getting the nonce under this policy — removing 'unsafe-inline' from the origin that holds signing material is worth being sure about, and it's not something I want to find out from production.
Add the nonce to that script, paste the counts, and I'll merge it the same day. Sorry this one sat as long as it did.
Resolves frontend/wallet/next.config.js: keeps main's NEXT_PUBLIC_COMMIT_SHA env re-export alongside this branch's header changes. Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
frontend/website is statically prerendered on every route and nothing in app/ calls headers(), so the per-request nonce middleware generates never reaches the HTML. strict-dynamic makes browsers ignore 'self', so the nonced policy blocked every script on the page. Measured on a local next build && next start of this branch: 18 script tags on / and on /products/wallet, 0 of them carrying a nonce, under script-src 'self' 'nonce-...' 'strict-dynamic'. The site would have loaded with no JavaScript — no GSAP, no framer-motion, no three.js. Same resolution the docs site already got in this PR: script-src 'self' 'unsafe-inline' and no nonce. 'unsafe-inline' is required for Next's own inline flight-data bootstrap (six tags on /). The wallet keeps its strict nonced policy, which is the origin that holds signing material. Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Miracle656
left a comment
There was a problem hiding this comment.
Re-read at ed40782. The nonce fix is correct, and I verified it rather than taking the claim — because no counts were posted, I ran the build myself. It passes. I also found and fixed one thing the last two reviews never looked at: the same CSP bug, on the marketing site.
The blocker is fixed — and here are the counts
frontend/wallet/app/layout.tsx now reads (await headers()).get("x-nonce") and passes nonce={nonce} to the pre-paint theme script, with the layout made async. That's exactly the shape I suggested, and middleware.js was already setting x-nonce on the request, so nothing else had to move.
Evidence from a local next build --webpack && next start on frontend/wallet (this branch merged with current main), counting <script tags against the nonce in that same response's CSP header:
/ scripts total: 20 | with the header's nonce: 20
/dashboard scripts total: 30 | with the header's nonce: 30
/lock scripts total: 20 | with the header's nonce: 20
content-security-policy: default-src 'self'; script-src 'self'
'nonce-MjgyMzAwNTct…' 'wasm-unsafe-eval'; …
Equal, on all three, and exactly one CSP header on the response (no stale duplicate from next.config.js). The theme script specifically:
<script nonce="<matches the response's CSP nonce>">(function(){var t=localStorage.getItem('veil_theme')…So Next's hydration and chunk scripts are getting the nonce under a policy with no 'unsafe-inline', on the origin that holds signing material. That was the thing I didn't want to learn from production, and it's now measured.
Also green on the merge result: npx jest --ci in frontend/wallet → 44 suites / 541 tests passed; npx tsc --noEmit → clean.
What I fixed myself: frontend/website had the docs bug
This is the one place neither of the earlier reviews looked. frontend/website/middleware.js was sending
script-src 'self' 'nonce-${nonce}' 'strict-dynamic'
over a site where every route is statically prerendered — next build marks all of /, /es, /products/* as ○ (Static) — and nothing in app/ calls headers(). So the per-request nonce never reaches the HTML, and strict-dynamic makes the browser ignore 'self'. Measured on a local next build && next start:
/ scripts total: 18 | with a nonce: 0
/products/wallet scripts total: 18 | with a nonce: 0
policy: script-src 'self' 'nonce-MGU2MmZjMzIt…' 'strict-dynamic'
Eleven src scripts and six executable inline ones, none nonced, all blocked. The marketing site would have loaded with zero JavaScript — no GSAP, no framer-motion, no three.js. Identical in kind to what you fixed for docs, so I applied the identical resolution: no nonce, script-src 'self' 'unsafe-inline', with a comment recording the measurement so nobody re-adds strict-dynamic without checking. Verified after the fix: script-src 'self' 'unsafe-inline'. (<script type="application/ld+json"> in components/JsonLd.tsx is a data block, not executed, so it isn't the reason for 'unsafe-inline' — Next's own self.__next_f.push(...) bootstrap is.)
Pushed to your branch as 607c3902, along with fd7367dc resolving the frontend/wallet/next.config.js conflict with main (which had added the NEXT_PUBLIC_COMMIT_SHA env re-export; both sides kept).
One note, not a blocker
ed40782 reformats all of app/layout.tsx to double quotes and semicolons — 77+/36− for a three-line change. I checked it rather than trusting it: tokenising both versions and diffing, the only differences are async, await, ??, the headers import and nonce={nonce}. Nothing else moved. But it's the same pattern that made #760 unreviewable, and here it meant reading a 113-line diff to confirm a 3-line fix. Worth turning the format-on-save off for this repo.
Dropping connect-src https: on the wallet and replacing it with an explicit host list remains the valuable part of this PR, and the host list holds up against what the app actually fetches client-side.
Merging. Thanks for the nonce plumbing — that was the right fix and it works.
Closes #725 Closes #720 Closes #719 Closes #701 Lands all four issues from Stanley471's fix/batch-701-719-720-725 via a local 3-way merge onto a moved base. 700 points across #725 (100), #720/#719/#701 (200 each). Conflicts resolved (6), five in favour of main: * .github/workflows/mobile-e2e.yml — main's setup-android@v4.0.4, which defaults to platform-tools, over the branch's v3.2.2 + explicit `packages`. Both fix the same "Failed to find package 'tools'" break. * frontend/mobile/app/swap.tsx — main's registry-driven tokensFor() (#877); the branch's TOKENS array would have reintroduced the per-module issuer table deleted for a bad-checksum mainnet USDY address. * frontend/wallet/app/layout.tsx — main's formatting plus the branch's BootnodeBanner import. #759's CSP nonce threading verified intact. * frontend/mobile/package.json — react-native-webview ~13.15.0 (the Expo SDK 54 pin) over ^14.0.1. * frontend/mobile/package-lock.json — reset to main's repaired lockfile, then regenerated with plain `npm install`. All three nested @react-native-async-storage/async-storage@1.24.0 entries intact (now under @reown/walletkit, @walletconnect/core and @walletconnect/utils); @walletconnect/web3wallet gone. `npm ci` from clean verified. * frontend/wallet/lib/privacy/client.ts — add/add, but the two files were unrelated: main's is the SPP client wrapper (#774, typed, privateSend), the branch's is a sponsored fee-bump submit helper (#725). Kept main's client.ts unchanged and moved the #725 helper to privacy/sponsoredSubmit.ts so neither was lost. Fixes made while landing, none of them from the author: * Wrong SPP pool contract id in 6 places (services/spp-bootnode/src/indexer.ts const + comment, .env.example, README x2, prover-spike.tsx, ProverWebView.tsx). CD3LA6RK… is the SDF test anchor's SEP-45 web-auth contract, not a pool — valid StrKey, real contract, wrong one. A bootnode pointed at it indexes zero events and serves an empty history, which is indistinguishable from "your notes are gone": the exact failure #719 exists to remove. Corrected to CBEDPYMA…, the block-list XLM pool already recorded in frontend/*/lib/privacy/config.ts from upstream deployments/testnet. * Added a StrKey.isValidContract startup guard on POOL_ADDRESSES so a malformed id fails loudly instead of silently indexing nothing. * BigInt('100_000_000') in prover-spike.tsx's BENCH_TX threw SyntaxError at module load — BigInt() parses the numeric-literal grammar, which has no separators — so the Prover Spike route could not open at all. No test imports that screen, so the suite was green. Now the literal 100_000_000n. * Bootnode fallback was wired to the banner but not the client: config.ts set bootnodeUrl unprobed, so with a dead bootnode the UI announced "using Nethermind's" while client.ts still used the dead URL. Now resolved through the same cached probe in initClient(), making the banner's claim true and #719's "falls back and says so" criterion actually hold. Added getConfiguredBootnodeUrl() to both config.ts files, returning null when unset, so the probe is skipped rather than health-checking Nethermind in order to fall back to Nethermind. * Banner re-probed every 5s (a debug interval) from the root layout; now 60s. * RPC URL leaks in the new service: the boot log printed RPC_URL verbatim into the host's logs, and the indexer stored raw error text in lastError, which /status serves publicly — and the Stellar SDK embeds the request URL in its errors. Provider keys live in the URL path, so both published a credential. Added src/redact.ts and applied it at both sites, keeping the origin and dropping the path. * ProverWebView's onShouldStartLoadWithRequest returned true unconditionally under a comment reading "Disable navigation — this is a pure computation surface". Policy was right, wiring was inverted. Now origin-based: local (bundle/loopback) allowed, every remote origin refused and logged by origin only. Origin rather than filename because Metro hashes asset names in release builds. * services/spp-bootnode had no package-lock.json, so the Dockerfile's two `npm ci` steps could not run — the documented Fly.io/Render deploy path was unbuildable. Generated one. * Reverted `npm ci || npm install` in ci.yml (both wallet and mobile jobs) to plain `npm ci`. That fallback turns a desynced lockfile into a warning, which is the recurring failure class this repo keeps paying for; it was a workaround for the broken lockfile on the branch's stale base, and the lockfile is now correct. * Dropped `new Core({ projectId }) as any` in mobile/lib/walletConnect.ts — the incompatibility it suppressed was an artifact of the stale lockfile, not a real mismatch. Typecheck clean without it. * PRIVACY_COST.md claimed opted-in diagnostics "can also report" fee and CPU instructions, present tense; nothing imports the helper, so no samples are collected. Reworded to state what exists, what it omits, and that wiring it in is follow-on work. #701's migration is a type-level swap only (IWeb3Wallet → IWalletKit, Web3Wallet.init → WalletKit.init). signXdrPayload() is untouched, so the host function, low-S normalisation, expiration ledger, footprint re-simulation, sequence and 5-element signature vector are all preserved. #701's acceptance criterion is a testnet round-trip and no transaction hash was supplied; that remains unverified and is recorded on the PR rather than assumed. SPP stays unaudited and testnet-only; the Nethermind fallback is testnet-only and mainnet remains locked out. services/spp-bootnode is undeployed, so nothing regresses on merge. No contracts touched, so expected-hashes.json is unaffected. Verified: mobile 88 suites/1039 tests, sdk 28/344, spp-bootnode 2/8, wallet tests + next build, all typechecks clean, `npm ci` from clean in frontend/mobile. Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Summary
Tightens the wallet's Content Security Policy and adds baseline security headers to the website and documentation apps.
Changes
unsafe-evalfrom the wallet CSP.connect-srcto the wallet's required RPC, Horizon, Wraith, and agent endpoints.X-Content-Type-Options,Referrer-Policy, andframe-ancestorsheaders to the website and docs apps.Validation
unsafe-eval.connect-srcuses an explicit endpoint allowlist.closes #705