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
This commit is contained in:
Alf Eaton 2021-03-25 14:02:21 +00:00 committed by Copybot
parent 116f167a6f
commit a5637651b5
28 changed files with 115 additions and 51 deletions

View file

@ -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()
}
}

View file

@ -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)

View file

@ -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

View file

@ -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'))

View file

@ -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)}')

View file

@ -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();
});

View file

@ -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'}

View file

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

View file

@ -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")

View file

@ -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)}

View file

@ -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%')

View file

@ -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")

View file

@ -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)

View file

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

View file

@ -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}"

View file

@ -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(

View file

@ -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)};

View file

@ -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"},

View file

@ -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}'

View file

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

View file

@ -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 || {})}

View file

@ -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 || {})}

View file

@ -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)}

View file

@ -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'};

View file

@ -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']
}

View file

@ -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; }

View file

@ -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 || {})}

View file

@ -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)