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

Fluster GmbH Sihlquai 131 8005 Zürich