Skip to content

Instantly share code, notes, and snippets.

@jhollinger
Last active March 8, 2023 22:56
Show Gist options
  • Save jhollinger/77265f9ed298ad8077b9fd7a82335c8d to your computer and use it in GitHub Desktop.
Save jhollinger/77265f9ed298ad8077b9fd7a82335c8d to your computer and use it in GitHub Desktop.
Rewrites index.html to with CSP strict-dynamic enabled
/**
* Usage: node csp-strict-dynamic.js dist/index.html [path/to/nginx.conf]
*
* Replaces all external script tags in index.html with dynamic loaders, calculates their SHA 256 hashes, and adds those
* hashes as allowed script sources + strict dynamic. Also works for inline scripts.
*
* Your index.html and/or given nginx/apache config file should contain your CSP policy with a script-src section like below:
*
* script-src 'strict-dynamic' {{csp-strict-dynamic-sources}};
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const buildDir = process.argv[1];
const htmlPath = process.argv[2];
const cspConfigPath = process.argv[3];
const cspStrictDynamicPlaceholder = '{{csp-strict-dynamic-sources}}';
const scriptPlaceholder = '{{strict-dynamic-js-loader}}';
const cspSources = [];
const jsSources = [];
const html = fs.readFileSync(htmlPath, {encoding: 'utf8'});
const newHtml = html
// make inline scripts CSP-safe
.replace(/<script(\s+type="text\/javascript")?\s*>(.+?)<\/script>/gis, (_all, _type, src) => {
const hash = genHash(src);
cspSources.push(hash);
return `\n<!-- ${hash} -->\n<script>${src}</script>\n`;
})
// collapse all external scripts into a single template
.replace(/<script\s+src\s*=\s*"([^"]+)"[^>]*>\s*<\/script>/gi, (script, src) => {
jsSources.push(src);
const first = jsSources.length === 1;
return first ? scriptPlaceholder : '';
})
// fill in the template with a dynamic loader that's CSP-safe
.replace(scriptPlaceholder, () => {
const loader = genLoader(jsSources);
const hash = genHash(loader);
cspSources.push(hash);
return `\n<!-- ${hash} -->\n<script>${loader}</script>\n`;
})
.replace(cspStrictDynamicPlaceholder, cspSources.join(' '));
fs.writeFileSync(htmlPath, newHtml);
if (cspConfigPath) {
const cspConfig = fs.readFileSync(cspConfigPath, {encoding: 'utf8'});
const newCspConfig = cspConfig.replace(cspStrictDynamicPlaceholder, cspSources.join(' '));
fs.writeFileSync(cspConfigPath, newCspConfig);
}
function genLoader(sources) {
const jsArray = JSON.stringify(sources);
return `
_loadScriptsInOrder(${jsArray});
function _loadScriptsInOrder(sources) {
if (sources.length === 0) return;
var src = sources.shift();
var script = document.createElement('script');
script.setAttribute('src', src);
script.setAttribute('defer', '');
script.onload = function() { _loadScriptsInOrder(sources) };
document.body.appendChild(script);
}
`;
}
function genHash(data) {
const hash = crypto.createHash('sha256');
hash.update(data);
const digest = hash.digest('base64');
return `'sha256-${digest}'`;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment