Repository navigation
Umami is blocked by the CSP (missing nonce on the only script without one) — diagnose, then fix or remove #10
Description
Umami has almost certainly never loaded, because it is the only <script> on the page without a nonce and the CSP uses 'strict-dynamic'. It is invisible today because the policy is reportOnly and nothing reports violations anywhere.
This issue is primarily a diagnosis. Do not start by changing code — start by confirming whether Umami is actually broken, and by deciding whether it should exist at all. Both answers change what the fix is.
What the code says
The script tag has no nonce
app/root.tsx:110-127, inside Document:
<body className="bg-background text-foreground">
{children}
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(env)}`,
}}
/>
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
<script
async
defer
src={`https://${env.UMAMI_DOMAIN}/${env.UMAMI_SCRIPT_NAME}`}
data-website-id={env.UMAMI_WEBSITE_ID}
data-domains={env.UMAMI_DOMAINS}
></script>
</body>Every other script on the page carries nonce={nonce} (lines 113, 118, 119) and so does <ClientHintCheck nonce={nonce} /> in the <head> at line 104. The Umami tag at lines 120-126 does not. nonce is already in scope in this component, so this is an oversight rather than a limitation.
The policy cannot permit it
server/index.ts:124-129:
'script-src': [
"'strict-dynamic'",
"'self'",
(req) => `'nonce-${req.headers['x-csp-nonce']}'`,
],
'script-src-attr': [(req) => `'nonce-${req.headers['x-csp-nonce']}'`],Per CSP Level 3, when 'strict-dynamic' is present, host-source expressions in the list are ignored by CSP3-compliant browsers. That includes 'self'. Trust propagates only from scripts carrying a valid nonce or hash, plus anything those scripts load.
So 'self' on line 126 is not doing anything for script-src at all. A third-party script with no nonce is not permitted.
For completeness: browsers that do not support 'strict-dynamic' ignore that keyword and fall back to the host list. 'self' still does not cover the Umami origin, so the outcome is the same either way.
The beacon would be blocked too, even if the script loaded
server/index.ts:116-120:
'connect-src': [
MODE === 'development' ? 'ws:' : null,
process.env.SENTRY_DSN ? '*.sentry.io' : null,
"'self'",
].filter(Boolean),Umami's tracker POSTs pageviews to https://<UMAMI_DOMAIN>/api/send, which is cross-origin. connect-src allows only 'self', conditionally *.sentry.io, and ws: in dev. The Umami domain is not in the list.
This one needs process.env.UMAMI_DOMAIN threaded into the middleware. That is available at the point server/index.ts builds the policy, because index.js runs import 'dotenv/config' as its first statement before importing the server.
Why nobody noticed
Three independent reasons, all of which must be true for this to stay hidden:
reportOnly: true(server/index.ts:113-114, with aNOTE: Remove reportOnly when you're ready to enforce this CSP). Violations are reported, not blocked. The script tag fails to load, the browser logs a warning, and the page carries on.- No reporting endpoint. The
directivesobject contains onlyconnect-src,font-src,frame-src,img-src,script-src,script-src-attr, andupgrade-insecure-requests. There is noreport-uriorreport-to, so violations are not sent anywhere at all. They exist only in the devtools console. - No violation listener in the app. Grepping
app/,server/, andtests/forsecuritypolicyviolation/SecurityPolicyViolationreturns nothing. Sentry is therefore not capturing CSP violations either — asecuritypolicyviolationevent listener would be needed for that, and there isn't one.
It has been broken since it was introduced
git log -S gives a clear ordering:
c4f1db1 chore(project): Initialize project— introducedcontentSecurityPolicyand'strict-dynamic'f28ea49 feat(analytics): integrate Umami for website tracking— added the script tag later
So Umami was added on top of an already-strict CSP and never had a nonce. This is not a regression from a later change; it has never worked.
What is not affected
Worth stating so the fix stays scoped:
- PostHog is fine. It is bundled (
import { posthog } from 'posthog-js'inapp/utils/analytics.tsxandapp/utils/analytics.client.tsx), not loaded as a cross-origin script, soscript-srcis irrelevant. Itsapi_hostis the relative/resources/ingest(app/utils/analytics.client.tsx:5), same-origin, whichconnect-src 'self'permits. - Sentry is bundled too, and
*.sentry.iois allowed inconnect-srcwhenSENTRY_DSNis set. - The inline
window.ENVscript atapp/root.tsx:112-117is nonced correctly.
Before you change anything: confirm the diagnosis
The analysis above is derived from reading the code and the CSP3 spec, not from observing the browser. Verify it.
The decisive test: open the Umami dashboard and check the timestamp of the most recent pageview. If it is stale — or if it only ever shows traffic that cannot be explained by direct hits — Umami is confirmed broken. This is the fastest and most conclusive check, and it needs no code changes.
Supporting checks:
curl -sI https://arpitdalal.dev | grep -i content-security— confirm the policy actually ships and thatreportOnlyis set as expected.- Load
https://arpitdalal.devwith devtools open, Console filtered to "Content Security Policy". Expect a refusal naming the Umami URL andscript-src. Note both thescript-srcrefusal and, separately, aconnect-srcrefusal once/if the script does load. - Network tab: confirm no request is ever made to the Umami script URL.
Open questions — these determine the fix
1. Do you actually want two analytics tools?
This site runs both PostHog (app/utils/analytics.client.tsx, plus usePosthogPageView in app/utils/analytics.tsx) and Umami. All five UMAMI_* variables are populated in .env, and UMAMI_PUBLIC_ANALYTICS_URL is surfaced as an "Analytics" link in the footer (app/root.tsx, the footerOnlyExternalLinks list).
The plausible reason for keeping Umami is that it offers a public, shareable analytics page, which is what UMAMI_PUBLIC_ANALYTICS_URL is for. If that is the reason, Umami is load-bearing on intent even though it is broken on reality, and the right fix is Option B below. If it is not the reason, Umami is dead weight and the honest fix is Option A.
This is a product decision, not a technical one. Make it explicitly.
2. What is *.cloudinary.com in img-src doing?
server/index.ts:123:
'img-src': ["'self'", 'data:', '*.cloudinary.com'],This repo does not appear to use Cloudinary. The remote images that are rendered are Hashnode coverImage.url values, fetched via the GraphQL API at gql.hashnode.com (app/graphql/gql.ts:5, app/routes/resources+/newsletter.tsx:39) and passed to CardImage in app/components/card.tsx:44 via app/components/notes.tsx:82 and app/components/blog-posts.tsx. Hashnode serves those from its own CDN, which is very unlikely to be *.cloudinary.com.
So there is a strong chance img-src is missing the Hashnode image host, and that cover images on the blog and notes pages are silently failing to load — currently invisible for the same reportOnly reason. Verify by checking the actual host of a coverImage.url from the API and by looking at the Network tab for blocked image requests.
If confirmed, this is a second latent bug in the same policy and should be fixed in the same pass. It is in scope for this issue on that basis.
3. If you fix the nonce, what else surfaces when you enforce?
Turning reportOnly off is the natural end state, and it will immediately enforce every directive in the policy, including whatever is wrong with img-src above. That is the point, but it means the enforcement flip should be its own commit, after the violations are known and fixed — not bundled into the nonce fix.
Note the ordering constraint: reportOnly can only come off once violations are actually observable, which means adding reporting (report-to/report-uri) or a securitypolicyviolation listener first. Otherwise you are flipping enforcement on blind.
Options
Option A — Remove Umami
Delete the script tag from app/root.tsx:120-126, drop the UMAMI_* entries from app/utils/env.server.ts's getEnv() and from envSchema in app/utils/schemas.ts, remove them from .env.example and from Fly secrets, and replace the footer's "Analytics" external link (footerOnlyExternalLinks, which falls back to UMAMI_PUBLIC_ANALYTICS_URL) with whatever PostHog equivalent you want or nothing.
Leaves PostHog as the single analytics tool. Simplest end state, and the CSP needs no change at all.
Choose this if you cannot answer question 1 with a reason to keep Umami.
Option B — Fix Umami (recommended if you want the public analytics page)
- Add the nonce to the tag in
app/root.tsx:<script nonce={nonce} async defer src={`https://${env.UMAMI_DOMAIN}/${env.UMAMI_SCRIPT_NAME}`} data-website-id={env.UMAMI_WEBSITE_ID} data-domains={env.UMAMI_DOMAINS} ></script>
- Add the Umami origin to
connect-srcinserver/index.ts:116-120so the beacon is permitted:Use a full'connect-src': [ MODE === 'development' ? 'ws:' : null, process.env.SENTRY_DSN ? '*.sentry.io' : null, process.env.UMAMI_DOMAIN ? `https://${process.env.UMAMI_DOMAIN}` : null, "'self'", ].filter(Boolean),
https://origin rather than a wildcard, so a compromised Umami domain cannot widen the policy. - If question 2 confirms
img-srcis missing the Hashnode host, add it explicitly in the same PR. - Keep
reportOnly: truefor now. Do not flip it in this PR.
Option C — Self-host or proxy the tracker
Serving the tracker from your own origin (or proxying it through a route) removes the cross-origin script entirely and simplifies the policy, at the cost of giving up Umami's managed updates and adding a caching/versioning concern. Worth considering only if the nonce route proves awkward. Mentioned for completeness — probably not worth it here.
Acceptance criteria
- The Umami dashboard shows current pageviews, or Umami is fully removed per Option A. One or the other, confirmed empirically — not assumed.
- No Content Security Policy violations in the devtools console on
/,/talks,/uses,/contact. - Cover images on the blog and notes pages actually load (this covers question 2 either way — confirm or rule out).
- If violations are now possible to observe, they are: either a
report-to/report-uridirective or asecuritypolicyviolationlistener that forwards to Sentry. Without one of these, this class of bug stays invisible and will recur. npm run validateandnpm run test:runpass.
Tests
tests/server.smoke.test.ts already has a CSP section worth extending — see lines 199-242, which cover per-request nonce generation and confirm a client-supplied x-csp-nonce is ignored (a real injection defence, keep it). Add assertions that the policy contains the Umami origin in connect-src when UMAMI_DOMAIN is set, and that it does not when it is unset.
Related
This overlaps with #9 Task 2 (moving CSP out of Express via @nichtsam/helmet). That task's write-up already flags this nonce problem as something it would surface. If you fix it here first, update that section of #9 to point here. They should not be done in the same PR.
Activity
fix(csp): capture parse-time violations before monitoring loads
docs(csp): correct the record — reportOnly never blocked anything
arpitdalal commentedon Sep 27, 2026
Diagnosed, and the diagnosis needed correcting. Summary of what shipped in #18.
The central claim in this issue is wrong. The issue says Umami
script-srcviolations were happening and that the tracker has never loaded because of the missing nonce. Butserver/index.tssetsreportOnly: true, which means the policy is never applied — the browser receivesContent-Security-Policy-Report-Only, reports violations, and loads everything anyway. Verified against production:curl -sI https://arpitdalal.devreturns only the report-only header, never an enforcingContent-Security-Policy.So the tracker has been loading and reporting all along, the beacon was never blocked, and the Hashnode cover images were never refused. Nothing is broken today.
The traps worth recording, since they are what made the analysis in this issue so confident:
'strict-dynamic'genuinely does make'self'inert forscript-srcin CSP3 browsers. That part of the reasoning is correct — it just has no effect while the policy is report-only.cdn.hashnode.comis confirmed the real cover-image host, and*.cloudinary.comis genuinely used by the logo and project screenshots. Both belong inimg-src; the policy had only the latter.What landed anyway, because it is the prerequisite rather than the fix: the nonce on the tracker tag, the Umami origin in
connect-src, and*.hashnode.cominimg-src. All three are required beforereportOnlycan come off, and all three are live breakage the moment it does.Also landed: CSP violations are now captured by an inline nonced script as the first child of
<head>and forwarded to Sentry. Registering the listener from the dynamically imported monitoring module was too late — parse-time violations for parser-discovered resources fire before it loads. That is the reporting capability this issue's acceptance criteria asked for, and it is what makes the eventual enforcement flip safe rather than blind.reportOnlystaystrue. A smoke test now asserts the enforcing header is absent, and it was verified to fail if someone flips the flag, so the flip cannot happen silently. The NOTE at the enforcement site was rewritten to say why the ordering matters.Not verified, and I cannot verify it: whether the Umami dashboard is actually receiving pageviews. That is still the open empirical question from this issue, and it is worth answering before deciding to keep Umami at all — see the open question 1 about running two analytics tools.
fix(csp): stop CSP reporting from recursing through Sentry
fix(csp): make the policy correct enough to enforce, and make its vio…
fix(zod): skip the eval probe so CSP stops reporting a swallowed throw