diff --git a/services/web/app/src/infrastructure/HttpPermissionsPolicy.js b/services/web/app/src/infrastructure/HttpPermissionsPolicy.js new file mode 100644 index 0000000000..a4be6b8e32 --- /dev/null +++ b/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 diff --git a/services/web/app/src/infrastructure/Server.js b/services/web/app/src/infrastructure/Server.js index d50bb9bae5..a20c14f142 100644 --- a/services/web/app/src/infrastructure/Server.js +++ b/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)) diff --git a/services/web/app/src/infrastructure/types.ts b/services/web/app/src/infrastructure/types.ts new file mode 100644 index 0000000000..3fc32759e3 --- /dev/null +++ b/services/web/app/src/infrastructure/types.ts @@ -0,0 +1,8 @@ +export interface HttpPermissionsPolicyRule { + [key: string]: string +} + +export interface HttpPermissionsPolicy { + blocked: [string] + allowed: HttpPermissionsPolicyRule +} diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index dfcc2f01ce..956beb62c2 100644 --- a/services/web/config/settings.defaults.js +++ b/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', diff --git a/services/web/test/acceptance/src/HttpPermissionsPolicyTests.js b/services/web/test/acceptance/src/HttpPermissionsPolicyTests.js new file mode 100644 index 0000000000..c4d2fe21fa --- /dev/null +++ b/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 + }) + }) +}) diff --git a/services/web/test/unit/src/infrastructure/HTTPPermissionsPolicyTests.js b/services/web/test/unit/src/infrastructure/HTTPPermissionsPolicyTests.js new file mode 100644 index 0000000000..407510921b --- /dev/null +++ b/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=()') + }) + }) +})