Astro CSP Headers for SRI with Cloudflare Pages

Published: 16 Oct 2024. Estimated read time about 6 minutes.
šŸ‘Øā€šŸ’» This post was written by a human.
Categories: [performance]
Astro CSP Headers for SRI with Cloudflare Pages

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.

Image showing integrity and security score, with an evaluation of 1.0 out of 5 for SRI

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

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.

Auto minify cloudflare setting

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.

Recommended Posts

Back to top ā‡ˆ