plugins_web_csp.js


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;