Files
Verso/services/web/app/src/infrastructure/CSP.js
T
Alf EatonandCopybot c91d99de80 Allow additional CSP directives to be defined for specific views (#19550)
GitOrigin-RevId: 19bf1004479b5106e64e9c13d58d69e328cc12f1
2024-10-14 11:01:19 +00:00

135 lines
3.5 KiB
JavaScript

const crypto = require('crypto')
const path = require('path')
module.exports = function ({
reportUri,
reportPercentage,
reportOnly = false,
exclude = [],
viewDirectives = {},
}) {
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)
if (reportUri) {
res.set('Reporting-Endpoints', `csp-endpoint="${reportUri}"`)
}
const originalRender = res.render
res.render = (...args) => {
const view = relativeViewPath(args[0])
if (exclude.includes(view)) {
// remove the default policy
res.removeHeader(header)
res.removeHeader('Reporting-Endpoints')
} else {
// set the view policy
res.locals.cspEnabled = true
const scriptNonce = crypto.randomBytes(16).toString('base64')
res.locals.scriptNonce = scriptNonce
const policy = buildViewPolicy(
scriptNonce,
reportPercentage,
reportUri,
viewDirectives[view]
)
// Note: https://csp-evaluator.withgoogle.com/ is useful for checking the policy
res.set(header, policy)
}
originalRender.apply(res, args)
}
next()
}
}
const buildDefaultPolicy = (reportUri, styleSrc) => {
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}`)
directives.push(`report-to csp-endpoint`)
}
if (styleSrc) {
directives.push(`style-src ${styleSrc}`)
}
return directives.join('; ')
}
const buildViewPolicy = (
scriptNonce,
reportPercentage,
reportUri,
viewDirectives
) => {
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
...(viewDirectives ?? []),
]
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}`)
directives.push(`report-to csp-endpoint`)
}
}
return directives.join('; ')
}
const webRoot = path.resolve(__dirname, '..', '..', '..')
// build the view path relative to the web root
function relativeViewPath(view) {
return path.isAbsolute(view)
? path.relative(webRoot, view)
: path.join('app', 'views', view)
}
function removeCSPHeaders(res) {
res.removeHeader('Content-Security-Policy')
res.removeHeader('Content-Security-Policy-Report-Only')
}
/**
* WARNING: allowing inline styles can open a security hole;
* this is intended only for use in specific circumstances, such as Safari's built-in PDF viewer.
*/
function allowUnsafeInlineStyles(res) {
res.set(
'Content-Security-Policy',
buildDefaultPolicy(undefined, "'unsafe-inline'")
)
}
module.exports.buildDefaultPolicy = buildDefaultPolicy
module.exports.removeCSPHeaders = removeCSPHeaders
module.exports.allowUnsafeInlineStyles = allowUnsafeInlineStyles