Explorar o código

[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 <alf.eaton@overleaf.com>
GitOrigin-RevId: 088a6082ad21c5b3f229887ba0ab3eca8d0528cd
Jakob Ackermann %!s(int64=4) %!d(string=hai) anos
pai
achega
224edddad4

+ 54 - 24
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

+ 1 - 1
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)

+ 0 - 1
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,

+ 5 - 0
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