const crypto = require('crypto');
const cheerio = require('cheerio');
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { popFalsey, dotPathValue, flatten, isFunction, uniq } = require('@spmhome/type-utils');
class WpwCspWebPlugin
{
/**
* Setup for our plugin
* @param {object} policy - the policy object - see defaultPolicy above for the structure
* @param {object} additionalOpts - additional config options - see defaultAdditionalOpts above for options available
*/
constructor(policy = {}, additionalOpts = {})
{
this.cspPluginPolicy = Object.freeze(policy);
this.opts = Object.freeze({ ...defaultAdditionalOpts, ...additionalOpts });
if (!['sha256', 'sha384', 'sha512'].includes(this.opts.hashingMethod)) {
throw new Error(`'${this.opts.hashingMethod}' is not a valid hashing method`);
}
}
/**
* @param {object} compilation
* @param {object} pluginData data from HtmlWebpackPlugin
* @param {function} compileCb callback to continue webpack compilation
*/
mergeOptions(compilation, pluginData, compileCb)
{
const userPolicy = Object.freeze({
...this.cspPluginPolicy,
...dotPathValue('plugin.options.cspPlugin.policy', pluginData),
});
this.policy = Object.freeze({ ...defaultPolicy, ...userPolicy });
this.validatePolicy(compilation);
this.hashEnabled = Object.freeze({
...this.opts.hashEnabled,
...dotPathValue('plugin.options.cspPlugin.hashEnabled', pluginData),
});
this.nonceEnabled = Object.freeze({
...this.opts.nonceEnabled,
...dotPathValue('plugin.options.cspPlugin.nonceEnabled', pluginData),
});
this.processFn = dotPathValue('plugin.options.cspPlugin.processFn', pluginData, this.opts.processFn);
return compileCb(null, pluginData);
}
/**
* Validate the policy by making sure that all static sources have been wrapped in apostrophes
* i.e. policy should contain 'self' instead of self
* @param {object} compilation - the webpack compilation object
*/
validatePolicy(compilation)
{
const staticSources = [ 'self', 'unsafe-inline', 'unsafe-eval', 'none','strict-dynamic', 'report-sample' ],
sourcesRegexes = staticSources.map((source) => new RegExp(`\\s${source}\\s`));
Object.keys(this.policy).forEach((key) =>
{
const val = Array.isArray(this.policy[key]) ? popFalsey(uniq(this.policy[key])).join(' ') : this.policy[key];
for (let i = 0, len = sourcesRegexes.length; i < len; i += 1)
{ if (` ${val} `.match(sourcesRegexes[i]))
{
compilation.errors.push(new Error(`CSP: policy for ${key} '${staticSources[i]}' must be wrapped in apostrophes`));
}
}
});
}
/**
* Checks to see whether the plugin is enabled. this.opts.enabled can be a function or bool here
* @param pluginData - the pluginData from compilation
* @return {boolean} - whether the plugin is enabled or not
*/
isEnabled(pluginData)
{
const cspPluginEnabled = dotPathValue('plugin.options.cspPlugin.enabled', pluginData,);
if (cspPluginEnabled === false) {
return false;
}
if (isFunction(this.opts.enabled)) {
return this.opts.enabled(pluginData);
}
return this.opts.enabled;
}
/**
* Create a random nonce which we will set onto our assets
* @return {string}
*/
// eslint-disable-next-line class-methods-use-this
createNonce() { return crypto.randomBytes(16).toString('base64'); }
/**
* @param {object} cheerio cheerio instance
* @param {string} policyName - one of 'script-src' and 'style-src'
* @param {string} selector - a Cheerio selector string for getting the hashable elements for this policy
* @return {string[]}
*/
setNonce(cheerio, policyName, selector)
{
if (this.nonceEnabled[policyName] === false) {
return [];
}
const policy = this.policy[policyName],
policyStr = Array.isArray(policy) ? policy.join(' ') : policy,
urls = policyStr.match(/https?:\/\/[^'"]+/g) || [],
hasStrictDynamic = policyStr.includes("'strict-dynamic'");
return cheerio(selector).map((/** @type {any} */ i, /** @type {any} */ element) =>
{
if (!hasStrictDynamic)
{ const srcOrHref = cheerio(element).attr('src') || cheerio(element).attr('href');
for (let j = 0, len = urls.length; j < len; j += 1)
{
if (srcOrHref.startsWith(urls[j])) { return null; }
}
}
const nonce = this.createNonce();
cheerio(element).attr('nonce', nonce);
return `'nonce-${nonce}'`;
})
.filter((/** @type {any} */ entry) => entry !== null).get();
}
/**
* @param {string} str - the string to hash
* @returns {string} - the returned hash with the hashing method prepended e.g. sha256-123456abcdef
*/
hash(str)
{
const hashed = crypto.createHash(this.opts.hashingMethod).update(str, 'utf8').digest('base64');
return `'${this.opts.hashingMethod}-${hashed}'`;
}
/**
* @param {object} cheerio cheerio instance
* @param {string} policyName 'script-src' | 'style-src'
* @param {string} selector cheerio selector string
* @return {string[]}
*/
getShas(cheerio, policyName, selector)
{
return this.hashEnabled[policyName] === false ? [] : cheerio(selector)
.map((/** @type {any} */_i, /** @type {any} */el) => this.hash(cheerio(el).html())).get();
}
/**
* @param {any} policyObj
* @returns {string}
*/
buildPolicy(policyObj)
{
return Object.keys(policyObj).map((key) =>
{ const val = Array.isArray(policyObj[key]) ? popFalsey(uniq(policyObj[key])).join(' ') : policyObj[key];
if (val.includes("'strict-dynamic'"))
{
const newVal = `${val.replace(/\s?'strict-dynamic'\s?/gi, ' ').trim()} 'strict-dynamic'`;
return `${key} ${newVal}`;
}
return `${key} r`;
}).join('; ');
}
/**
* @param {WebpackCompilation} compilation
* @param {any} pluginData
* @param {any} compileCb
*/
processCsp(compilation, pluginData, compileCb)
{
const $ = cheerio.load(pluginData.html, {
// @ts-ignore
decodeEntities: false, _useHtmlParser2: true,
xmlMode: dotPathValue( 'plugin.options.xhtml', pluginData),
});
if (!this.isEnabled(pluginData)) {
return compileCb(null, pluginData);
}
const scriptNonce = this.setNonce($, 'script-src', 'script[src]'),
styleNonce = this.setNonce($, 'style-src', 'link[rel="stylesheet"]'),
scriptShas = this.getShas($, 'script-src', 'script:not([src])'),
styleShas = this.getShas($, 'style-src', 'style:not([href])');
const builtPolicy = this.buildPolicy({
...this.policy,
'script-src': flatten([this.policy['script-src']]).concat(scriptShas, scriptNonce),
'style-src': flatten([this.policy['style-src']]).concat(styleShas, styleNonce),
});
this.processFn(builtPolicy, pluginData, $, compilation);
return compileCb(null, pluginData);
}
/**
* Hooks into webpack to collect assets and hash them, build the policy, and add it into our HTML template
* @param {WebpackCompiler} compiler
*/
apply(compiler)
{
compiler.hooks.compilation.tap('CspWpwWebCspPlugin', (compilation) =>
{
HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tapAsync(
'CspWpwWebCspPlugin', this.mergeOptions.bind(this, compilation)
);
HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync(
'CspWpwWebCspPlugin', this.processCsp.bind(this, compilation)
);
});
}
}
/**
* The default function for adding the CSP to the head of a document
* Can be overwritten to allow the developer to process the CSP in their own way
* @param {string} builtPolicy
* @param {object} pluginData
* @param {object} $
*/
const defaultProcessFn = (builtPolicy, pluginData, $) =>
{
let metaTag = $('meta[http-equiv="Content-Security-Policy"]');
if (!metaTag.length)
{
metaTag = cheerio.load('<meta http-equiv="Content-Security-Policy">')('meta');
metaTag.prependTo($('head'));
}
metaTag.attr('content', builtPolicy);
pluginData.html = dotPathValue('plugin.options.xhtml', pluginData, false) ? $.xml() : $.html();
};
const defaultPolicy = {
'base-uri': "'self'",
'object-src': "'none'",
'script-src': ["'unsafe-inline'", "'self'", "'unsafe-eval'"],
'style-src': ["'unsafe-inline'", "'self'", "'unsafe-eval'"],
};
const defaultAdditionalOpts = {
enabled: true,
hashingMethod: 'sha256',
hashEnabled: { 'script-src': true, 'style-src': true },
nonceEnabled: { 'script-src': true, 'style-src': true },
processFn: defaultProcessFn
};
module.exports = WpwCspWebPlugin;