This is the last of three posts about rebuilding the Nevitech website. Part 1 was the twelve month story, Part 2 was output caching. This one is the problem the two of them create when you put them together, and how we fixed it.
Search for this and most of the answers tell you to pick one. You can have a strict Content Security Policy with nonces, or you can have output caching, but not both. You can have both. It takes two pieces and they both have to be there.
The problem
A nonce is a random value generated per request. It appears twice, once in the Content-Security-Policy response header and once as an attribute on every inline script and style you want the browser to run:
Content-Security-Policy: script-src 'self' 'nonce-Xf9k2...'<script nonce="Xf9k2...">The browser compares the two. Match and the script runs. No match and it does not, silently, with a console error nobody reads.
Now add output caching. The cache stores the rendered HTML, which contains whatever nonce was current when the cache was populated. The header is regenerated per request, so it contains a fresh one. Every cache hit therefore serves a page whose script tags carry a nonce that does not match its own header, and the browser blocks the lot.
The failure is unpleasant because it is intermittent. The first request after a cache clear looks perfect. It is the second one that breaks, and if you are testing with the cache off, which you probably are, you will never see it.
The idea
The fix is to stop putting the real nonce into the HTML in the first place.
Views render a literal placeholder instead:
<style nonce="{nonce_token}">@Html.Raw(criticalCss)</style>
<script nonce="{nonce_token}">
// deferred stylesheet swap
</script>That string is what gets cached, because it is what the view produced. It is inert and identical for every visitor, so it is safe to store.
Middleware sitting outside the cache then swaps the placeholder for the real per request nonce on the way out. Cached response or not, every visitor gets a fresh nonce in the body.
The middleware
The middleware wraps the response stream, reads the finished HTML, and does a straight string replace using the nonce from the CSP library:
var nonceReplacementService = context.RequestServices
.GetRequiredService<INonceReplacementService>();
if (nonceReplacementService.ContainsNoncePlaceholders(body))
{
body = nonceReplacementService.ReplaceNoncePlaceholders(body);
}There is a bit more to it than that in practice. It skips /umbraco/ entirely, so the back office is untouched. It skips anything with a static file extension, so images and stylesheets never get buffered. It checks the content type and only processes text/html. And if anything at all goes wrong it logs the error and copies the original response through unchanged, because a page with a broken nonce is better than no page.
The header
The body is only half the job. On a cache hit the stored response can bring its own Content-Security-Policy header with it, and that header contains the old nonce.
So the cache policy rewrites it on the way out. In ServeFromCacheAsync it hooks OnStarting, pulls the current nonce, finds any 'nonce-...' value in the header and replaces it:
context.HttpContext.Response.OnStarting(() =>
{
var nonce = cspNonceService.GetNonce();
if (!string.IsNullOrEmpty(nonce) &&
httpContext.Response.Headers.TryGetValue("Content-Security-Policy", out var cspValues))
{
httpContext.Response.Headers["Content-Security-Policy"] =
ReplaceNonceInCspHeader(cspValues[0], nonce);
}
return Task.CompletedTask;
});Both halves are required. Fix the body and the header still disagrees. Fix the header and the body still disagrees. It is the sort of problem where each fix on its own makes it look like the approach does not work, which I suspect is why most people conclude that it cannot be done.
Order is important
The nonce middleware has to run before the output cache middleware, so that it is wrapping the response for cached and uncached requests alike. Get it the wrong way round and cached responses go straight out without ever passing through the replacement.
It is two lines and a comment, and the comment is there because I will not remember why in a year:
// CRITICAL: Nonce replacement must come BEFORE output cache
// This allows the middleware to wrap the response stream and replace nonces
// in both the HTML body and CSP headers for all responses (cached and non-cached)
u.AppBuilder.UseNonceReplacement();
u.AppBuilder.UseOutputCache();The bit nonces cannot fix
While we are here, one thing that comes up on every Umbraco 15 or later build with a strict policy.
The rich text editor writes inline style="" attributes into the markup. A nonce can never cover an inline attribute, only a tag, so no amount of nonce plumbing will help. Blocking them means editor formatting silently disappears on the front end.
The narrow fix is style-src-attr, which applies to style attributes only and leaves style-src fully nonce protected:
style-src-attr 'unsafe-inline'It is not perfect, but it is a great deal better than dropping 'unsafe-inline' into style-src and giving up on style nonces entirely.
The rest of the policy starts from allowing nothing at all and adds back only what is needed, and it is suppressed on back office paths so it does not fight the editor.
What it costs
Two honest downsides.
The middleware buffers the response into memory to do the replacement. For a marketing website with normal page sizes that is fine. For very large responses, or a website streaming output, it is not free and you would want to measure it.
The other cost is that it is one more piece of custom infrastructure that a future developer, quite possibly me, has to understand before changing anything near the pipeline. Hence the comment.
Testing it
Do not trust it because it looks right. Request a page that you know is cached, three times in a row, and check that the nonce in the header and the nonce in the HTML are different every time and match each other every time.
I did exactly that. Three requests, three different nonces, each matching its own header, every response served from cache. Then I turned it off to be sure the test would have caught the failure, which is the step people skip.
Was it worth it
For this website, arguably not. As I said in Part 2, we did not need page caching at all.
As a piece of knowledge, definitely. Strict CSP is becoming a normal client requirement, and output caching is the obvious first move on a slow website. Sooner or later somebody is going to want both, and the standard advice is to give one of them up.
You do not have to. It is a placeholder, a bit of middleware, a header rewrite and two lines in the right order.