0
0
mirror of https://github.com/honojs/hono.git synced 2024-11-21 18:18:57 +01:00

refactor: secure headers middleware (#2039)

This commit is contained in:
Aris Kemper 2024-01-19 23:34:31 +01:00 committed by GitHub
parent 0800accacd
commit 039cac90ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 88 additions and 56 deletions

View File

@ -1,3 +1,4 @@
import type { Context } from '../../context.ts'
import type { MiddlewareHandler } from '../../types.ts'
interface ContentSecurityPolicyOptions {
@ -96,46 +97,61 @@ const DEFAULT_OPTIONS: SecureHeadersOptions = {
export const secureHeaders = (customOptions?: Partial<SecureHeadersOptions>): MiddlewareHandler => {
const options = { ...DEFAULT_OPTIONS, ...customOptions }
const headersToSet = Object.entries(HEADERS_MAP)
.filter(([key]) => options[key as keyof SecureHeadersOptions])
.map(([key, defaultValue]) => {
const overrideValue = options[key as keyof SecureHeadersOptions]
if (typeof overrideValue === 'string') return [defaultValue[0], overrideValue]
return defaultValue
})
const headersToSet = getFilteredHeaders(options)
if (options.contentSecurityPolicy) {
const cspDirectives = Object.entries(options.contentSecurityPolicy)
.map(([directive, value]) => {
// convert camelCase to kebab-case directives (e.g. `defaultSrc` -> `default-src`)
directive = directive.replace(
/[A-Z]+(?![a-z])|[A-Z]/g,
(match, offset) => (offset ? '-' : '') + match.toLowerCase()
)
return `${directive} ${Array.isArray(value) ? value.join(' ') : value}`
})
.join('; ')
headersToSet.push(['Content-Security-Policy', cspDirectives])
headersToSet.push(['Content-Security-Policy', getCSPDirectives(options.contentSecurityPolicy)])
}
if (options.reportingEndpoints) {
const reportingEndpoints = options.reportingEndpoints
.map((endpoint) => `${endpoint.name}="${endpoint.url}"`)
.join(', ')
headersToSet.push(['Reporting-Endpoints', reportingEndpoints])
headersToSet.push(['Reporting-Endpoints', getReportingEndpoints(options.reportingEndpoints)])
}
if (options.reportTo) {
const reportToOptions = options.reportTo.map((option) => JSON.stringify(option)).join(', ')
headersToSet.push(['Report-To', reportToOptions])
headersToSet.push(['Report-To', getReportToOptions(options.reportTo)])
}
return async function secureHeaders(ctx, next) {
await next()
headersToSet.forEach(([header, value]) => {
ctx.res.headers.set(header, value)
})
setHeaders(ctx, headersToSet)
ctx.res.headers.delete('X-Powered-By')
}
}
function getFilteredHeaders(options: SecureHeadersOptions): [string, string][] {
return Object.entries(HEADERS_MAP)
.filter(([key]) => options[key as keyof SecureHeadersOptions])
.map(([key, defaultValue]) => {
const overrideValue = options[key as keyof SecureHeadersOptions]
return typeof overrideValue === 'string' ? [defaultValue[0], overrideValue] : defaultValue
})
}
function getCSPDirectives(
contentSecurityPolicy: SecureHeadersOptions['contentSecurityPolicy']
): string {
return Object.entries(contentSecurityPolicy || [])
.map(([directive, value]) => {
const kebabCaseDirective = directive.replace(/[A-Z]+(?![a-z])|[A-Z]/g, (match, offset) =>
offset ? '-' + match.toLowerCase() : match.toLowerCase()
)
return `${kebabCaseDirective} ${Array.isArray(value) ? value.join(' ') : value}`
})
.join('; ')
}
function getReportingEndpoints(
reportingEndpoints: SecureHeadersOptions['reportingEndpoints'] = []
): string {
return reportingEndpoints.map((endpoint) => `${endpoint.name}="${endpoint.url}"`).join(', ')
}
function getReportToOptions(reportTo: SecureHeadersOptions['reportTo'] = []): string {
return reportTo.map((option) => JSON.stringify(option)).join(', ')
}
function setHeaders(ctx: Context, headersToSet: [string, string][]) {
headersToSet.forEach(([header, value]) => {
ctx.res.headers.set(header, value)
})
}

View File

@ -1,3 +1,4 @@
import type { Context } from '../../context'
import type { MiddlewareHandler } from '../../types'
interface ContentSecurityPolicyOptions {
@ -96,46 +97,61 @@ const DEFAULT_OPTIONS: SecureHeadersOptions = {
export const secureHeaders = (customOptions?: Partial<SecureHeadersOptions>): MiddlewareHandler => {
const options = { ...DEFAULT_OPTIONS, ...customOptions }
const headersToSet = Object.entries(HEADERS_MAP)
.filter(([key]) => options[key as keyof SecureHeadersOptions])
.map(([key, defaultValue]) => {
const overrideValue = options[key as keyof SecureHeadersOptions]
if (typeof overrideValue === 'string') return [defaultValue[0], overrideValue]
return defaultValue
})
const headersToSet = getFilteredHeaders(options)
if (options.contentSecurityPolicy) {
const cspDirectives = Object.entries(options.contentSecurityPolicy)
.map(([directive, value]) => {
// convert camelCase to kebab-case directives (e.g. `defaultSrc` -> `default-src`)
directive = directive.replace(
/[A-Z]+(?![a-z])|[A-Z]/g,
(match, offset) => (offset ? '-' : '') + match.toLowerCase()
)
return `${directive} ${Array.isArray(value) ? value.join(' ') : value}`
})
.join('; ')
headersToSet.push(['Content-Security-Policy', cspDirectives])
headersToSet.push(['Content-Security-Policy', getCSPDirectives(options.contentSecurityPolicy)])
}
if (options.reportingEndpoints) {
const reportingEndpoints = options.reportingEndpoints
.map((endpoint) => `${endpoint.name}="${endpoint.url}"`)
.join(', ')
headersToSet.push(['Reporting-Endpoints', reportingEndpoints])
headersToSet.push(['Reporting-Endpoints', getReportingEndpoints(options.reportingEndpoints)])
}
if (options.reportTo) {
const reportToOptions = options.reportTo.map((option) => JSON.stringify(option)).join(', ')
headersToSet.push(['Report-To', reportToOptions])
headersToSet.push(['Report-To', getReportToOptions(options.reportTo)])
}
return async function secureHeaders(ctx, next) {
await next()
headersToSet.forEach(([header, value]) => {
ctx.res.headers.set(header, value)
})
setHeaders(ctx, headersToSet)
ctx.res.headers.delete('X-Powered-By')
}
}
function getFilteredHeaders(options: SecureHeadersOptions): [string, string][] {
return Object.entries(HEADERS_MAP)
.filter(([key]) => options[key as keyof SecureHeadersOptions])
.map(([key, defaultValue]) => {
const overrideValue = options[key as keyof SecureHeadersOptions]
return typeof overrideValue === 'string' ? [defaultValue[0], overrideValue] : defaultValue
})
}
function getCSPDirectives(
contentSecurityPolicy: SecureHeadersOptions['contentSecurityPolicy']
): string {
return Object.entries(contentSecurityPolicy || [])
.map(([directive, value]) => {
const kebabCaseDirective = directive.replace(/[A-Z]+(?![a-z])|[A-Z]/g, (match, offset) =>
offset ? '-' + match.toLowerCase() : match.toLowerCase()
)
return `${kebabCaseDirective} ${Array.isArray(value) ? value.join(' ') : value}`
})
.join('; ')
}
function getReportingEndpoints(
reportingEndpoints: SecureHeadersOptions['reportingEndpoints'] = []
): string {
return reportingEndpoints.map((endpoint) => `${endpoint.name}="${endpoint.url}"`).join(', ')
}
function getReportToOptions(reportTo: SecureHeadersOptions['reportTo'] = []): string {
return reportTo.map((option) => JSON.stringify(option)).join(', ')
}
function setHeaders(ctx: Context, headersToSet: [string, string][]) {
headersToSet.forEach(([header, value]) => {
ctx.res.headers.set(header, value)
})
}