Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(secure-headers): enable to set nonce in CSP #2577

Merged
merged 7 commits into from
May 1, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
139 changes: 108 additions & 31 deletions deno_dist/middleware/secure-headers/index.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,40 @@
import { Buffer } from "node:buffer";
import type { Context } from '../../context.ts'
import type { MiddlewareHandler } from '../../types.ts'

declare module '../../context.ts' {
interface ContextVariableMap {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
yusukebe marked this conversation as resolved.
Show resolved Hide resolved
secureHeadersNonce?: string
}
}

type ContentSecurityPolicyOptionHandler = (ctx: Context, directive: string) => string
yusukebe marked this conversation as resolved.
Show resolved Hide resolved
type ContentSecurityPolicyOptionValue = (string | ContentSecurityPolicyOptionHandler)[]

interface ContentSecurityPolicyOptions {
defaultSrc?: string[]
baseUri?: string[]
childSrc?: string[]
connectSrc?: string[]
fontSrc?: string[]
formAction?: string[]
frameAncestors?: string[]
frameSrc?: string[]
imgSrc?: string[]
manifestSrc?: string[]
mediaSrc?: string[]
objectSrc?: string[]
defaultSrc?: ContentSecurityPolicyOptionValue
baseUri?: ContentSecurityPolicyOptionValue
childSrc?: ContentSecurityPolicyOptionValue
connectSrc?: ContentSecurityPolicyOptionValue
fontSrc?: ContentSecurityPolicyOptionValue
formAction?: ContentSecurityPolicyOptionValue
frameAncestors?: ContentSecurityPolicyOptionValue
frameSrc?: ContentSecurityPolicyOptionValue
imgSrc?: ContentSecurityPolicyOptionValue
manifestSrc?: ContentSecurityPolicyOptionValue
mediaSrc?: ContentSecurityPolicyOptionValue
objectSrc?: ContentSecurityPolicyOptionValue
reportTo?: string
sandbox?: string[]
scriptSrc?: string[]
scriptSrcAttr?: string[]
scriptSrcElem?: string[]
styleSrc?: string[]
styleSrcAttr?: string[]
styleSrcElem?: string[]
upgradeInsecureRequests?: string[]
workerSrc?: string[]
sandbox?: ContentSecurityPolicyOptionValue
scriptSrc?: ContentSecurityPolicyOptionValue
scriptSrcAttr?: ContentSecurityPolicyOptionValue
scriptSrcElem?: ContentSecurityPolicyOptionValue
styleSrc?: ContentSecurityPolicyOptionValue
styleSrcAttr?: ContentSecurityPolicyOptionValue
styleSrcElem?: ContentSecurityPolicyOptionValue
upgradeInsecureRequests?: ContentSecurityPolicyOptionValue
workerSrc?: ContentSecurityPolicyOptionValue
}

