diff --git a/src/middleware/secure-headers/index.test.ts b/src/middleware/secure-headers/index.test.ts index 915d6e7d..2f3d9ad5 100644 --- a/src/middleware/secure-headers/index.test.ts +++ b/src/middleware/secure-headers/index.test.ts @@ -36,6 +36,7 @@ describe('Secure Headers Middleware', () => { expect(res.headers.get('Cross-Origin-Resource-Policy')).toEqual('same-origin') expect(res.headers.get('Cross-Origin-Opener-Policy')).toEqual('same-origin') expect(res.headers.get('Origin-Agent-Cluster')).toEqual('?1') + expect(res.headers.get('Permissions-Policy')).toBeNull() expect(res.headers.get('Content-Security-Policy')).toBeFalsy() }) @@ -48,6 +49,9 @@ describe('Secure Headers Middleware', () => { defaultSrc: ["'self'"], }, crossOriginEmbedderPolicy: true, + permissionsPolicy: { + camera: [], + }, }) ) app.get('/test', async (ctx) => { @@ -72,6 +76,7 @@ describe('Secure Headers Middleware', () => { expect(res.headers.get('Cross-Origin-Opener-Policy')).toEqual('same-origin') expect(res.headers.get('Origin-Agent-Cluster')).toEqual('?1') expect(res.headers.get('Cross-Origin-Embedder-Policy')).toEqual('require-corp') + expect(res.headers.get('Permissions-Policy')).toEqual('camera=()') expect(res.headers.get('Content-Security-Policy')).toEqual("default-src 'self'") }) @@ -98,6 +103,7 @@ describe('Secure Headers Middleware', () => { expect(res.headers.get('X-Permitted-Cross-Domain-Policies')).toEqual('none') expect(res.headers.get('Cross-Origin-Resource-Policy')).toEqual('same-origin') expect(res.headers.get('Cross-Origin-Opener-Policy')).toEqual('same-origin') + expect(res.headers.get('Permissions-Policy')).toBeNull() expect(res.headers.get('Origin-Agent-Cluster')).toEqual('?1') }) @@ -154,6 +160,28 @@ describe('Secure Headers Middleware', () => { expect(res.headers.get('X-XSS-Protection')).toEqual('1') }) + it('should set Permission-Policy header', async () => { + const app = new Hono() + app.use( + '/test', + secureHeaders({ + permissionsPolicy: { + fullscreen: ['self'], + bluetooth: ['none'], + payment: ['self', 'example.com'], + syncXhr: [], + camera: false, + microphone: true, + geolocation: ['*'], + }, + }) + ) + + const res = await app.request('/test') + expect(res.headers.get('Permissions-Policy')).toEqual( + 'fullscreen=(self), bluetooth=(none), payment=(self example.com), sync-xhr=(), camera=none, microphone=(), geolocation=(*)' + ) + }) it('CSP Setting', async () => { const app = new Hono() app.use( diff --git a/src/middleware/secure-headers/permissions-policy.ts b/src/middleware/secure-headers/permissions-policy.ts new file mode 100644 index 00000000..f63593ec --- /dev/null +++ b/src/middleware/secure-headers/permissions-policy.ts @@ -0,0 +1,86 @@ +// https://github.com/w3c/webappsec-permissions-policy/blob/main/features.md + +export type PermissionsPolicyDirective = + | StandardizedFeatures + | ProposedFeatures + | ExperimentalFeatures + +/** + * These features have been declared in a published version of the respective specification. + */ +type StandardizedFeatures = + | 'accelerometer' + | 'ambientLightSensor' + | 'attributionReporting' + | 'autoplay' + | 'battery' + | 'bluetooth' + | 'camera' + | 'chUa' + | 'chUaArch' + | 'chUaBitness' + | 'chUaFullVersion' + | 'chUaFullVersionList' + | 'chUaMobile' + | 'chUaModel' + | 'chUaPlatform' + | 'chUaPlatformVersion' + | 'chUaWow64' + | 'computePressure' + | 'crossOriginIsolated' + | 'directSockets' + | 'displayCapture' + | 'encryptedMedia' + | 'executionWhileNotRendered' + | 'executionWhileOutOfViewport' + | 'fullscreen' + | 'geolocation' + | 'gyroscope' + | 'hid' + | 'identityCredentialsGet' + | 'idleDetection' + | 'keyboardMap' + | 'magnetometer' + | 'microphone' + | 'midi' + | 'navigationOverride' + | 'payment' + | 'pictureInPicture' + | 'publickeyCredentialsGet' + | 'screenWakeLock' + | 'serial' + | 'storageAccess' + | 'syncXhr' + | 'usb' + | 'webShare' + | 'windowManagement' + | 'xrSpatialTracking' + +/** + * These features have been proposed, but the definitions have not yet been integrated into their respective specs. + */ +type ProposedFeatures = + | 'clipboardRead' + | 'clipboardWrite' + | 'gemepad' + | 'sharedAutofill' + | 'speakerSelection' + +/** + * These features generally have an explainer only, but may be available for experimentation by web developers. + */ +type ExperimentalFeatures = + | 'allScreensCapture' + | 'browsingTopics' + | 'capturedSurfaceControl' + | 'conversionMeasurement' + | 'digitalCredentialsGet' + | 'focusWithoutUserActivation' + | 'joinAdInterestGroup' + | 'localFonts' + | 'runAdAuction' + | 'smartCard' + | 'syncScript' + | 'trustTokenRedemption' + | 'unload' + | 'verticalScroll' diff --git a/src/middleware/secure-headers/secure-headers.ts b/src/middleware/secure-headers/secure-headers.ts index 3ef52aa0..902b45f7 100644 --- a/src/middleware/secure-headers/secure-headers.ts +++ b/src/middleware/secure-headers/secure-headers.ts @@ -6,6 +6,7 @@ import type { Context } from '../../context' import type { MiddlewareHandler } from '../../types' import { encodeBase64 } from '../../utils/encode' +import type { PermissionsPolicyDirective } from './permissions-policy' export type SecureHeadersVariables = { secureHeadersNonce?: string @@ -54,6 +55,12 @@ interface ReportingEndpointOptions { url: string } +type PermissionsPolicyValue = '*' | 'self' | 'src' | 'none' | string + +type PermissionsPolicyOptions = Partial< + Record +> + type overridableHeader = boolean | string interface SecureHeadersOptions { @@ -73,6 +80,7 @@ interface SecureHeadersOptions { xPermittedCrossDomainPolicies?: overridableHeader xXssProtection?: overridableHeader removePoweredBy?: boolean + permissionsPolicy?: PermissionsPolicyOptions } type HeadersMap = { @@ -108,6 +116,7 @@ const DEFAULT_OPTIONS: SecureHeadersOptions = { xPermittedCrossDomainPolicies: true, xXssProtection: true, removePoweredBy: true, + permissionsPolicy: {}, } type SecureHeadersCallback = ( @@ -154,6 +163,7 @@ export const NONCE: ContentSecurityPolicyOptionHandler = (ctx) => { * @param {overridableHeader} [customOptions.xPermittedCrossDomainPolicies=true] - Settings for the X-Permitted-Cross-Domain-Policies header. * @param {overridableHeader} [customOptions.xXssProtection=true] - Settings for the X-XSS-Protection header. * @param {boolean} [customOptions.removePoweredBy=true] - Settings for remove X-Powered-By header. + * @param {PermissionsPolicyOptions} [customOptions.permissionsPolicy] - Settings for the Permissions-Policy header. * @returns {MiddlewareHandler} The middleware handler function. * * @example @@ -175,6 +185,13 @@ export const secureHeaders = (customOptions?: SecureHeadersOptions): MiddlewareH headersToSet.push(['Content-Security-Policy', value as string]) } + if (options.permissionsPolicy && Object.keys(options.permissionsPolicy).length > 0) { + headersToSet.push([ + 'Permissions-Policy', + getPermissionsPolicyDirectives(options.permissionsPolicy), + ]) + } + if (options.reportingEndpoints) { headersToSet.push(['Reporting-Endpoints', getReportingEndpoints(options.reportingEndpoints)]) } @@ -255,6 +272,30 @@ function getCSPDirectives( ] } +function getPermissionsPolicyDirectives(policy: PermissionsPolicyOptions): string { + return Object.entries(policy) + .map(([directive, value]) => { + const kebabDirective = camelToKebab(directive) + + if (typeof value === 'boolean') { + return `${kebabDirective}=${value ? '()' : 'none'}` + } + + if (Array.isArray(value)) { + const allowlist = value.length === 0 ? '()' : `(${value.join(' ')})` + return `${kebabDirective}=${allowlist}` + } + + return '' + }) + .filter(Boolean) + .join(', ') +} + +function camelToKebab(str: string): string { + return str.replace(/([a-z\d])([A-Z])/g, '$1-$2').toLowerCase() +} + function getReportingEndpoints( reportingEndpoints: SecureHeadersOptions['reportingEndpoints'] = [] ): string {