Zero of our 79 pages send a Content-Security-Policy, and the strict version would need 892 hashes
Our site sends six security headers on every response. Count them yourself: curl -sI https://pub-trivia.app/faq You get strict-transport-security, x-content-type-options, x-frame-options, referrer-policy, permis
Our site sends six security headers on every response. Count them yourself:
curl -sI https://pub-trivia.app/faq
You get strict-transport-security, x-content-type-options, x-frame-options, referrer-policy, permissions-policy and x-dns-prefetch-control. You do not get a content-security-policy, because there is no CSP anywhere in the 79 pages in our sitemap. I grepped the response headers of all 79: zero hits. I grepped the repository for the string: zero hits.
There is exactly one CSP on the whole origin, and we did not write it:
curl -sI 'https://pub-trivia.app/_next/image?url=%2Fdemo-1.webp&w=1200&q=75' | grep -i security
content-security-policy: script-src 'none'; frame-src 'none'; sandbox;
That is Next.js hardcoding a policy on its own image optimiser, on the sensible theory that a route which echoes bytes back at you should not be a script host. The framework protects its own route and leaves the other 79 to us.
So I went and priced the policy we have not written.
The inventory, over all 79 delivered pages
| thing a CSP cares about | count |
|---|---|
<script> tags with src
|
1,496 |
| of those, cross-origin | 0 |
of those, with integrity
|
0 |
inline <script> tags |
1,557 |
of those, application/ld+json
|
200 |
| of those, executable | 1,357 |
on* event handler attributes |
0 |
javascript: URLs |
0 |
<style> tags |
0 |
style attributes |
468 |
<iframe> |
0 |
<object> or <embed>
|
0 |
<base> |
0 |
<form> |
0 |
<img> with a data: or absolute URL |
0 of 77 |
Most of that table is good news. With no forms, no frames, no plugins, no <base> and no inline handlers, five directives are free and absolute:
object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'; frame-ancestors 'self'
That last one is worth a sentence on its own. We send x-frame-options: SAMEORIGIN, which is the 2009 spelling of frame-ancestors and which every modern browser treats as the weaker, less expressive version of a directive we do not send.
The 468 style attributes are the first real cost. They hold only 40 distinct values across the whole site, 25,278 bytes in total, and they are Tailwind utilities that could not be static (animation delays, computed widths). A nonce cannot help: nonces apply to elements, and a style attribute is not an element. The options are style-src 'unsafe-inline', or 'unsafe-hashes' plus 40 hashes, which is the directive whose own name tells you how the spec feels about it.
The 200 JSON-LD blocks need no hashes, and I checked rather than assumed
Our pages carry a lot of structured data, 200 blocks across the 79 pages, all of it <script type="application/ld+json">. My first count treated them as inline scripts needing hashes, which would have added 122 distinct hashes to the policy for no reason.
A data block is not a script. The HTML spec decides the script's type before it gets anywhere near the CSP check, and a block whose type is not a JavaScript type is never prepared for execution, so there is nothing for script-src to block. I wanted to see that rather than cite it, so I made a page with the tightest possible policy, one executable inline script and one JSON-LD block:
<meta http-equiv="Content-Security-Policy" content="script-src 'none'">
<script>window.__ran = 1;</script>
<script type="application/ld+json">{"@context":"https://schema.org","@type":"FAQPage"}</script>
I registered a securitypolicyviolation listener before the document loaded and read it back afterwards:
{
"violations": [ { "d": "script-src-elem", "s": "", "l": 4 } ],
"ran": false,
"scripts": 2,
"ldjsonParsed": "FAQPage"
}
One violation, on line 4, which is the executable script. The data block on line 5 produced nothing, stayed in the DOM, and still parses. So script-src 'none' is perfectly compatible with a page full of schema.org markup, which is a relief for anyone whose rich results depend on it. You can see the blocks on any content page of ours, the FAQ has three, by running this in the console:
[...document.querySelectorAll('script[type="application/ld+json"]')]
.map(s => JSON.parse(s.textContent)['@type'])
That leaves 1,357 executable inline scripts to account for, 12 to 27 per page. They are not ours in any meaningful sense: 79 are next-themes writing the theme class onto <html> before first paint, 79 are the (self.__next_f=self.__next_f||[]).push([0]) bootstrap, and the remaining 1,199 are the React Server Components flight payload, streamed into the document as a long series of __next_f.push calls. On this site that payload is the majority of the HTML by weight.
Option one: hashes
Hash every executable inline script, put the hashes in script-src, keep the pages static. The numbers:
| distinct sha256 hashes site-wide | 892 |
| hashes appearing on exactly one page | 830 |
| hashes appearing on all 79 pages | 2 |
| per-page policy header | 932 to 1,742 bytes, median 1,256 |
| all 79 per-page policies | 95,714 bytes |
| one global policy for every page | about 49 KB of header |
The two universal hashes are the theme script and the bootstrap. Everything else is flight payload, and flight payload is per page by definition, so a single site-wide policy is 49 KB of header on every response to cover 890 scripts of which one page needs 25. That is not a policy, it is a manifest.
Per-route policies at roughly 1.2 KB are entirely reasonable in size. The problem is the lifecycle. The flight payload contains the asset URLs, which contain the deploy id:
dpl_ appears 29 times inside the inline scripts of a single page
So every deploy rewrites almost every hash on the site, and the policy has to be generated during the build and attached per route. next.config.mjs headers() cannot do that: it is a static list written before the build output exists.
One thing I expected to be worse and is not. Two of our pages are rendered per request, and I assumed their hashes would be unstable. I fetched the homepage twice, two seconds apart:
bytes 170,437 vs 170,408 (different)
inline executable scripts 25 vs 25
hashes in common 25
The HTML differs, but only in two <meta> tags carrying Sentry trace ids. Every inline script hash was identical. So hashes are stable per request and unstable per deploy, which is exactly the opposite of the shape people assume when they reach for a nonce.
Option two: a nonce, which is not free on a static site
The documented Next.js approach is a nonce generated in middleware and attached to the request header, which the framework then stamps onto every script it emits. It works, and it is one file.
It also ends static rendering for every page it touches, because a nonce must be unique per response and a prerendered page is one response reused for days. 77 of our 79 pages currently answer x-nextjs-prerender: 1:
curl -sI https://pub-trivia.app/legal/cookies | grep -i prerender
I do not have to estimate what losing that costs, because the two pages that are already per-request are sitting right there. Four URLs down one warm connection, twice:
| page | rendering | TTFB run 1 | TTFB run 2 |
|---|---|---|---|
| /legal/cookies | prerendered | 70 ms | 84 ms |
| /faq | prerendered | 169 ms (cold connection) | 107 ms |
| /pricing | per request | 208 ms | 196 ms |
| the homepage | per request | 222 ms | 209 ms |
Prerendered pages answer from the edge in 70 to 107 ms. Per-request pages answer in 196 to 222 ms, because the render happens in a different continent from the visitor. A nonce-based CSP buys a real security property and charges roughly 130 ms of TTFB on 77 pages that currently do not pay it.
'strict-dynamic' does not get you out of it either. It covers scripts created by already-trusted scripts, but all 1,357 of these are parser-inserted, so each still needs its own nonce or hash.
What I am actually going to do
Be honest about the threat first. These 79 pages render no user input, submit no forms, hold no session, and load one third-party script. A CSP here is not stopping a stored XSS, because there is nowhere to store one. What it is for is limiting the blast radius of a compromised dependency, and that argues for the directives that are cheap and absolute, not for a heroic script-src.
So, in order:
- Ship the free half now, on all 79 pages, from
next.config.mjs:
content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self';
connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none';
frame-src 'none'; frame-ancestors 'self'
This is weak where script-src is concerned, and it still removes four whole classes of attack: no <base> hijack, no form exfiltration, no plugins, no framing, no loading images or fonts or CSS from anywhere but us. It costs nothing and breaks nothing.
Add
report-toand a report endpoint before tightening anything, because a policy with no telemetry is a policy you will discover is broken from a support email.Drop the one cross-origin script, which I wrote about separately and which turns out to render no element at all. With that gone,
script-src 'self'is true and the only remaining obstacle is our own framework's inline payload.Treat the signed-in app as a separate policy with a separate budget. It is already dynamic, so a nonce there costs nothing it is not already paying.
Everything above is checkable from outside with one curl and the console. The free tools are the easiest pages to watch, because they run real client-side JavaScript with nothing else going on: pick the round planner and run this:
const inline = [...document.querySelectorAll('script:not([src])')]
inline.filter(s => s.type !== 'application/ld+json').length // hashes this page costs
In the HTML we serve, that page has 16 inline blocks: 2 JSON-LD, which are free, and 14 that a strict policy would have to name.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.