diff --git a/services/web/app/src/infrastructure/CSP.js b/services/web/app/src/infrastructure/CSP.js new file mode 100644 index 0000000000..171d45e52a --- /dev/null +++ b/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() + } +} diff --git a/services/web/app/src/infrastructure/Server.js b/services/web/app/src/infrastructure/Server.js index 0e9bc5707c..77cbf634e6 100644 --- a/services/web/app/src/infrastructure/Server.js +++ b/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) diff --git a/services/web/app/views/layout.pug b/services/web/app/views/layout.pug index af0c74c9cb..42110fd928 100644 --- a/services/web/app/views/layout.pug +++ b/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 diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index 1cde011e47..26a726e4a2 100644 --- a/services/web/app/views/project/editor.pug +++ b/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>' - //- 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')) diff --git a/services/web/app/views/project/editor/history/fileTreeV2.pug b/services/web/app/views/project/editor/history/fileTreeV2.pug index aecd08cc2f..cc9de102b9 100644 --- a/services/web/app/views/project/editor/history/fileTreeV2.pug +++ b/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)}') diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug index 041cce4e68..554b1048f8 100644 --- a/services/web/app/views/project/editor/new_from_template.pug +++ b/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(); }); diff --git a/services/web/app/views/project/editor/pdf.pug b/services/web/app/views/project/editor/pdf.pug index 5c9f07706e..777a8415de 100644 --- a/services/web/app/views/project/editor/pdf.pug +++ b/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'} diff --git a/services/web/app/views/project/list.pug b/services/web/app/views/project/list.pug index ae71073a32..0ef563f929 100644 --- a/services/web/app/views/project/list.pug +++ b/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)} diff --git a/services/web/app/views/project/list/front-chat.pug b/services/web/app/views/project/list/front-chat.pug index bbc283df23..28911319a7 100644 --- a/services/web/app/views/project/list/front-chat.pug +++ b/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") diff --git a/services/web/app/views/project/list/side-bar.pug b/services/web/app/views/project/list/side-bar.pug index f80f14c4a8..decaceea77 100644 --- a/services/web/app/views/project/list/side-bar.pug +++ b/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)} diff --git a/services/web/app/views/project/token/access.pug b/services/web/app/views/project/token/access.pug index f5cd62575a..fe7f71a5f1 100644 --- a/services/web/app/views/project/token/access.pug +++ b/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%') diff --git a/services/web/app/views/referal/bonus.pug b/services/web/app/views/referal/bonus.pug index 84c13a6372..9b80439a76 100644 --- a/services/web/app/views/referal/bonus.pug +++ b/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") diff --git a/services/web/app/views/subscriptions/dashboard.pug b/services/web/app/views/subscriptions/dashboard.pug index 9810cea5af..7c301cd855 100644 --- a/services/web/app/views/subscriptions/dashboard.pug +++ b/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) diff --git a/services/web/app/views/subscriptions/dashboard/_managed_institutions.pug b/services/web/app/views/subscriptions/dashboard/_managed_institutions.pug index abe464e943..2f0fb587ba 100644 --- a/services/web/app/views/subscriptions/dashboard/_managed_institutions.pug +++ b/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 diff --git a/services/web/app/views/subscriptions/dashboard/_personal_subscription_recurly.pug b/services/web/app/views/subscriptions/dashboard/_personal_subscription_recurly.pug index 3ac3e891ed..8865252731 100644 --- a/services/web/app/views/subscriptions/dashboard/_personal_subscription_recurly.pug +++ b/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}" diff --git a/services/web/app/views/subscriptions/new.pug b/services/web/app/views/subscriptions/new.pug index 9f0e3c9b74..884d2aae19 100644 --- a/services/web/app/views/subscriptions/new.pug +++ b/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( diff --git a/services/web/app/views/subscriptions/plans.pug b/services/web/app/views/subscriptions/plans.pug index 3741dd594a..f33e2eabae 100644 --- a/services/web/app/views/subscriptions/plans.pug +++ b/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)}; diff --git a/services/web/app/views/subscriptions/successful_subscription.pug b/services/web/app/views/subscriptions/successful_subscription.pug index 294efeb6dc..6034de644d 100644 --- a/services/web/app/views/subscriptions/successful_subscription.pug +++ b/services/web/app/views/subscriptions/successful_subscription.pug @@ -29,7 +29,7 @@ block content p a.btn.btn-primary(href="/project") < #{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"}, diff --git a/services/web/app/views/subscriptions/team/invite.pug b/services/web/app/views/subscriptions/team/invite.pug index c7f49d9354..11cbe32c6b 100644 --- a/services/web/app/views/subscriptions/team/invite.pug +++ b/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}' diff --git a/services/web/app/views/user/sessions.pug b/services/web/app/views/user/sessions.pug index 81af933e23..632b8225ae 100644 --- a/services/web/app/views/user/sessions.pug +++ b/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)} diff --git a/services/web/app/views/user/setPassword.pug b/services/web/app/views/user/setPassword.pug index 60d6b8eacb..7dd65319ff 100644 --- a/services/web/app/views/user/setPassword.pug +++ b/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 || {})} diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index b47285fc46..eaefbce020 100644 --- a/services/web/app/views/user/settings.pug +++ b/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 || {})} diff --git a/services/web/app/views/user/settings/user-oauth.pug b/services/web/app/views/user/settings/user-oauth.pug index 5f69f496c7..3ec7ac2bab 100644 --- a/services/web/app/views/user/settings/user-oauth.pug +++ b/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)} diff --git a/services/web/app/views/user_membership/index.pug b/services/web/app/views/user_membership/index.pug index fe4e8be9e0..0af61bcadd 100644 --- a/services/web/app/views/user_membership/index.pug +++ b/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'}; diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index f5c964af32..8908b45afd 100644 --- a/services/web/config/settings.defaults.coffee +++ b/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'] + } diff --git a/services/web/modules/launchpad/app/views/launchpad.pug b/services/web/modules/launchpad/app/views/launchpad.pug index def74981f4..73d4fad29a 100644 --- a/services/web/modules/launchpad/app/views/launchpad.pug +++ b/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; } diff --git a/services/web/modules/user-activate/app/views/user/activate.pug b/services/web/modules/user-activate/app/views/user/activate.pug index 378b9fceb9..a38b79da1f 100644 --- a/services/web/modules/user-activate/app/views/user/activate.pug +++ b/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 || {})} diff --git a/services/web/webpack.config.dev.js b/services/web/webpack.config.dev.js index d768bfc154..46b2ed97fb 100644 --- a/services/web/webpack.config.dev.js +++ b/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