CSP.js 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. const crypto = require('crypto')
  2. const path = require('path')
  3. module.exports = function ({
  4. reportUri,
  5. reportPercentage,
  6. reportOnly = false,
  7. exclude = [],
  8. }) {
  9. const header = reportOnly
  10. ? 'Content-Security-Policy-Report-Only'
  11. : 'Content-Security-Policy'
  12. const defaultPolicy = buildDefaultPolicy(reportUri)
  13. return function (req, res, next) {
  14. // set the default policy
  15. res.set(header, defaultPolicy)
  16. const originalRender = res.render
  17. res.render = (...args) => {
  18. const view = relativeViewPath(args[0])
  19. if (exclude.includes(view)) {
  20. // remove the default policy
  21. res.removeHeader(header)
  22. } else {
  23. // set the view policy
  24. res.locals.cspEnabled = true
  25. const scriptNonce = crypto.randomBytes(16).toString('base64')
  26. res.locals.scriptNonce = scriptNonce
  27. const policy = buildViewPolicy(scriptNonce, reportPercentage, reportUri)
  28. // Note: https://csp-evaluator.withgoogle.com/ is useful for checking the policy
  29. res.set(header, policy)
  30. }
  31. originalRender.apply(res, args)
  32. }
  33. next()
  34. }
  35. }
  36. const buildDefaultPolicy = reportUri => {
  37. const directives = [
  38. `base-uri 'none'`, // forbid setting a "base" element
  39. `default-src 'none'`, // forbid loading anything from a "src" attribute
  40. `form-action 'none'`, // forbid setting a form action
  41. `frame-ancestors 'none'`, // forbid loading embedded content
  42. `img-src 'self'`, // allow loading images from the same domain (e.g. the favicon).
  43. ]
  44. if (reportUri) {
  45. directives.push(`report-uri ${reportUri}`)
  46. // NOTE: implement report-to once it's more widely supported
  47. }
  48. return directives.join('; ')
  49. }
  50. const buildViewPolicy = (scriptNonce, reportPercentage, reportUri) => {
  51. const directives = [
  52. `script-src 'nonce-${scriptNonce}' 'unsafe-inline' 'strict-dynamic' https: 'report-sample'`, // only allow scripts from certain sources
  53. `object-src 'none'`, // forbid loading an "object" element
  54. `base-uri 'none'`, // forbid setting a "base" element
  55. ]
  56. if (reportUri) {
  57. // enable the report URI for a percentage of CSP-enabled requests
  58. const belowReportCutoff = Math.random() * 100 <= reportPercentage
  59. if (belowReportCutoff) {
  60. directives.push(`report-uri ${reportUri}`)
  61. // NOTE: implement report-to once it's more widely supported
  62. }
  63. }
  64. return directives.join('; ')
  65. }
  66. const webRoot = path.resolve(__dirname, '..', '..', '..')
  67. // build the view path relative to the web root
  68. function relativeViewPath(view) {
  69. return path.isAbsolute(view)
  70. ? path.relative(webRoot, view)
  71. : path.join('app', 'views', view)
  72. }
  73. function removeCSPHeaders(res) {
  74. res.removeHeader('Content-Security-Policy')
  75. res.removeHeader('Content-Security-Policy-Report-Only')
  76. }
  77. module.exports.buildDefaultPolicy = buildDefaultPolicy
  78. module.exports.removeCSPHeaders = removeCSPHeaders