mirror of
https://github.com/overleaf/overleaf.git
synced 2025-02-23 14:50:56 +00:00
Merge pull request #17596 from overleaf/rh-permissions-policy
[web] Add Permissions-Policy header GitOrigin-RevId: 8934bbbda411102580d9ef8af135dcdc147627f9
This commit is contained in:
parent
67e9a40e87
commit
8644e239c6
6 changed files with 282 additions and 0 deletions
88
services/web/app/src/infrastructure/HttpPermissionsPolicy.js
Normal file
88
services/web/app/src/infrastructure/HttpPermissionsPolicy.js
Normal 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
|
|
@ -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
services/web/app/src/infrastructure/types.ts
Normal file
8
services/web/app/src/infrastructure/types.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export interface HttpPermissionsPolicyRule {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
export interface HttpPermissionsPolicy {
|
||||
blocked: [string]
|
||||
allowed: HttpPermissionsPolicyRule
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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=()')
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue