From c912c9f708ef6a976b708083da8aa825c8b6fa14 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Date: Mon, 4 Mar 2024 11:14:00 +0530 Subject: [PATCH] Csp directive (#2005) * feat: add csp directives options * fix: fix changes for adding CSP directives * fix: remove unwanted space * chore: add additional directives for CSP in routes.js * fix: restrict user to set style-src & script-src in additional directives and example * feat: expose getCSPHeader function for application to set CSP headers for their applicaiton * fix: electrode/lint fix for return type * fix: update name for variable and refactor code * fix: fix lint issues and reformat code * build: add changelog file --- .../csp-directive_2024-03-01-04-51.json | 10 ++++ packages/subapp-server/lib/fastify-plugin.js | 14 +++-- packages/subapp-server/lib/utils.js | 59 +++++++++++-------- samples/poc-subappv1-csp/src/routes.js | 21 ++++++- 4 files changed, 72 insertions(+), 32 deletions(-) create mode 100644 common/changes/subapp-server/csp-directive_2024-03-01-04-51.json diff --git a/common/changes/subapp-server/csp-directive_2024-03-01-04-51.json b/common/changes/subapp-server/csp-directive_2024-03-01-04-51.json new file mode 100644 index 000000000..c12d36241 --- /dev/null +++ b/common/changes/subapp-server/csp-directive_2024-03-01-04-51.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "subapp-server", + "comment": "Ablity to add CSP directives by application.", + "type": "minor" + } + ], + "packageName": "subapp-server" +} \ No newline at end of file diff --git a/packages/subapp-server/lib/fastify-plugin.js b/packages/subapp-server/lib/fastify-plugin.js index 28fb6504f..b3b8935d5 100644 --- a/packages/subapp-server/lib/fastify-plugin.js +++ b/packages/subapp-server/lib/fastify-plugin.js @@ -46,7 +46,7 @@ function makeRouteHandler({ path, routeRenderer, routeOptions }) { await until(() => request.app.webpackDev.valid === true, 400); console.log(`Webpack stats valid: ${request.app.webpackDev.valid}`); } - + const context = await routeRenderer({ useStream, mode: "", @@ -56,8 +56,15 @@ function makeRouteHandler({ path, routeRenderer, routeOptions }) { const data = context.result; const status = data.status; - const cspHeader = getCSPHeader({ styleNonce, scriptNonce }); - + let cspHeader; + /** If csp headers are provided by application in route options then use that otherwise generate CSP headers */ + if (routeOptions.cspHeaderValues instanceof Function) { + const rawCSPHeader = routeOptions.cspHeaderValues({ styleNonce, scriptNonce }); + // Replace newline characters and spaces + cspHeader = rawCSPHeader.replace(/\s{2,}/g, " ").trim(); + } else { + cspHeader = getCSPHeader({ styleNonce, scriptNonce }); + } if (cspHeader) { reply.header("Content-Security-Policy", cspHeader); } @@ -77,7 +84,6 @@ function makeRouteHandler({ path, routeRenderer, routeOptions }) { reply.code(status); return reply.send(data); } - } catch (err) { reply.status(HttpStatusCodes.INTERNAL_SERVER_ERROR); if (process.env.NODE_ENV !== "production") { diff --git a/packages/subapp-server/lib/utils.js b/packages/subapp-server/lib/utils.js index cc9341dda..79fb85dbe 100644 --- a/packages/subapp-server/lib/utils.js +++ b/packages/subapp-server/lib/utils.js @@ -23,9 +23,12 @@ const updateFullTemplate = (baseDir, options) => { }; function getDefaultRouteOptions() { - const { settings = {}, devServer = {}, fullDevServer = {}, httpDevServer = {} } = getDevProxy - ? getDevProxy() - : {}; + const { + settings = {}, + devServer = {}, + fullDevServer = {}, + httpDevServer = {} + } = getDevProxy ? getDevProxy() : {}; const { webpackDev, useDevProxy } = settings; // temporary location to write build artifacts in dev mode const buildArtifacts = ".etmp"; @@ -42,9 +45,9 @@ function getDefaultRouteOptions() { buildArtifacts, prodBundleBase: "/js", devBundleBase: "/js", - cspNonceValue: undefined, + cspNonceValue: undefined, // if `true`, electrode will generate nonce and add CSP header - cspNonce: false, + cspNonce: false, templateFile: Path.join(__dirname, "..", "resources", "index-page"), cdn: {}, reporting: { enable: false } @@ -143,46 +146,49 @@ function nonceGenerator(_) { /** * Sets CSP nonce to routeOptions and returns the nonce value * cspNonce - boolean || object || string - * - * @param {*} param0 + * + * @param {*} param0 * @returns nonce value */ function setCSPNonce({ routeOptions }) { let nonce = nonceGenerator(); - - switch(typeof routeOptions.cspNonce) { + + switch (typeof routeOptions.cspNonce) { // if cspNonce is a string, electrode validates it for nonce value and uses the same to set CSP header case "string": { - assert(routeOptions.cspNonce.match(/^[A-Za-z0-9+=\/]{22}$/), "Error: unable to set CSP header. Invalid nonce value passed!"); + assert( + routeOptions.cspNonce.match(/^[A-Za-z0-9+=\/]{22}$/), + "Error: unable to set CSP header. Invalid nonce value passed!" + ); routeOptions.cspNonceValue = { styleNonce: routeOptions.cspNonce, scriptNonce: routeOptions.cspNonce }; break; - }; + } - // if cspNonce is true, electrode will generate nonce and sets CSP header for both + // if cspNonce is true, electrode will generate nonce and sets CSP header for both // styles and script. case "boolean": { - nonce = !!routeOptions.cspNonce === true ? nonce : "" + nonce = !!routeOptions.cspNonce === true ? nonce : ""; routeOptions.cspNonceValue = { styleNonce: nonce, scriptNonce: nonce }; break; - }; - // if cspHeader is an object, app should explicitly enable it for script and/or style. + } + // if cspHeader is an object, app should explicitly enable it for script and/or style. // cspHeader: { style: true } - will enable nonce only for styles case "object": { routeOptions.cspNonceValue = { styleNonce: routeOptions.cspNonce && !!routeOptions.cspNonce.style === true ? nonce : "", - scriptNonce: routeOptions.cspNonce && !!routeOptions.cspNonce.script === true ? nonce : "" + scriptNonce: routeOptions.cspNonce && !!routeOptions.cspNonce.script === true ? nonce : "" }; break; - }; + } // TODO: add 'case' so that app can pass a nonce generator function. - + default: { routeOptions.cspNonceValue = { styleNonce: "", @@ -191,35 +197,36 @@ function setCSPNonce({ routeOptions }) { break; } } - + return routeOptions.cspNonceValue; } function getCSPHeader({ styleNonce = "", scriptNonce = "" }) { - const unsafeEval = process.env.NODE_ENV !== "production" ? - `'unsafe-eval'` : ""; + const unsafeEval = process.env.NODE_ENV !== "production" ? `'unsafe-eval'` : ""; const styleSrc = styleNonce ? `style-src 'nonce-${styleNonce}' 'strict-dynamic';` : ""; - - const scriptSrc = scriptNonce ? `script-src 'nonce-${scriptNonce}' 'strict-dynamic' ${unsafeEval}; `: ""; + + const scriptSrc = scriptNonce + ? `script-src 'nonce-${scriptNonce}' 'strict-dynamic' ${unsafeEval}; ` + : ""; return `${scriptSrc}${styleSrc}`; } /** - * Wait for a condition and execute rest of the code. + * Wait for a condition and execute rest of the code. * @param conditionFunction - A function that returns conditions to be waited for. * @param maxWait - Max duration (in ms) to wait before promise resolves to avoid indefinite wait. * @returns A promise that resolves after given condition in conditionFunction is satisfied or after the max wait time. */ function until(conditionFunction, maxWait) { - const poll = (resolve) => { + const poll = resolve => { if (conditionFunction()) { resolve(); } else { setTimeout(_ => poll(resolve), maxWait); } - } + }; return new Promise(poll); } diff --git a/samples/poc-subappv1-csp/src/routes.js b/samples/poc-subappv1-csp/src/routes.js index efd27392e..17b1cf2cd 100644 --- a/samples/poc-subappv1-csp/src/routes.js +++ b/samples/poc-subappv1-csp/src/routes.js @@ -1,6 +1,5 @@ const path = require("path"); const { cspNonceValue } = require("./server/utils"); - const subAppOptions = { serverSideRendering: false, }; @@ -10,6 +9,22 @@ const tokenHandlers = [path.join(__dirname, "./server/token-handler")]; const commonRouteOptions = { tokenHandlers, }; +/** + * + * @param {string} styleNonce Value + * @param {string} scriptNonce Value + * @returns {string} CSP header value + */ +const setCSPHeaderValues = ({styleNonce, scriptNonce}) => { + const cspHeader = ` + script-src 'self' 'nonce-${scriptNonce}' 'strict-dynamic' 'unsafe-eval'; + style-src 'self' 'nonce-${styleNonce}' 'strict-dynamic' 'unsafe-eval'; + font-src 'self'; + object-src 'none'; + form-action 'self'; + `; + return cspHeader; +}; /** * To set CSP header @@ -20,6 +35,7 @@ const commonRouteOptions = { * * Option 3 - Selectively set boolean flag for `cspNonce`. { style: true } will add nonce only * for styles + * */ export default { @@ -30,7 +46,8 @@ export default { // Enable one of these to use CSP header cspNonce: true, // cspNonce: { style: true }, // { script: true } - // cspNonce: cspNonceValue, + // cspNonce: cspNonceValue, + cspHeaderValues: setCSPHeaderValues, criticalCSS: path.join(__dirname, "./server/critical.css"), ...commonRouteOptions }