Skip to content

security: tighten frontend CSP headers - #759

Merged
Miracle656 merged 5 commits into
Miracle656:mainfrom
Emelie-Dev:issue-705-tighten-csp
Oct 1, 2026
Merged

Miracle656 merged 5 commits into
Miracle656:mainfrom
Emelie-Dev:issue-705-tighten-csp

Conversation

@Emelie-Dev

@Emelie-Dev Emelie-Dev commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Tightens the wallet's Content Security Policy and adds baseline security headers to the website and documentation apps.

Changes

  • Removed unsafe-eval from the wallet CSP.
  • Replaced required inline scripts with nonce/hash-based CSP handling.
  • Restricted connect-src to the wallet's required RPC, Horizon, Wraith, and agent endpoints.
  • Added CSP, X-Content-Type-Options, Referrer-Policy, and frame-ancestors headers to the website and docs apps.
  • Verified the updated CSP across dashboard, send, receive, swap, and agent flows.

Validation

  • Confirmed the wallet CSP contains no unsafe-eval.
  • Confirmed connect-src uses an explicit endpoint allowlist.
  • Checked the wallet flows for CSP violations.
  • Verified the baseline security headers are returned by both the website and docs apps.

closes #705

@drips-wave

drips-wave Bot commented Sep 23, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

@vercel

vercel Bot commented Sep 23, 2026

Copy link
Copy Markdown

@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 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-dynamic for docs and keep script-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 _document and getServerSideProps, 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.tsx is a client component and lib/soroswap.ts calls the aggregator from the browser. Without this, every swap quote fails.
  • https://open.er-api.com — lib/currency.ts fetches 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 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.com and rpc.ankr.com are not needed — they live in lib/rpcFailover.ts, which only app/api/rpc/mainnet/route.ts imports, so those calls are server-side and never leave the browser.
  • global.transak.com goes through window.open(url, '_blank'), not a fetch, so it's unaffected.
  • ipfs.io is only resolveUri() building an image URL, and img-src allows https:.
  • stellar.expert is 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.

Emelie-Dev and others added 3 commits October 1, 2026 18:43
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 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Miracle656
Miracle656 merged commit a9a09a3 into Miracle656:main Oct 1, 2026
1 of 4 checks passed
Miracle656 added a commit that referenced this pull request Oct 2, 2026
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
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.

Tighten the wallet CSP and add headers to website and docs

2 participants