mirror of
https://github.com/honojs/hono.git
synced 2024-11-24 11:07:29 +01:00
feat(secureHeader): add Permissions-Policy header to secure headers middleware (#3314)
* feat(secureHeader): add Permissions-Policy header to secure headers middleware * refactor(secure-headers): optimize getPermissionsPolicyDirectives function
This commit is contained in:
parent
f9a23a9992
commit
5a25e33e93
@ -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(
|
||||
|
86
src/middleware/secure-headers/permissions-policy.ts
Normal file
86
src/middleware/secure-headers/permissions-policy.ts
Normal file
@ -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'
|
@ -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<PermissionsPolicyDirective, PermissionsPolicyValue[] | boolean>
|
||||
>
|
||||
|
||||
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 {
|
||||
|
Loading…
Reference in New Issue
Block a user