interface ReportToOptions {
Expand Down Expand Up @@ -95,12 +106,38 @@ const DEFAULT_OPTIONS: SecureHeadersOptions = {
xXssProtection: true,
}

type SecureHeadersCallback = (
ctx: Context,
headersToSet: [string, string | string[]][]
) => [string, string][]

const generateNonce = () => {
const buffer = new Uint8Array(16)
crypto.getRandomValues(buffer)
return Buffer.from(buffer).toString('base64')
}
export const NONCE: ContentSecurityPolicyOptionHandler = (ctx) => {
const nonce =
ctx.get('secureHeadersNonce') ||
(() => {
const newNonce = generateNonce()
ctx.set('secureHeadersNonce', newNonce)
return newNonce
})()
return `'nonce-${nonce}'`
}

export const secureHeaders = (customOptions?: Partial<SecureHeadersOptions>): MiddlewareHandler => {
const options = { ...DEFAULT_OPTIONS, ...customOptions }
const headersToSet = getFilteredHeaders(options)
const callbacks: SecureHeadersCallback[] = []

if (options.contentSecurityPolicy) {
headersToSet.push(['Content-Security-Policy', getCSPDirectives(options.contentSecurityPolicy)])
const [callback, value] = getCSPDirectives(options.contentSecurityPolicy)
if (callback) {
callbacks.push(callback)
}
headersToSet.push(['Content-Security-Policy', value as string])
}

if (options.reportingEndpoints) {
Expand All @@ -112,8 +149,14 @@ export const secureHeaders = (customOptions?: Partial<SecureHeadersOptions>): Mi
}

return async function secureHeaders(ctx, next) {
// should evaluate callbacks before next()
// some callback calls ctx.set() for embedding nonce to the page
const headersToSetForReq =
callbacks.length === 0
? headersToSet
: callbacks.reduce((acc, cb) => cb(ctx, acc), headersToSet)
await next()
setHeaders(ctx, headersToSet)
setHeaders(ctx, headersToSetForReq)
ctx.res.headers.delete('X-Powered-By')
}
}
Expand All @@ -129,15 +172,49 @@ function getFilteredHeaders(options: SecureHeadersOptions): [string, string][] {

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}`
): [SecureHeadersCallback | undefined, string | string[]] {
const callbacks: ((ctx: Context, values: string[]) => void)[] = []
const resultValues: string[] = []

for (const [directive, value] of Object.entries(contentSecurityPolicy as any)) {
const valueArray = Array.isArray(value) ? value : [value]

valueArray.forEach((value, i) => {
if (typeof value === 'function') {
const index = i * 2 + 2 + resultValues.length
callbacks.push((ctx, values) => {
values[index] = value(ctx, directive)
})
}
})
.join('; ')

resultValues.push(
directive.replace(/[A-Z]+(?![a-z])|[A-Z]/g, (match, offset) =>
offset ? '-' + match.toLowerCase() : match.toLowerCase()
),
...valueArray.flatMap((value) => [' ', value]),
'; '
)
}
resultValues.pop()

return callbacks.length === 0
? [undefined, resultValues.join('')]
: [
(ctx, headersToSet) =>
headersToSet.map((values) => {
if (values[0] === 'Content-Security-Policy') {
const clone = values[1].slice() as unknown as string[]
callbacks.forEach((cb) => {
cb(ctx, clone)
})
return [values[0], clone.join('')]
} else {
return values as [string, string]
}
}),
resultValues,
]
}

function getReportingEndpoints(
Expand Down
47 changes: 46 additions & 1 deletion src/middleware/secure-headers/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable quotes */
import { Hono } from '../../hono'
import { poweredBy } from '../powered-by'
import { secureHeaders } from '.'
import { secureHeaders, NONCE } from '.'

describe('Secure Headers Middleware', () => {
it('default middleware', async () => {
Expand Down Expand Up @@ -322,4 +322,49 @@ describe('Secure Headers Middleware', () => {
)
expect(res4.headers.get('Content-Security-Policy')).toEqual("default-src 'self'; report-to e1")
})

it('CSP nonce for script-src', async () => {
const app = new Hono()
app.use(
'/test',
secureHeaders({
contentSecurityPolicy: {
scriptSrc: ["'self'", NONCE],
},
})
)

app.all('*', async (c) => {
return c.text(`nonce: ${c.get('secureHeadersNonce')}`)
})

const res = await app.request('/test')
const csp = res.headers.get('Content-Security-Policy')
const nonce = csp?.match(/script-src 'self' 'nonce-([a-zA-Z0-9+/]+=*)'/)?.[1] || ''
expect(csp).toMatch(`script-src 'self' 'nonce-${nonce}'`)
expect(await res.text()).toEqual(`nonce: ${nonce}`)
})

it('CSP nonce for script-src and style-src', async () => {
const app = new Hono()
app.use(
'/test',
secureHeaders({
contentSecurityPolicy: {
scriptSrc: ["'self'", NONCE],
styleSrc: ["'self'", NONCE],
},
})
)

app.all('*', async (c) => {
return c.text(`nonce: ${c.get('secureHeadersNonce')}`)
})

const res = await app.request('/test')
const csp = res.headers.get('Content-Security-Policy')
const nonce = csp?.match(/script-src 'self' 'nonce-([a-zA-Z0-9+/]+=*)'/)?.[1] || ''
expect(csp).toMatch(`script-src 'self' 'nonce-${nonce}'`)
expect(csp).toMatch(`style-src 'self' 'nonce-${nonce}'`)
})
})