Bläddra i källkod

Add Content-Security-Policy header (#3783)

* Add Content-Security-Policy header
* Add nonce attribute to script tags
* Use source-map for webpack devtool
* Add ng-csp attribute when CSP is enabled
* Allow overriding CSP settings with environment variables
* Hook into render and allow routes to disable the CSP header

GitOrigin-RevId: a873736a3514198165f1b2f1e18d002b65f20d30
Alf Eaton 5 år sedan
förälder
incheckning
a5637651b5
28 ändrade filer med 115 tillägg och 51 borttagningar
  1. 53 0
      services/web/app/src/infrastructure/CSP.js
  2. 7 0
      services/web/app/src/infrastructure/Server.js
  3. 11 10
      services/web/app/views/layout.pug
  4. 6 8
      services/web/app/views/project/editor.pug
  5. 1 1
      services/web/app/views/project/editor/history/fileTreeV2.pug
  6. 1 1
      services/web/app/views/project/editor/new_from_template.pug
  7. 1 1
      services/web/app/views/project/editor/pdf.pug
  8. 3 0
      services/web/app/views/project/list.pug
  9. 2 2
      services/web/app/views/project/list/front-chat.pug
  10. 0 5
      services/web/app/views/project/list/side-bar.pug
  11. 1 1
      services/web/app/views/project/token/access.pug
  12. 3 3
      services/web/app/views/referal/bonus.pug
  13. 1 1
      services/web/app/views/subscriptions/dashboard.pug
  14. 1 1
      services/web/app/views/subscriptions/dashboard/_managed_institutions.pug
  15. 1 1
      services/web/app/views/subscriptions/dashboard/_personal_subscription_recurly.pug
  16. 3 3
      services/web/app/views/subscriptions/new.pug
  17. 1 1
      services/web/app/views/subscriptions/plans.pug
  18. 1 1
      services/web/app/views/subscriptions/successful_subscription.pug
  19. 1 1
      services/web/app/views/subscriptions/team/invite.pug
  20. 1 1
      services/web/app/views/user/sessions.pug
  21. 1 1
      services/web/app/views/user/setPassword.pug
  22. 2 2
      services/web/app/views/user/settings.pug
  23. 1 1
      services/web/app/views/user/settings/user-oauth.pug
  24. 1 1
      services/web/app/views/user_membership/index.pug
  25. 7 0
      services/web/config/settings.defaults.coffee
  26. 2 2
      services/web/modules/launchpad/app/views/launchpad.pug
  27. 1 1
      services/web/modules/user-activate/app/views/user/activate.pug
  28. 1 1
      services/web/webpack.config.dev.js

+ 53 - 0
services/web/app/src/infrastructure/CSP.js

@@ -0,0 +1,53 @@
+const crypto = require('crypto')
+
+module.exports = function({
+  reportUri,
+  reportOnly = false,
+  exclude = [],
+  percentage
+}) {
+  return function(req, res, next) {
+    const originalRender = res.render
+
+    res.render = (...args) => {
+      // use the view path after removing any prefix up to a "views" folder
+      const view = args[0].split('/views/').pop()
+
+      // enable the CSP header for a percentage of requests
+      const belowCutoff = Math.random() * 100 <= percentage
+
+      if (belowCutoff && !exclude.includes(view)) {
+        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:`,
+          `object-src 'none'`,
+          `base-uri 'none'`
+        ]
+
+        if (reportUri) {
+          directives.push(`report-uri ${reportUri}`)
+          // NOTE: implement report-to once it's more widely supported
+        }
+
+        const policy = directives.join('; ')
+
+        // 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)
+      }
+
+      originalRender.apply(res, args)
+    }
+
+    next()
+  }
+}

+ 7 - 0
services/web/app/src/infrastructure/Server.js

@@ -5,6 +5,7 @@ const logger = require('logger-sharelatex')
 const metrics = require('@overleaf/metrics')
 const expressLocals = require('./ExpressLocals')
 const Validation = require('./Validation')
+const csp = require('./CSP')
 const Router = require('../router')
 const helmet = require('helmet')
 const UserSessionsRedis = require('../Features/User/UserSessionsRedis')
@@ -221,6 +222,12 @@ 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))
+}
+
 logger.info('creating HTTP server'.yellow)
 const server = require('http').createServer(app)
 

+ 11 - 10
services/web/app/views/layout.pug

@@ -4,12 +4,13 @@ html(
 	lang=(currentLngCode || 'en')
 )
 	- metadata = metadata || {}
+
 	block vars
 
 	head
 		include ./_metadata.pug
 
-		script(type="text/javascript").
+		script(type="text/javascript", nonce=scriptNonce).
 			// Stop superfish from loading
 			window.similarproducts = true
 		style [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak  {display: none !important; display: none; }
@@ -32,12 +33,12 @@ html(
 
 		//- Google Analytics
 		if (typeof(gaToken) != "undefined")
-			script(type='text/javascript').
+			script(type="text/javascript", nonce=scriptNonce).
 				(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
 				(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
 				m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
 				})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
-			script(type='text/javascript').
+			script(type="text/javascript", nonce=scriptNonce).
 				ga('create', '#{gaToken}', '#{settings.cookieDomain.replace(/^\./, "")}');
 				ga('send', 'pageview');
 
@@ -54,7 +55,7 @@ html(
 			if gaOptimize === true && typeof(gaOptimizeId) != "undefined"
 				//- Anti-flicker snippet
 				style(type='text/css') .async-hide { opacity: 0 !important}
-				script(type='text/javascript').
+				script(type="text/javascript", nonce=scriptNonce).
 					if (!ga.isBlocked) {
 						ga('require', '#{gaOptimizeId}');
 						ga('send', 'event', 'pageview',  document.title.substring(0, 499), window.location.href.substring(0, 499));
@@ -66,7 +67,7 @@ html(
 					}
 
 		else
-			script(type='text/javascript').
+			script(type="text/javascript", nonce=scriptNonce).
 				window.ga = function() { console.log("would send to GA", arguments) };
 
 		block meta
@@ -106,9 +107,9 @@ html(
 		block head-scripts
 
 
-	body
+	body(ng-csp=(cspEnabled ? "no-unsafe-eval" : false))
 		if(settings.recaptcha && settings.recaptcha.siteKeyV3)
-			script(src="https://www.google.com/recaptcha/api.js?render="+settings.recaptcha.siteKeyV3)
+			script(type="text/javascript", nonce=scriptNonce, src="https://www.google.com/recaptcha/api.js?render="+settings.recaptcha.siteKeyV3)
 
 
 		if (typeof(suppressNavbar) == "undefined")
@@ -122,9 +123,9 @@ html(
 		!= moduleIncludes("contactModal", locals)
 
 		block foot-scripts
-			script(src=buildJsPath('libraries.js'))
-			script(src=buildJsPath('main.js'))
-			script(type="text/javascript").
+			script(type="text/javascript", nonce=scriptNonce, src=buildJsPath('libraries.js'))
+			script(type="text/javascript", nonce=scriptNonce, src=buildJsPath('main.js'))
+			script(type="text/javascript", nonce=scriptNonce).
 				//- Look for bundle
 				var cdnBlocked = typeof Frontend === 'undefined'
 				//- Prevent loops

+ 6 - 8
services/web/app/views/project/editor.pug

@@ -159,12 +159,10 @@ block content
 			h3 {{ title }}
 		.modal-body(ng-bind-html="message")
 
-	//- We need to do .replace(/\//g, '\\/') do that '</script>' -> '<\/script>'
-	//- and doesn't prematurely end the script tag.
 	script#data(type="application/json").
 		!{StringHelper.stringifyJsonForScript({ userSettings: userSettings, user: user, trackChangesState: trackChangesState, useV2History: useV2History, enabledLinkedFileTypes: settings.enabledLinkedFileTypes, brandVariation: brandVariation })}
 
-	script(type='text/javascript').
+	script(type="text/javascript", nonce=scriptNonce).
 		window.data = JSON.parse(document.querySelector("#data").text);
 		window.project_id = "!{project_id}";
 		window.userSettings = window.data.userSettings;
@@ -192,11 +190,11 @@ block content
 		window.showReactAddFilesModal = "!{showReactAddFilesModal}" === 'true'
 
 	if (settings.overleaf != null)
-		script(type='text/javascript').
+		script(type="text/javascript", nonce=scriptNonce).
 			window.overallThemes = JSON.parse('!{StringHelper.stringifyJsonForScript(overallThemes)}');
 
 block foot-scripts
-	script(type="text/javascript" src=(wsUrl || '/socket.io') + '/socket.io.js')
-	script(src=mathJaxPath)
-	script(src=buildJsPath('libraries.js'))
-	script(src=buildJsPath('ide.js'))
+	script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js')
+	script(type="text/javascript", nonce=scriptNonce, src=mathJaxPath)
+	script(type="text/javascript", nonce=scriptNonce, src=buildJsPath('libraries.js'))
+	script(type="text/javascript", nonce=scriptNonce, src=buildJsPath('ide.js'))

+ 1 - 1
services/web/app/views/project/editor/history/fileTreeV2.pug

@@ -57,5 +57,5 @@ script(type="text/ng-template", id="historyFileEntityTpl")
 			)
 
 - var fileActionI18n = ['edited', 'renamed', 'created', 'deleted'].reduce((acc, i) => {acc[i] = translate('file_action_' + i); return acc}, {})
-script(type="text/javascript").
+script(type="text/javascript", nonce=scriptNonce).
 	window.fileActionI18n = JSON.parse('!{StringHelper.stringifyJsonForScript(fileActionI18n)}')

+ 1 - 1
services/web/app/views/project/editor/new_from_template.pug

@@ -25,7 +25,7 @@ block content
       input(type="hidden" name="brandVariationId" value=brandVariationId)
 
 block append foot-scripts
-  script.
+  script(type="text/javascript", nonce=scriptNonce).
     $(document).ready(function(){
       $('#create_form').submit();
     });

+ 1 - 1
services/web/app/views/project/editor/pdf.pug

@@ -414,6 +414,6 @@ script(type='text/ng-template', id='clearCacheModalTemplate')
 			span(ng-show="!state.inflight") #{translate("clear_cache")}
 			span(ng-show="state.inflight") #{translate("clearing")}…
 
-script(type="text/javascript").
+script(type="text/javascript", nonce=scriptNonce).
 	window.showNewLogsUI = #{showNewLogsUI || false}
 	window.logsUISubvariant = !{logsUISubvariant ? '"' + logsUISubvariant + '"' : 'null'}

+ 3 - 0
services/web/app/views/project/list.pug

@@ -62,3 +62,6 @@ block content
 		include ./list/modals
 
 	//- include ./list/front-chat
+
+	script(type="text/javascript", nonce=scriptNonce).
+		window.userHasNoSubscription = #{!!(settings.enableSubscriptions && !hasSubscription)}

+ 2 - 2
services/web/app/views/project/list/front-chat.pug

@@ -1,4 +1,4 @@
 if (frontChatWidgetRoomId)
-	script.
+	script(type="text/javascript", nonce=scriptNonce).
 		window.FCSP = '#{frontChatWidgetRoomId}';
-	script(src="https://chat-assets.frontapp.com/v1/chat.bundle.js")
+	script(type="text/javascript", nonce=scriptNonce, src="https://chat-assets.frontapp.com/v1/chat.bundle.js")

+ 0 - 5
services/web/app/views/project/list/side-bar.pug

@@ -161,8 +161,3 @@ if (showUserDetailsArea)
 			p.small To help you work from home throughout 2021, we're providing discounted plans and special initiatives.
 			p
 				a(href="https://www.overleaf.com/events/wfh2021").btn.btn-primary Upgrade
-
-
-			
-script.
-	window.userHasNoSubscription = #{!!(settings.enableSubscriptions && !hasSubscription)}

+ 1 - 1
services/web/app/views/project/token/access.pug

@@ -92,7 +92,7 @@ block content
 
 
 block append foot-scripts
-	script.
+	script(type="text/javascript", nonce=scriptNonce).
 		$(document).ready(function () {
 			setTimeout(function() {
 				$('.loading-screen-brand').css('height', '20%')

+ 3 - 3
services/web/app/views/referal/bonus.pug

@@ -122,7 +122,7 @@ block content
 
 
 block append foot-scripts
-	script(type='text/javascript').
+	script(type="text/javascript", nonce=scriptNonce).
 		$(document).ready(function () {
 			$.ajax({dataType: "script", cache: true, url: "//connect.facebook.net/en_US/all.js"}).done(function () {
 				window.fbAsyncInit = function() {
@@ -148,9 +148,9 @@ block append foot-scripts
 			}
 		}
 
-	script(type='text/javascript', src='//platform.twitter.com/widgets.js')
+	script(type="text/javascript", nonce=scriptNonce, src='//platform.twitter.com/widgets.js')
 
-	script(type="text/javascript").
+	script(type="text/javascript", nonce=scriptNonce).
 		$(function() {
 			$(".twitter").click(function() {
 				ga('send', 'event', 'referal-button', 'clicked', "twitter")

+ 1 - 1
services/web/app/views/subscriptions/dashboard.pug

@@ -3,7 +3,7 @@ extends ../layout
 include ./dashboard/_team_name_mixin
 
 block head-scripts
-	script(src="https://js.recurly.com/v4/recurly.js")
+	script(type="text/javascript", nonce=scriptNonce, src="https://js.recurly.com/v4/recurly.js")
 
 block content
 	main.content.content-alt(ng-cloak)

+ 1 - 1
services/web/app/views/subscriptions/dashboard/_managed_institutions.pug

@@ -1,4 +1,4 @@
-script(type='text/javascript').
+script(type="text/javascript", nonce=scriptNonce).
 	window.managedInstitutions = !{StringHelper.stringifyJsonForScript(managedInstitutions)}
 
 each institution in managedInstitutions

+ 1 - 1
services/web/app/views/subscriptions/dashboard/_personal_subscription_recurly.pug

@@ -1,4 +1,4 @@
-script(type='text/javascript').
+script(type="text/javascript", nonce=scriptNonce).
 	window.recurlyApiKey = "!{settings.apis.recurly.publicKey}"
 	window.subscription = !{StringHelper.stringifyJsonForScript(personalSubscription)}
 	window.recomendedCurrency = "#{personalSubscription.recurly.currency}"

+ 3 - 3
services/web/app/views/subscriptions/new.pug

@@ -1,8 +1,8 @@
 extends ../layout
 
 block head-scripts
-	script(src="https://js.recurly.com/v4/recurly.js")
-	script(type='text/javascript').
+	script(type="text/javascript", nonce=scriptNonce, src="https://js.recurly.com/v4/recurly.js")
+	script(type="text/javascript", nonce=scriptNonce).
 		window.countryCode = !{StringHelper.stringifyJsonForScript(countryCode || '')}
 		window.recurlyApiKey = "!{settings.apis.recurly.publicKey}"
 		window.recomendedCurrency = !{StringHelper.stringifyJsonForScript(String(currency).slice(0,3))}
@@ -352,7 +352,7 @@ block content
 							p.small.text-center(ng-non-bindable) We're confident that you'll love #{settings.appName}, but if not you can cancel anytime. We'll give you your money back, no questions asked, if you let us know within 30 days.
 
 
-	script(type="text/javascript").
+	script(type="text/javascript", nonce=scriptNonce).
 		ga('send', 'event', 'pageview', 'payment_form', "#{plan_code}")
 
 	script(

+ 1 - 1
services/web/app/views/subscriptions/plans.pug

@@ -7,7 +7,7 @@ block vars
 	- metadata = { viewport: true }
 
 block head-scripts
-	script(type='text/javascript').
+	script(type="text/javascript", nonce=scriptNonce).
 		window.recomendedCurrency = '#{recomendedCurrency}';
 		window.abCurrencyFlag = '#{abCurrencyFlag}';
 		window.groupPlans = !{StringHelper.stringifyJsonForScript(groupPlans)};

+ 1 - 1
services/web/app/views/subscriptions/successful_subscription.pug

@@ -29,7 +29,7 @@ block content
 						p
 							a.btn.btn-primary(href="/project") &lt; #{translate("back_to_your_projects")}
 
-	script(type="text/javascript").
+	script(type="text/javascript", nonce=scriptNonce).
 		window.ab = [
 			{step:1, bucket:"student_control", testName:"editor_plan"},
 			{step:1, bucket:"collab_test", testName:"editor_plan"},

+ 1 - 1
services/web/app/views/subscriptions/team/invite.pug

@@ -1,7 +1,7 @@
 extends ../../layout
 
 block head-scripts
-	script(type='text/javascript').
+	script(type="text/javascript", nonce=scriptNonce).
 		window.teamId = '#{teamId}'
 		window.hasIndividualRecurlySubscription = #{hasIndividualRecurlySubscription}
 		window.inviteToken = '#{inviteToken}'

+ 1 - 1
services/web/app/views/user/sessions.pug

@@ -2,7 +2,7 @@ extends ../layout
 
 
 block head-scripts
-	script(type='text/javascript').
+	script(type="text/javascript", nonce=scriptNonce).
 		window.otherSessions = !{StringHelper.stringifyJsonForScript(sessions)}
 
 

+ 1 - 1
services/web/app/views/user/setPassword.pug

@@ -56,6 +56,6 @@ block content
 								) #{translate("set_new_password")}
 
 
-	script(type='text/javascript').
+	script(type="text/javascript", nonce=scriptNonce).
 		window.usersEmail = "#{getReqQueryParam('email')}"
 		window.passwordStrengthOptions = !{StringHelper.stringifyJsonForScript(settings.passwordStrengthOptions || {})}

+ 2 - 2
services/web/app/views/user/settings.pug

@@ -284,9 +284,9 @@ block content
 	script#data(type="application/json").
 		!{StringHelper.stringifyJsonForScript({ reconfirmationRemoveEmail, reconfirmedViaSAML })}
 
-	script(type="text/javascript").
+	script(type="text/javascript", nonce=scriptNonce).
 		window.data = JSON.parse(document.querySelector("#data").text);
 
-	script(type='text/javascript').
+	script(type="text/javascript", nonce=scriptNonce).
 		window.usersEmail = !{StringHelper.stringifyJsonForScript(user.email)};
 		window.passwordStrengthOptions = !{StringHelper.stringifyJsonForScript(settings.passwordStrengthOptions || {})}

+ 1 - 1
services/web/app/views/user/settings/user-oauth.pug

@@ -1,5 +1,5 @@
 block head-scripts
-	script(type='text/javascript').
+	script(type="text/javascript", nonce=scriptNonce).
 		window.oauthProviders = !{StringHelper.stringifyJsonForScript(oauthProviders)}
 		window.thirdPartyIds = !{StringHelper.stringifyJsonForScript(thirdPartyIds)}
 

+ 1 - 1
services/web/app/views/user_membership/index.pug

@@ -108,7 +108,7 @@ block content
 									a(href=paths.exportMembers) #{translate('export_csv')}
 
 
-	script(type="text/javascript").
+	script(type="text/javascript", nonce=scriptNonce).
 		window.users = !{StringHelper.stringifyJsonForScript(users)};
 		window.paths = !{StringHelper.stringifyJsonForScript(paths)};
 		window.groupSize = #{groupSize || 'null'};

+ 7 - 0
services/web/config/settings.defaults.coffee

@@ -689,3 +689,10 @@ module.exports = settings =
 		createFileModes: []
 	}
 
+	csp: {
+		percentage: parseFloat(process.env.CSP_PERCENTAGE) || 0
+		enabled: process.env.CSP_ENABLED == 'true'
+		reportOnly: process.env.CSP_REPORT_ONLY == 'true'
+		reportUri: process.env.CSP_REPORT_URI
+		exclude: ['project/editor', 'project/list']
+	}

+ 2 - 2
services/web/modules/launchpad/app/views/launchpad.pug

@@ -2,14 +2,14 @@ extends ../../../../app/views/layout
 
 block content
 
-	script(type="text/javascript").
+	script(type="text/javascript", nonce=scriptNonce).
 		window.data = {
 			adminUserExists: !{adminUserExists == true},
 			ideJsPath: "!{buildJsPath('ide.js')}",
 			authMethod: "!{authMethod}"
 		}
 
-	script(type="text/javascript" src=(wsUrl || '/socket.io') + '/socket.io.js')
+	script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js')
 
 	style.
 		hr { margin-bottom: 5px; }

+ 1 - 1
services/web/modules/user-activate/app/views/user/activate.pug

@@ -59,5 +59,5 @@ block content
 									span(ng-show="!activationForm.inflight") #{translate("activate")}
 									span(ng-show="activationForm.inflight") #{translate("activating")}…
 
-	script(type='text/javascript').
+	script(type="text/javascript", nonce=scriptNonce).
 		window.passwordStrengthOptions = !{StringHelper.stringifyJsonForScript(settings.passwordStrengthOptions || {})}

+ 1 - 1
services/web/webpack.config.dev.js

@@ -7,7 +7,7 @@ module.exports = merge(base, {
   mode: 'development',
 
   // Enable accurate source maps for dev
-  devtool: 'eval-source-map',
+  devtool: 'source-map',
 
   plugins: [
     // Extract CSS to a separate file (rather than inlining to a <style> tag)