DEV Community

Cover image for I Wrote Two CSPs, the One Being Enforced Isn't Mine
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

I Wrote Two CSPs, the One Being Enforced Isn't Mine

The cheapest way to audit security headers is to send the site a request and count the lines that come back. Mine come back like this:

curl -s -D - -o /dev/null https://mustafaerbay.com.tr/ | grep -i -E \
  '^strict-transport|^x-content-type|^x-frame|^referrer-policy|^permissions-policy|^content-security'
Enter fullscreen mode Exit fullscreen mode

Six lines. HSTS, nosniff, X-Frame-Options: SAMEORIGIN, Referrer-Policy, Permissions-Policy, and a Content-Security-Policy-Report-Only that runs to 562 bytes including its name. Reading the list feels good: the guy did his homework.

Then I actually read that sixth line. No report-uri in it. No report-to either. So I'm telling the browser "evaluate these rules and report violations to me" without giving it an address to report to. I checked the git history: that header has been live since 6 July 2026 (the nginx copy in the repo is a byte-for-byte mirror of the live file, and the line is identical on the server). Today is 6 November. For 123 days I have been asking for a report that goes nowhere.

This post is what came out when I pulled that thread. The conclusion: my blog carries two content security policies, both written by me, and neither blocks anything. The only policy actually imposed on the browser today is a third one — on a single path, /_uma/ — and I didn't write it.

Measure first: how many headers does each path get

The first rule of a header audit is not to read the configuration. Configuration tells you intent; what crosses the wire is the truth. So I requested each path and counted. All through the Cloudflare edge, on the morning of 6 November 2026:

