Skip to content

Umami is blocked by the CSP (missing nonce on the only script without one) — diagnose, then fix or remove #10

Closed

Description

@arpitdalal

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:

  1. reportOnly: true (server/index.ts:113-114, with a NOTE: 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.
  2. No reporting endpoint. The directives object contains only connect-src, font-src, frame-src, img-src, script-src, script-src-attr, and upgrade-insecure-requests. There is no report-uri or report-to, so violations are not sent anywhere at all. They exist only in the devtools console.
  3. No violation listener in the app. Grepping app/, server/, and tests/ for securitypolicyviolation / SecurityPolicyViolation returns nothing. Sentry is therefore not capturing CSP violations either — a securitypolicyviolation event 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 — introduced contentSecurityPolicy and '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' in app/utils/analytics.tsx and app/utils/analytics.client.tsx), not loaded as a cross-origin script, so script-src is irrelevant. Its api_host is the relative /resources/ingest (app/utils/analytics.client.tsx:5), same-origin, which connect-src 'self' permits.
  • Sentry is bundled too, and *.sentry.io is allowed in connect-src when SENTRY_DSN is set.
  • The inline window.ENV script at app/root.tsx:112-117 is 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:

  1. curl -sI https://arpitdalal.dev | grep -i content-security — confirm the policy actually ships and that reportOnly is set as expected.
  2. Load https://arpitdalal.dev with devtools open, Console filtered to "Content Security Policy". Expect a refusal naming the Umami URL and script-src. Note both the script-src refusal and, separately, a connect-src refusal once/if the script does load.
  3. 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)

  1. 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>
  2. Add the Umami origin to connect-src in server/index.ts:116-120 so the beacon is permitted:
    '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),
    Use a full https:// origin rather than a wildcard, so a compromised Umami domain cannot widen the policy.
  3. If question 2 confirms img-src is missing the Hashnode host, add it explicitly in the same PR.
  4. Keep reportOnly: true for 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-uri directive or a securitypolicyviolation listener that forwards to Sentry. Without one of these, this class of bug stays invisible and will recur.
  • npm run validate and npm run test:run pass.

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

  1. arpitdalal commented on Sep 27, 2026

    @arpitdalal
    OwnerAuthor

    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-src violations were happening and that the tracker has never loaded because of the missing nonce. But server/index.ts sets reportOnly: true, which means the policy is never applied — the browser receives Content-Security-Policy-Report-Only, reports violations, and loads everything anyway. Verified against production: curl -sI https://arpitdalal.dev returns only the report-only header, never an enforcing Content-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:

    1. 'strict-dynamic' genuinely does make 'self' inert for script-src in CSP3 browsers. That part of the reasoning is correct — it just has no effect while the policy is report-only.
    2. A report-only policy is indistinguishable from an enforced one when you read the source. Every directive reads as if it were binding. The issue's framing ("invisible today for the same reportOnly reason") is right about the invisibility and wrong about the consequence.
    3. cdn.hashnode.com is confirmed the real cover-image host, and *.cloudinary.com is genuinely used by the logo and project screenshots. Both belong in img-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.com in img-src. All three are required before reportOnly can 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.

    reportOnly stays true. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions