The OMVP blog performance leaderboard has made a comeback, and with it there are some new metrics it evaluates.
For all of these this blog already does what it needs to except for one. Letās fix that.
You can ignore the reported āerrorā with the CSP, the evaluator tooling doesnāt yet recognise WASM headers š
What is SRI?
Subresource Integrity (SRI) is a security feature that enables browsers to verify that resources they fetch (for example, from a CDN) are delivered without unexpected manipulation. It works by allowing you to provide a cryptographic hash that a fetched resource must match. source
Given that this entire blog is hosted on the CDN you could argue SRI doesnāt make much of a difference here, but I donāt like the bad score so we might as well fix it.
So far Astro has made it very easy to do things the right way anyway.
Hello Astro-Shield
Ta-da thereās a magic package that can do all the heavy lifting for us called Astro-Shield š
It will automatically scan all statically generated pages and compute the hashes for all scripts and styles it finds, and then generate a nice CSP header for us to allow these hashes on our pages. Easy-peasy.
Err, well, no.
That would be the case if I used Netlify or Vercel, but Iām a Cloudflare man so this is a bit of a problem.
Thereās an open issue on their Github page to call for funding to add CF Pages header support, but I donāt quite have the time to dive into all the specifics of how to do it properly.
However, I have a good understanding of what the correct SRI CSP header should be and Iām equipped with Cline - previously Claude Dev in my VS Code so lets have the AI figure this one out for us.
The Solution
The Astro-Shield documentation gives a brief example of how to output the SRI hashes to a file so it can be picked up by other modules, that seems like a good start.
// file: astro.config.mjs
import { resolve } from 'node:path';
import { defineConfig } from 'astro/config';
import { shield } from '@kindspells/astro-shield';
const rootDir = new URL('.', import.meta.url).pathname;
const modulePath = resolve(rootDir, 'src', 'generated', 'sriHashes.mjs');
export default defineConfig({
integrations: [
shield({
sri: { hashesModule: modulePath },
}),
],
});
Now we have all the SRI hashes in a new sriHashes.mjs
file when we run npm build
, so all we need is some sort of a post-build step that would
- get all the unique hashes for scripts and styles from
sriHashes.mjs
- format and add them to our
/dist/_headers
file so that on deployment Cloudflare Pages applies the header with the matching hashes
I have a pretty simple use case here in that I donāt have that many hashes to include, so I donāt have to split out the _header
entries into one for every URL, I can simply have one wildcard header for all pages.
Cline, get to it.
My default /public/_headers
serves as the start for the new build step. It contains the non-SRI headers I want to set on all pages.
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Referrer-Policy: no-referrer
Permissions-Policy: accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(self), fullscreen=(self), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(self), xr-spatial-tracking=()
Strict-Transport-Security: max-age=15552000; includeSubDomains; preload
Access-Control-Allow-Origin: https://jcpretorius.com
Next, we have the build step processing Cline created for us in /scripts/generate-csp-header.mjs
import fs from 'fs/promises';
import path from 'path';
import { perResourceSriHashes } from '../src/generated/sriHashes.mjs';
const headersPath = path.join(process.cwd(), 'dist', '_headers');
async function generateCSPHeader() {
try {
// Collect unique hashes
const scriptHashes = new Set(Object.values(perResourceSriHashes.scripts));
const styleHashes = new Set(Object.values(perResourceSriHashes.styles));
// Generate CSP header
const cspHeader =
`Content-Security-Policy: default-src 'self'; object-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://track.example.com ${Array.from(
scriptHashes
)
.map((hash) => `'${hash}'`)
.join(' ')}; connect-src 'self' https://track.example.com; style-src 'self' ${Array.from(
styleHashes
)
.map((hash) => `'${hash}'`)
.join(
' '
)}; base-uri 'self'; img-src 'self' https://ipfs.io; frame-ancestors 'none'; worker-src 'self'; manifest-src 'none'; form-action 'self';`.trim();
// Read existing _headers file
let headersContent = await fs.readFile(headersPath, 'utf-8');
headersContent += '\n ' + cspHeader;
// Write updated content back to _headers file
await fs.writeFile(headersPath, headersContent);
console.log('CSP header generated and _headers file updated successfully.');
} catch (error) {
console.error('Error generating CSP header:', error);
}
}
generateCSPHeader();
it seems like it does a lot but itās actually quite simple. It looks at the unique hashes from the first generated file and appends the Content-Security-Policy entries to the final output /dist/_headers
.
All we need to do is register it as a post-build step in our package.json
{
"scripts": {
"postbuild": "node scripts/generate-csp-header.mjs"
}
}
Deploying it via Cloudflare Pages and what do you know, we have our CSP headers set with the correct hashes.
18:34:15.241 CSP header generated and _headers file updated successfully.
18:34:15.265 Finished
18:34:15.266 Note: No functions dir at /functions found. Skipping.
18:34:15.266 Validating asset output directory
18:34:17.212 Deploying your site to Cloudflare's global network...
18:34:18.844 Parsed 1 valid header rule.
One thing to note, if you had Auto Minify enabled in Cloudflare you will need to disable that as the hashes computed at build time will be of the non-auto-minified versions.
This caught me out for a while, but also seems like Auto Minify is going away as its being deprecated so its for the best.
Thanks for stopping by.