@mrtc0/csp-html-webpack-plugin
Version:
192 lines (163 loc) • 5.56 kB
text/typescript
import * as crypto from "crypto";
import { JSDOM } from "jsdom";
export type DirectiveSet = {
[key: string]: Array<string>;
};
const strictCspDirectiveSet: DirectiveSet = {
"base-uri": [`'self'`],
"object-src": [`'none'`],
"script-src": [`'strict-dynamic'`],
};
export class Csp {
private document: Document;
private directiveSet: DirectiveSet;
constructor(html: string, directiveSet?: DirectiveSet) {
this.document = new JSDOM(html).window.document;
this.directiveSet = strictCspDirectiveSet;
if (directiveSet) {
this.directiveSet = directiveSet;
}
}
getDocument(): Document {
return this.document;
}
/**
* Refactor all script tags to a single loader script.
*/
refactorScriptTagsForHashSourceCSP(): void {
const scriptTags = this.document.getElementsByTagName("script");
const srcScripts = Array.from(scriptTags).filter((script) =>
script.hasAttribute("src")
);
// remove all sourced scripts
srcScripts.forEach((script) => {
script.remove();
});
const loaderInlineScript = Csp.generateLoaderScript(srcScripts);
const newInlineScript = this.document.createElement("script");
newInlineScript.textContent = loaderInlineScript;
this.document.body.appendChild(newInlineScript);
}
/**
* Get the hash of all inline scripts.
* @returns The list of hashes of inline scripts.
*/
getHashAllInlineScripts(): string[] {
const hashes: Array<string> = [];
const scriptTags = this.document.getElementsByTagName("script");
const inlineScripts = Array.from(scriptTags).filter(
(script) => !script.hasAttribute("src")
);
inlineScripts.forEach((script) => {
const hash = Csp.hashInlineScript(script.textContent || "");
hashes.push(hash);
});
return hashes;
}
/**
* Get the hash of all inline styles.
* @returns The list of hashes of inline styles.
*/
getHashAllInlineStyles(): string[] {
const hashes: Array<string> = [];
const styleTags = this.document.getElementsByTagName("style");
const inlineStyles = Array.from(styleTags);
inlineStyles.forEach((style) => {
const hash = Csp.hashInlineStyle(style.textContent || "");
hashes.push(hash);
});
return hashes;
}
addCspMetaTag(csp: string): void {
const meta = this.document.createElement("meta");
meta.setAttribute("http-equiv", "Content-Security-Policy");
meta.setAttribute("content", csp);
this.document.getElementsByTagName("head")[0].prepend(meta);
}
/**
* Generate a CSP directive set.
*
* @param scriptHashes - The list of hashes of inline scripts.
* @param styleHashes - The list of hashes of inline styles.
* @returns The CSP directive set.
*/
generateDirectiveSet(scriptHashes: string[], styleHashes: string[]): string {
const autoGeneratedDirectiveSet: DirectiveSet = {};
if (this.directiveSet["script-src"] || scriptHashes.length > 0) {
const scriptValues: Set<string> = new Set(
[`'strict-dynamic'`].concat(
this.directiveSet["script-src"],
scriptHashes
)
);
autoGeneratedDirectiveSet["script-src"] = Array.from(scriptValues)
.flat()
.filter((v) => v);
}
if (this.directiveSet["style-src"] || styleHashes.length > 0) {
const styleValues: Set<string> = new Set(
[`'self'`].concat(this.directiveSet["style-src"], styleHashes)
);
autoGeneratedDirectiveSet["style-src"] = Array.from(styleValues)
.flat()
.filter((v) => v);
}
let cspTemplate = { ...this.directiveSet, ...autoGeneratedDirectiveSet };
return Object.entries(cspTemplate)
.map(([directive, values]) => {
return `${directive} ${values.join(" ")};`;
})
.join("");
}
/**
* Generate loader inline scripts to dynamically load external scripts.
* For example:
* <script src="https://example.com/analytics.js"></script>
* <script src="/app.js"></script>
* will be refactored to:
* <script>
* var scripts = ['https://example.com/analytics.js', '/app.js'];
* scripts.forEach(function(scriptUrl) {
* var s = document.createElement('script');
* s.src = scriptUrl;
* s.async = false;
* document.body.appendChild(s);
* });
* </script>
*
* @param scripts - The list of sourced scripts.
* @returns The loader script that will load all sourced scripts.
*/
static generateLoaderScript(scripts: HTMLScriptElement[]): string {
if (scripts.length === 0) {
return "";
}
const srcListFormatted = scripts.map((s) => `'${s.src}'`).join();
return `var scripts = [${srcListFormatted}];
scripts.forEach(function(scriptUrl) {
var s = document.createElement('script');
s.src = scriptUrl;
s.async = false;
document.body.appendChild(s);
});\n`;
}
/**
* @param scriptText - The text of the inline script.
* @returns The hash of the inline script.
*/
static hashInlineScript(scriptText: string): string {
const hash = crypto
.createHash("sha256")
.update(scriptText)
.digest("base64");
return `'sha256-${hash}'`;
}
/**
* @param styleText - The text of the inline style.
* @returns The hash of the inline style.
*/
static hashInlineStyle(styleText: string): string {
const hash = crypto.createHash("sha256").update(styleText).digest("base64");
return `'sha256-${hash}'`;
}
}