فهرست منبع

Merge pull request #17596 from overleaf/rh-permissions-policy

[web] Add Permissions-Policy header

GitOrigin-RevId: 8934bbbda411102580d9ef8af135dcdc147627f9
roo hutton 2 سال پیش
والد
کامیت
8644e239c6

+ 88 - 0
services/web/app/src/infrastructure/HttpPermissionsPolicy.js

@@ -0,0 +1,88 @@
+//  @ts-check
+
+const Settings = require('@overleaf/settings')
+
+/**
+ * @typedef {import('./types').HttpPermissionsPolicy} HttpPermissionsPolicy
+ * */
+
+class HttpPermissionsPolicyMiddleware {
+  /**
+   * Initialise the middleware with a Permissions Policy config
+   * @param {HttpPermissionsPolicy} policy
+   */
+  constructor(policy) {
+    this.middleware = this.middleware.bind(this)
+    if (policy) {
+      this.policy = this.buildPermissionsPolicy(policy)
+    }
+  }
+
+  /**
+   * Checks the provided policy is valid
+   * @param {HttpPermissionsPolicy} policy
+   * @returns {boolean}
+   */
+  validatePermissionsPolicy(policy) {
+    let policyIsValid = true
+
+    if (!policy.allowed) {
+      return true
+    }
+
+    for (const [directive, origins] of Object.entries(policy.allowed)) {
+      // Do any directives in the allowlist clash with the denylist?
+      if (policy.blocked && policy.blocked.includes(directive)) {
+        policyIsValid = false
+      }
+      if (!origins) {
+        policyIsValid = false
+      }
+    }
+
+    return policyIsValid
+  }
+
+  /**
+   * Constructs a Permissions-Policy header string from the given policy configuration
+   * @param {HttpPermissionsPolicy} policy
+   * @returns {string}
+   */
+  buildPermissionsPolicy(policy) {
+    if (!this.validatePermissionsPolicy(policy)) {
+      throw new Error('Invalid Permissions-Policy header configuration')
+    }
+
+    const policyElements = []
+
+    if (policy.blocked && policy.blocked.length > 0) {
+      policyElements.push(
+        policy.blocked.map(policyElement => `${policyElement}=()`).join(', ')
+      )
+    }
+
+    if (policy.allowed && Object.entries(policy.allowed).length > 0) {
+      policyElements.push(
+        Object.keys(policy.allowed)
+          .map(allowKey => `${allowKey}=(${policy.allowed[allowKey]})`)
+          .join(', ')
+      )
+    }
+
+    return policyElements.join(', ')
+  }
+
+  middleware(req, res, next) {
+    if (this.policy && Settings.useHttpPermissionsPolicy) {
+      const originalRender = res.render
+
+      res.render = (...args) => {
+        res.setHeader('Permissions-Policy', this.policy)
+        originalRender.apply(res, args)
+      }
+    }
+    next()
+  }
+}
+
+module.exports = HttpPermissionsPolicyMiddleware

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

@@ -9,6 +9,7 @@ const Router = require('../router')
 const helmet = require('helmet')
 const UserSessionsRedis = require('../Features/User/UserSessionsRedis')
 const Csrf = require('./Csrf')
+const HttpPermissionsPolicyMiddleware = require('./HttpPermissionsPolicy')
 
 const sessionsRedisClient = UserSessionsRedis.client()
 
@@ -143,6 +144,15 @@ if (Settings.blockCrossOriginRequests) {
   app.use(Csrf.blockCrossOriginRequests())
 }
 
+if (Settings.useHttpPermissionsPolicy) {
+  const httpPermissionsPolicy = new HttpPermissionsPolicyMiddleware(
+    Settings.httpPermissions
+  )
+  logger.debug('adding permissions policy config', Settings.httpPermissions)
+
+  webRouter.use(httpPermissionsPolicy.middleware)
+}
+
 RedirectManager.apply(webRouter)
 
 webRouter.use(cookieParser(Settings.security.sessionSecret))

+ 8 - 0
services/web/app/src/infrastructure/types.ts

@@ -0,0 +1,8 @@
+export interface HttpPermissionsPolicyRule {
+  [key: string]: string
+}
+
+export interface HttpPermissionsPolicy {
+  blocked: [string]
+  allowed: HttpPermissionsPolicyRule
+}

+ 39 - 0
services/web/config/settings.defaults.js

@@ -105,6 +105,40 @@ const parseTextExtensions = function (extensions) {
   }
 }
 
