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

[web] Add Permissions-Policy header

GitOrigin-RevId: 8934bbbda411102580d9ef8af135dcdc147627f9
This commit is contained in:
roo hutton 2024-04-05 12:46:41 +01:00 committed by Copybot
parent 67e9a40e87
commit 8644e239c6
6 changed files with 282 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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