From 224edddad435d6bd2163fabd34eb023d8316ec60 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Thu, 17 Mar 2022 09:23:07 +0000 Subject: [PATCH] [web] set a default, strict CSP on ALL endpoints (#6271) * Remove use of CSP_PERCENTAGE * Move header calculation earlier * Set a default policy and add comments * Apply the CSP header to all responses * Enable CSP in dev environment * [web] set a default, strict CSP on ALL endpoints * [misc] enable CSP in dev-env * Only build the default policy once * Update docker-compose.yml * [web] webpack: set default CSP header on webpack assets This aligns the webpack dev-server with production in nocdn=true mode. Co-authored-by: Alf Eaton GitOrigin-RevId: 088a6082ad21c5b3f229887ba0ab3eca8d0528cd --- services/web/app/src/infrastructure/CSP.js | 78 +++++++++++++------ services/web/app/src/infrastructure/Server.js | 2 +- services/web/config/settings.defaults.js | 1 - services/web/webpack.config.dev.js | 5 ++ 4 files changed, 60 insertions(+), 26 deletions(-) diff --git a/services/web/app/src/infrastructure/CSP.js b/services/web/app/src/infrastructure/CSP.js index 284a782f90..4f4c216f97 100644 --- a/services/web/app/src/infrastructure/CSP.js +++ b/services/web/app/src/infrastructure/CSP.js @@ -6,46 +6,37 @@ module.exports = function ({ reportPercentage, reportOnly = false, exclude = [], - percentage, }) { + const header = reportOnly + ? 'Content-Security-Policy-Report-Only' + : 'Content-Security-Policy' + + const defaultPolicy = buildDefaultPolicy(reportUri) + return function (req, res, next) { + // set the default policy + res.set(header, defaultPolicy) + const originalRender = res.render res.render = (...args) => { const view = relativeViewPath(args[0]) - // enable the CSP header for a percentage of requests - const belowCutoff = Math.random() * 100 <= percentage - - if (belowCutoff && !exclude.includes(view)) { + if (exclude.includes(view)) { + // remove the default policy + res.removeHeader(header) + } else { + // set the view policy res.locals.cspEnabled = true const scriptNonce = crypto.randomBytes(16).toString('base64') res.locals.scriptNonce = scriptNonce - const directives = [ - `script-src 'nonce-${scriptNonce}' 'unsafe-inline' 'strict-dynamic' https: 'report-sample'`, - `object-src 'none'`, - `base-uri 'none'`, - ] - - // enable the report URI for a percentage of CSP-enabled requests - const belowReportCutoff = Math.random() * 100 <= reportPercentage - - if (reportUri && belowReportCutoff) { - directives.push(`report-uri ${reportUri}`) - // NOTE: implement report-to once it's more widely supported - } - - const policy = directives.join('; ') + const policy = buildViewPolicy(scriptNonce, reportPercentage, reportUri) // Note: https://csp-evaluator.withgoogle.com/ is useful for checking the policy - const header = reportOnly - ? 'Content-Security-Policy-Report-Only' - : 'Content-Security-Policy' - res.set(header, policy) } @@ -56,6 +47,43 @@ module.exports = function ({ } } +const buildDefaultPolicy = reportUri => { + const directives = [ + `base-uri 'none'`, // forbid setting a "base" element + `default-src 'none'`, // forbid loading anything from a "src" attribute + `form-action 'none'`, // forbid setting a form action + `frame-ancestors 'none'`, // forbid loading embedded content + `img-src 'self'`, // allow loading images from the same domain (e.g. the favicon). + ] + + if (reportUri) { + directives.push(`report-uri ${reportUri}`) + // NOTE: implement report-to once it's more widely supported + } + + return directives.join('; ') +} + +const buildViewPolicy = (scriptNonce, reportPercentage, reportUri) => { + const directives = [ + `script-src 'nonce-${scriptNonce}' 'unsafe-inline' 'strict-dynamic' https: 'report-sample'`, // only allow scripts from certain sources + `object-src 'none'`, // forbid loading an "object" element + `base-uri 'none'`, // forbid setting a "base" element + ] + + if (reportUri) { + // enable the report URI for a percentage of CSP-enabled requests + const belowReportCutoff = Math.random() * 100 <= reportPercentage + + if (belowReportCutoff) { + directives.push(`report-uri ${reportUri}`) + // NOTE: implement report-to once it's more widely supported + } + } + + return directives.join('; ') +} + const webRoot = path.resolve(__dirname, '..', '..', '..') // build the view path relative to the web root @@ -64,3 +92,5 @@ function relativeViewPath(view) { ? path.relative(webRoot, view) : path.join('app', 'views', view) } + +module.exports.buildDefaultPolicy = buildDefaultPolicy diff --git a/services/web/app/src/infrastructure/Server.js b/services/web/app/src/infrastructure/Server.js index bf814d0d9f..c00e2ce175 100644 --- a/services/web/app/src/infrastructure/Server.js +++ b/services/web/app/src/infrastructure/Server.js @@ -271,7 +271,7 @@ webRouter.use( // add CSP header to HTML-rendering routes, if enabled if (Settings.csp && Settings.csp.enabled) { logger.info('adding CSP header to rendered routes', Settings.csp) - webRouter.use(csp(Settings.csp)) + app.use(csp(Settings.csp)) } logger.info('creating HTTP server'.yellow) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 1b14fbc810..8e5abb1a13 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -772,7 +772,6 @@ module.exports = { moduleImportSequence: ['launchpad', 'server-ce-scripts', 'user-activate'], csp: { - percentage: parseFloat(process.env.CSP_PERCENTAGE) || 0, enabled: process.env.CSP_ENABLED === 'true', reportOnly: process.env.CSP_REPORT_ONLY === 'true', reportPercentage: parseFloat(process.env.CSP_REPORT_PERCENTAGE) || 0, diff --git a/services/web/webpack.config.dev.js b/services/web/webpack.config.dev.js index 104f3cf37a..6f4b010aae 100644 --- a/services/web/webpack.config.dev.js +++ b/services/web/webpack.config.dev.js @@ -3,6 +3,7 @@ const merge = require('webpack-merge') const MiniCssExtractPlugin = require('mini-css-extract-plugin') const base = require('./webpack.config') +const { buildDefaultPolicy } = require('./app/src/infrastructure/CSP') module.exports = merge(base, { mode: 'development', @@ -30,6 +31,10 @@ module.exports = merge(base, { port: 3808, public: 'www.dev-overleaf.com:443', + headers: { + 'Content-Security-Policy': buildDefaultPolicy(), + }, + // Customise output to the (node) console stats: { colors: true, // Enable some coloured highlighting