0
0
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:
kbkn3 2024-09-08 15:43:50 +09:00 committed by GitHub
parent f9a23a9992
commit 5a25e33e93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 155 additions and 0 deletions

View File

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

View 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'

View File

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