+const httpPermissionsPolicy = {
+  blocked: [
+    'accelerometer',
+    'attribution-reporting',
+    'browsing-topics',
+    'camera',
+    'display-capture',
+    'encrypted-media',
+    'fullscreen',
+    'gamepad',
+    'geolocation',
+    'gyroscope',
+    'hid',
+    'identity-credentials-get',
+    'idle-detection',
+    'local-fonts',
+    'magnetometer',
+    'microphone',
+    'midi',
+    'otp-credentials',
+    'payment',
+    'picture-in-picture',
+    'screen-wake-lock',
+    'serial',
+    'storage-access',
+    'usb',
+    'window-management',
+    'xr-spatial-tracking',
+  ],
+  allowed: {
+    autoplay: 'self "https://videos.ctfassets.net"',
+  },
+}
+
 module.exports = {
   env: 'server-ce',
 
@@ -275,6 +309,11 @@ module.exports = {
     recurly: {},
   },
 
+  // Defines which features are allowed in the
+  // Permissions-Policy HTTP header
+  httpPermissions: httpPermissionsPolicy,
+  useHttpPermissionsPolicy: true,
+
   jwt: {
     key: process.env.OT_JWT_AUTH_KEY,
     algorithm: process.env.OT_JWT_AUTH_ALG || 'HS256',

+ 29 - 0
services/web/test/acceptance/src/HttpPermissionsPolicyTests.js

@@ -0,0 +1,29 @@
+const { expect } = require('chai')
+const fetch = require('node-fetch')
+const Settings = require('@overleaf/settings')
+
+const BASE_URL = `http://${process.env.HTTP_TEST_HOST || 'localhost'}:23000`
+
+describe('HttpPermissionsPolicy', function () {
+  it('should have permissions-policy header on user-facing pages', async function () {
+    const response = await fetch(BASE_URL)
+
+    expect(response.headers.get('permissions-policy')).to.equal(
+      'accelerometer=(), attribution-reporting=(), browsing-topics=(), camera=(), display-capture=(), encrypted-media=(), fullscreen=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), identity-credentials-get=(), idle-detection=(), local-fonts=(), magnetometer=(), microphone=(), midi=(), otp-credentials=(), payment=(), picture-in-picture=(), screen-wake-lock=(), serial=(), storage-access=(), usb=(), window-management=(), xr-spatial-tracking=(), autoplay=(self "https://videos.ctfassets.net")'
+    )
+  })
+
+  it('should not have permissions-policy header on requests for non-rendered content', async function () {
+    const response = await fetch(`${BASE_URL}/dev/csrf`)
+
+    expect(response.headers.get('permissions-policy')).to.be.null
+  })
+
+  describe('when permissions policy is disabled', function () {
+    it('it adds no additional headers', async function () {
+      Settings.useHttpPermissionsPolicy = false
+      const response = await fetch(BASE_URL)
+      expect(response.headers.get('permissions-policy')).to.be.null
+    })
+  })
+})

+ 108 - 0
services/web/test/unit/src/infrastructure/HTTPPermissionsPolicyTests.js

@@ -0,0 +1,108 @@
+const { expect } = require('chai')
+const sinon = require('sinon')
+const modulePath = '../../../../app/src/infrastructure/HttpPermissionsPolicy.js'
+const SandboxedModule = require('sandboxed-module')
+const HttpPermissionsPolicyMiddleware = require('../../../../app/src/infrastructure/HttpPermissionsPolicy')
+const MockRequest = require('../helpers/MockRequest')
+const MockResponse = require('../helpers/MockResponse')
+
+describe('HttpPermissionsPolicy', function () {
+  this.beforeEach(function () {
+    this.next = sinon.stub()
+    this.HttpPermissionsPolicy = SandboxedModule.require(modulePath, {})
+    this.path = '/foo/bar'
+    this.req = new MockRequest()
+    return (this.res = new MockResponse())
+  })
+
+  describe('when a single blocked policy element is provided', function () {
+    it('returns a valid header string', function () {
+      const policy = {
+        blocked: ['accelerometer'],
+      }
+      const httpPermissionsMiddleware = new HttpPermissionsPolicyMiddleware(
+        policy
+      )
+      return expect(httpPermissionsMiddleware.policy).to.equal(
+        'accelerometer=()'
+      )
+    })
+  })
+
+  describe('when a single allowed policy element is provided', function () {
+    it('returns a valid header string', function () {
+      const policy = {
+        allowed: { camera: 'self' },
+      }
+      const httpPermissionsMiddleware = new HttpPermissionsPolicyMiddleware(
+        policy
+      )
+      return expect(httpPermissionsMiddleware.policy).to.equal('camera=(self)')
+    })
+  })
+
+  describe('when a full policy is provided', function () {
+    it('returns a valid header string', function () {
+      const policy = {
+        blocked: ['usb', 'hid'],
+        allowed: { camera: 'self https://example.com', fullscreen: 'self' },
+      }
+      const httpPermissionsMiddleware = new HttpPermissionsPolicyMiddleware(
+        policy
+      )
+      return expect(httpPermissionsMiddleware.policy).to.equal(
+        'usb=(), hid=(), camera=(self https://example.com), fullscreen=(self)'
+      )
+    })
+  })
+
+  describe('when a conflicting policy is provided', function () {
+    it('returns an error', function () {
+      const policy = {
+        blocked: ['usb'],
+        allowed: { usb: 'self' },
+      }
+      return expect(() => new HttpPermissionsPolicyMiddleware(policy)).to.throw(
+        'Invalid Permissions-Policy header configuration'
+      )
+    })
+  })
+
+  describe('when the allowlist contains an incomplete directive', function () {
+    it('returns an error', function () {
+      const policy = {
+        blocked: ['usb'],
+        allowed: { camera: '' },
+      }
+      return expect(() => new HttpPermissionsPolicyMiddleware(policy)).to.throw(
+        'Invalid Permissions-Policy header configuration'
+      )
+    })
+  })
+
+  describe('when an empty denylist is provided', function () {
+    it('returns a valid header string ', function () {
+      const policy = {
+        allowed: { camera: 'self' },
+        blocked: [],
+      }
+      const httpPermissionsMiddleware = new HttpPermissionsPolicyMiddleware(
+        policy
+      )
+      return expect(httpPermissionsMiddleware.policy).to.equal('camera=(self)')
+    })
+  })
+
+  describe('when an empty allowlist is provided', function () {
+    it('returns a valid header string ', function () {
+      const policy = {
+        allowed: {},
+        blocked: ['usb'],
+      }
+      const httpPermissionsMiddleware = new HttpPermissionsPolicyMiddleware(
+        policy
+      )
+      return expect(httpPermissionsMiddleware.policy).to.equal('usb=()')
+    })
+  })
+})