Path Security headers received
/, /blog/, an article, /en/ 6
/feed.json, /rss.xml, /robots.txt 6
A non-existent address (404) 6
/api/popular 6
/admin/ and /_admin/analytics/ (401) 6
/_uma/ (Umami) 6 + Umami's own CSP
/sw.js, /_v 1 (nosniff only)
/_astro/*.js, /_astro/*.css 0

That last line surprised me. Every piece of JavaScript and CSS on the site is served from /_astro/, and responses from that path carry not a single security header. Not even nosniff — which, for a static asset, is precisely the most meaningful header on the list.

The cause isn't a typo, it's documented nginx behaviour — as in the post where I left sixty-eight nginx backups behind, what misled me here isn't the line I wrote but how the server reads it. nginx inherits add_header directives from the parent level only if there are no add_header directives at the current level; the docs phrase it as "if and only if". My location /_astro/ block contains exactly one add_header Cache-Control line. That one line drops all six server-level headers. In the /sw.js and /_v blocks I had written nosniff out again, which is why one header survives there — meaning I noticed the trap somewhere and patched it by hand, without generalising the reason.

In location / I include the header file a second time. So I had learned this rule before: learned it, rescued that block, and never looked at the four others.

The server runs nginx 1.30.0. That means add_header_inherit, added in 1.29.3, is available to me: with the merge parameter a nested level can add its own header and still keep the ones from above. Instead of writing the missing headers out block by block — six lines each for /_astro/ and @astro_prev, five each for /_v and /sw.js, which already carry nosniff — one directive reopens the inheritance. I didn't change it during this run; I don't touch live nginx while writing an article.

I asked for a report and left out the address

Now the main event. What does Content-Security-Policy-Report-Only actually do? MDN is clear: it exists to monitor violations without enforcing the policy, and the report-to directive must be specified for reports to be sent — if it isn't, in MDN's own words, the operation won't have any effect.

My policy has neither report-to nor its older sibling report-uri. The spec agrees on direction: CSP Level 3 treats report-uri as deprecated in favour of report-to. There is a third channel for collecting violations — the browser fires a securitypolicyviolation event in the page for each one. That channel came out empty as well: zero lines in my source tree listen for it.

Three channels, all closed. This header has been evaluated in my readers' browsers for 123 days, violations have been detected, and the result has been told to nobody. The browser isn't at fault; it did exactly what it was asked. The missing part is on my side.

I found exactly one sentence in my own defence: the config says the header is there "for visibility". The measuring half is in place; the reading half never got written. The difference between posting a sentry and hearing from one showed up the same way before on this very blog.

Suppose I did enforce it: what would I gain

Adding a reporting endpoint is half an hour of work. But what would I actually gain by switching from Report-Only to a real Content-Security-Policy?

The policy's script line begins like this: script-src 'self' 'unsafe-inline' 'unsafe-eval' …. MDN has a direct warning for that keyword: 'unsafe-inline' defeats much of the purpose of having a CSP. So in enforce mode, an injected inline script would not trip my policy. What I ship to browsers today is, against XSS, mostly set dressing.

That keyword isn't there by whim. I counted the live HTML of my 4 November article: 44 executable inline script blocks with no src, 124,677 bytes of UTF-8. (There are also 4 ld+json data blocks, 12,113 bytes; they aren't executed, so they stay out of this count.) The real split is inside those 44: only 3 are type="module", the bundles Astro compiles and emits; the remaining 41 are is:inline blocks I wrote by hand — theme switch, reading progress, QR, comment form, Turnstile. Remove 'unsafe-inline' and my site breaks on my own code, not on Astro's.

The policy's third flaw is list maintenance. At first glance I counted the five hosts on the script-src line; then I parsed the whole policy and found seven distinct third-party hosts — I had skipped the ones on connect-src and frame-src. My own post's thesis, confirmed by my own reading: when you count, count the whole list. I grepped all seven against the code:

  • cdn.jsdelivr.net → Mermaid 11.16.0; before this post, 411 Turkish articles and their 411 English twins pull it in. In use.
  • challenges.cloudflare.com → Turnstile in the comment form. In use.
  • u.mustafaerbay.com.tr → the Umami counter. In use.
  • static.cloudflareinsights.com → not one reference in the code. Stale.
  • cloudflareinsights.com (connect-src) → likewise absent. Stale.
  • embed.bsky.app → not one reference in the code. Stale.
  • bsky.app (frame-src) → present in the code, but only as a profile link; nothing ever frames it. Stale.

Four out of seven are stale. On top of that, the policy's frame-src line has no www.youtube-nocookie.com, while the repository holds a YouTube.astro component. No post imports it right now — I checked, zero. So this isn't a bug, it's a trap laid in advance: the day I switch to enforce, the first post that embeds a video shows an empty frame, and does it without an error. The question to ask while writing a policy is: who will update this list when I add the next feature?

Credit where due: everything in the policy other than 'unsafe-inline' does work. object-src 'none' closes off plugin embedding, base-uri 'self' stops an injected <base> tag from hijacking every relative URL, and form-action 'self' prevents the form from being POSTed to someone else's server. All three operate independently of inline scripts; so enforcing isn't "no benefit at all", it's "most of the benefit I imagined isn't there".

Then there's 'unsafe-eval', and its story is worse. I went looking for where it came from: it first appears in the 8 May Astro attempt, and the line in that commit states the reason — "for hot reload". A dev server's need. When the nginx policy was written on 6 July, the same keyword was copied over verbatim, and it has been on production responses ever since. A permission opened for a development reason has been living in production for four months, unquestioned.

One thing I tried to measure and then gave up on: what these headers cost. The six lines, names and values together, total 809 bytes and go out with every response; but HTTP/2 compresses headers with HPACK and keeps them in a table for the life of the connection, so the same 562-byte CSP is not re-sent from scratch on every request. So I did not derive a "this many MB per day" figure — deriving one would mean computing something I never measured.

The second policy: left over from a migration

There is a file called public/_headers in the repository. Inside it sits a complete, enforcing CSP: frame-ancestors 'none', plus script-src-elem, X-Frame-Options: DENY, HSTS with includeSubDomains; preload, and an X-XSS-Protection (the header browsers shelved years ago).

My first reaction was "I wrote this at the wrong layer", because _headers is a format read by Cloudflare Pages and Netlify, while my site is a Node process behind nginx on a VPS. nginx does not read that file, and there is no mechanism waiting to. It still gets published — but only as a file: I requested https://mustafaerbay.com.tr/_headers and got 200.

The git history tells a better story, though, and it refutes my first reaction. For its first month the blog ran on Cloudflare Pages; that era's workflow is literally named Deploy to Cloudflare and sets CF_PAGES: "1" in its environment. Pages does read _headers. So that CSP was live and was being enforced. The proof is in the same file's history: commit 3cc086b4, dated 1 April 2026, is titled "update CSP to allow Turnstile, QR API, and Mermaid CDN" — the policy really was blocking things, and I allowlisted them one by one. When the site moved to the VPS on 20 April, Pages went away and that policy fell out of force without anyone noticing.

That makes the file more dangerous, not more innocent: what sits in front of me isn't an untested draft, it's a rule set that once worked and got left behind in a migration. The api.qrserver.com line wasn't "one of them thought it necessary" either — it was found necessary by measurement, on the day. I described the same wrong-layer reflex at the header level in the HSTS preload post; here I caught it again, in my own file.

Put the two policies side by side and the differences show up:

Topic public/_headers (Pages era) nginx (today, report-only) What is it?
Framing X-Frame-Options: DENY + frame-ancestors 'none' SAMEORIGIN, no frame-ancestors deliberate relaxation
HSTS includeSubDomains; preload plain max-age=31536000 deliberate relaxation
QR service api.qrserver.com in connect-src absent forgotten
Element scope script-src-elem written out separately absent forgotten

I was going to write the first two rows up as "two different mindsets" — then I read the first two lines of the nginx file itself. The reasoning is written right there: includeSubDomains/preload were deliberately left out (subdomains, and the risk of not being able to back out), and frame-ancestors isn't on the list either. So these aren't a contradiction; they are a relaxation made on 3 June with a written rationale. I left myself a note, then forgot that I had left a note — which is a more common failure than contradiction.

The bottom two rows really were forgotten. The QR case is instructive: the component calls api.qrserver.com via img.src, and because the policy's img-src 'self' data: https: allows any https image, nothing breaks today. Today's silence comes from a coincidence, not from a decision.

Diagram

The only policy being enforced: Umami's

Measuring the /_uma/ path showed me something I didn't expect. Next to the six headers, separate from my report-only one, comes a real enforced Content-Security-Policy: default-src 'self', frame-ancestors 'self' and the rest. That isn't my line; it comes out of the Umami application itself, and all I did was put a proxy_pass in front of it.

One sentence sums up the situation: the only content security policy genuinely imposed on browsers on my own domain was written by someone else. I have two policies — one better on paper but left behind in a migration, the other sent with every response and doing nothing. The only working example today is a default from a package I installed.

The lesson isn't "Umami did a nice job" — that policy isn't tight either. Read the lines and you see it: connect-src 'self' https: permits effectively every HTTPS origin, which is wider than my own policy at that point, and frame-ancestors 'self' doesn't close framing, it just limits it to the same origin. The one place Umami genuinely narrows is script-src: no third-party hosts at all, just 'self' plus inline. That's where the lesson is. It enforced what was enforceable and let the rest go; my policy writes everything down and enforces none of it.

Having two CSP headers on one response creates no conflict, by the way. CSP Level 3 allows multiple policies to apply to one resource and requires each to be enforced separately; MDN sums it up as additional policies only being able to restrict further. The result is an intersection: every enforcing policy has to allow a resource, while a report-mode policy never blocks. On /_uma/, my header has no power to widen or narrow Umami's policy. Two headers, one effect.

The inverse of that rule is useful: when moving to enforce, you can keep a tight policy at the proxy and leave the application's own policy alone. I'm not doing that; I only learned, by measuring, that it's possible.

Why I abandoned enforce twice

The first one is above: the enforcing policy on Pages fell away by itself when the server changed on 20 April. I wasn't even aware I had dropped it — which still counts as dropping it.

The second was deliberate, and the git history narrates it commit by commit. On 8 May 2026 there are six commits touching CSP; four of them tune the policy directly. In order: my own scripts and API calls were allowlisted in Astro's CSP (a0bb77bd), the setting moved from Astro 6's experimental namespace to the top level (af5f6d62), a separate permission was added for Turnstile (dd3de166), three console errors were cleaned up (546d99df), and finally enforce was removed altogether (183fdd36). So I spent an evening adding permissions and chasing errors, then gave up. astro.config.mjs still carries that day's note: Astro's automatic hashes weren't catching MDX inline scripts and styles or dynamic islands, and in enforce mode Astro's own scripts were being blocked.

Today I opened the code of the Astro 7.3.5 I actually run and looked at the reason. The trackScriptHashes function in node_modules/astro/dist/core/csp/common.js generates hashes from these sets: scripts Astro inlined itself (internals.inlinedScripts), client directives, built client .js/.mjs files, integration scripts at the head-inline/before-hydration stages, and the island runtime. All things the build processed.

is:inline, by definition, enters none of those sets. Astro's directive reference says it plainly: a tag marked is:inline is copied into the output as-is, and its contents are not processed, optimised or bundled. Content that isn't processed doesn't get hashed. I counted is:inline usage in the .astro files of my source tree: 71 places, 11 of them inside BaseLayout.astro alone. The Turnstile tag in the comment component is is:inline too, and it carries an external src.

So the 8 May breakage wasn't a configuration error; it was an architectural mismatch. Astro's security.csp option (added in Astro 6.0.0 per the docs, and part of the base configuration today) emits the policy as a <meta> element and only knows about the scripts it generated. The docs do point at an escape hatch: you can supply hashes yourself (scriptDirective.hashes), and the installed version's code does read that field. But for 41 hand-written blocks that means a hash list recomputed on every deploy — not impossible, not free either.

Delivering it via <meta> has a second consequence I noticed late: the spec does not support report-only inside a <meta> element. report-uri, frame-ancestors and sandbox are on the same unsupported list. So the moment I pick Astro's route, I lose the luxury of "collect reports first, tighten later" — the only option is to enforce directly. That is largely the technical reason I broke and repaired the site across six commits in one day in May: there was no trial mode, and the only test rig in the field was production itself.

Three roads are open: turn the inline scripts into nonces (which wants every response rendered individually — expensive for a CDN-cached site), generate a hash list at build time (the realistic one for a static site), or move to strict-dynamic. Whichever it is, the reporting endpoint has to stay in nginx.

Five questions to ask your own site

This whole audit is read-only commands; nothing has to be changed to run it. If you want to repeat it on your own site, in order:

  1. What am I sending? Start with curl -s -D - -o /dev/null https://yoursite/ and see that the header really arrives. The config file is not evidence.
  2. Does it reach every path? Home page, a static asset, a 404, the feed, the admin panel if you have one — measure each separately. On nginx, a single add_header line in a nested block drops every header from above.
  3. If it's report-only, who collects the reports? With no report-to or report-uri in the policy, that header is not a monitoring tool, it's decoration. Careful: report-to does not take a URL, it takes the name of a group declared in a separate Reporting-Endpoints response header — adding report-to to the policy alone will not break the silence. Keep report-uri for older browsers, or listen for securitypolicyviolation in the page and write it down somewhere.
  4. What does the policy actually block? If script-src has 'unsafe-inline', there is no protection against inline injection. Count the real benefit your policy provides (object-src 'none', base-uri, form-action, frame-ancestors) separately.
  5. Whose job is the allowlist? Grep every host in the list against your code. Delete the stale ones, add the missing ones, and decide now whose task it will be to update that file when a feature lands.

Conclusion

Security headers are the easiest layer to audit and the easiest to fool yourself about. Six lines come back, you glance at the list and move on; headers prove their existence, not their effect. Whether a policy is doing any work is written not in the policy text, but in which path it reaches, in which mode, and reported to whom.

In my case the picture is this: the header list I have been collecting since 20 April is sound, its coverage is half-missing because one add_header line cut the inheritance, the policy that once genuinely worked was left behind when the server changed, its replacement has been asking for a report nobody reads for 123 days, and the only policy enforced today comes from a package I installed. Not one line is wrong — it's just that none of them know about each other.

I'm leaving two concrete tasks, both for myself:

  • public/_headers will either be deleted or moved to the layer that is genuinely applied; a second policy text with no counterpart in production only misleads me.
  • The report-only CSP will either get a real reporting endpoint — a Reporting-Endpoints header plus a report-to in the policy naming that group, with the reports written somewhere — or the header will be removed outright. On 13 November 2026 I will measure which one I did and write it up.

There's a third task, but I'm not promising it, because it's long: moving to enforce mode means producing a hash or a nonce for every inline script I wrote by hand. The material for that post is 41 blocks and a deploy pipeline.

Official Sources

Top comments (0)