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 all 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
140 changes: 108 additions & 32 deletions deno_dist/middleware/secure-headers/index.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,39 @@
import { Buffer } from "node:buffer";
import type { Context } from '../../context.ts'
import type { MiddlewareHandler } from '../../types.ts'

declare module '../../context.ts' {
interface ContextVariableMap {
secureHeadersNonce?: string
}
}

export type ContentSecurityPolicyOptionHandler = (ctx: Context, directive: string) => string
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 +105,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 +148,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 @@ -128,16 +170,50 @@ 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}`
contentSecurityPolicy: ContentSecurityPolicyOptions
): [SecureHeadersCallback | undefined, string | string[]] {
const callbacks: ((ctx: Context, values: string[]) => void)[] = []
const resultValues: string[] = []

for (const [directive, value] of Object.entries(contentSecurityPolicy)) {
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
85 changes: 84 additions & 1 deletion src/middleware/secure-headers/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
/* eslint-disable quotes */
import { Hono } from '../../hono'
import { poweredBy } from '../powered-by'
import { secureHeaders } from '.'
import { secureHeaders, NONCE } from '.'
import type { ContentSecurityPolicyOptionHandler } from '.'

declare module '../../context' {
interface ContextVariableMap {
['test-scriptSrc-nonce']?: string
['test-styleSrc-nonce']?: string
}
}

describe('Secure Headers Middleware', () => {
it('default middleware', async () => {
Expand Down Expand Up @@ -322,4 +330,79 @@ 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}'`)
expect(await res.text()).toEqual(`nonce: ${nonce}`)
})

it('CSP nonce by app own function', async () => {
const app = new Hono()
const setNonce: ContentSecurityPolicyOptionHandler = (ctx, directive) => {
ctx.set(`test-${directive}-nonce`, directive)
return `'nonce-${directive}'`
}
app.use(
'/test',
secureHeaders({
contentSecurityPolicy: {
scriptSrc: ["'self'", setNonce],
styleSrc: ["'self'", setNonce],
},
})
)

app.all('*', async (c) => {
return c.text(
`script: ${c.get('test-scriptSrc-nonce')}, style: ${c.get('test-styleSrc-nonce')}`
)
})

const res = await app.request('/test')
const csp = res.headers.get('Content-Security-Policy')
expect(csp).toMatch(`script-src 'self' 'nonce-scriptSrc'`)
expect(csp).toMatch(`style-src 'self' 'nonce-styleSrc'`)
expect(await res.text()).toEqual('script: scriptSrc, style: styleSrc')
})
})