Compute sha256 of CSP <script/> in NodeJS

Automatically generate the SHA-256 hash of the script tags for the Content Security Policy

Sep 17, 2022

#javascript #nodejs #security #webdev

Delilah

Kim Davies

I have been using the same strategy to compute automatically SHA-256 hash for my <script /> tags since a couple of years and as I recently applied it again at work and in Papyrs (I am currently migrating the kit that generate the blogspaces from vanilla JavaScript to Astro), I thought it could be an interesting subject for a new short blog post.


Getting started

While sha256 can be set manually, I personally rather like to generate these automatically once the build script is over - as a post-processing job. This means that I rely on the fact that what is built is trustable and that I compute the values once the scripts have been generated.

To do so, I create a dedicated script - e.g. ./build-csp.mjs - that I append to my build script:

// package.json

{
    "scripts": {
        "build:csp": "node build-csp.mjs",
        "build": "astro build && npm run build:csp"
    }
}

Search and update

As I aim to inject the shas at the end of the process, I add a placeholder within the Content Security Policy (CSP) to search and update the computed values. e.g. in following CSP meta tag I used the placeholder {{EXTRA\_SHAS}}:

<meta
    http-equiv="Content-Security-Policy"
    content="default-src 'none';
             script-src 'self' {{EXTRA_SHAS}};
/>

Because such a placeholder is not a valid policy, the browser would throw an error while evaluating the rules. What can notably happen when I develop since I post process only the production build.

That is why I often just avoid the use of CSP for my local environment. e.g. in my Astro project I skip the rendering of the meta tag if not a PROD build.

---
const csp = import.meta.env.PROD;
---

{
  csp && (
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'none';
      script-src 'self' {{EXTRA_SHAS}};
    />
  )
}

List all HTML files

To compute the sha256 sha for the content of the <script /> that are used in the HTML files I first need to, well, list the HTML files that have been built.

For such purpose, I use a recursive exploration function that I most probably found on Stackoverflow and keep using since then.

import { readdirSync, lstatSync } from "fs";
import { join } from "path";

export const findEntryPoints = (dir, files) => {
    readdirSync(dir).forEach((file) => {
        const fullPath = join(dir, file);
        if (lstatSync(fullPath).isDirectory()) {
            findEntryPoints(fullPath, files);
        } else {
            files.push(fullPath);
        }
    });
};

const entryPoints = [];
findEntryPoints("dist", entryPoints);

Once I got all the files, I filter those that are .html.

import { extname } from "path";

const htmlEntryPoints = entryPoints.filter((entry) => [".html"].includes(extname(entry)));

Process and update CSP

Once I got all the target files, I batch update these by reading their content, generating the related shas and ultimately update the rules.

import { join } from "path";
import { readFile } from "fs/promises";

const updateCSP = async (entry) => {
    const indexHtml = await readFile(join(process.cwd(), entry), "utf-8");
    const scriptHashes = await computeHashes(indexHtml);
    await writeCSP({ scriptHashes, indexHtml, entry });
};

const promises = htmlEntryPoints.map(updateCSP);
await Promise.all(promises);

Compute sha256

To create the hashes for the content of the <script /> tags, I use a regex to find those tags and use the Crypto API of NodeJS to effectively compute the values.

import { createHash } from "crypto";

const computeHashes = (indexHtml) => {
    const sw = /<script[\s\S]*?>([\s\S]*?)<\/script>/gm;

    const scriptHashes = [];

    let m;
    while ((m = sw.exec(indexHtml))) {
        const content = m[1];

        scriptHashes.push(`'sha256-${createHash("sha256").update(content).digest("base64")}'`);
    }

    return scriptHashes;
};

Write results

Finally, I replace the placeholder I declared earlier with these effective shas.

import { writeFile } from "fs/promises";

const writeCSP = async ({ scriptHashes, indexHtml, entry }) =>
    writeFile(
        entry,
        indexHtml.replace("{{EXTRA_SHAS}}", scriptHashes.map((sha256) => sha256).join(" ")),
        "utf-8"
    );

And voilà 🥳


Summary

In life it is sometimes the small things that make you happy and developing this little helper script is one of those.

To infinity and beyond
David