Skip to content

Commit 402eb3a

Browse files
lasder-cayusukebe
andauthored
fix(secure-headers): keep CSP callbacks scoped to their header (#5147)
* test(secure-headers): cover combined CSP callbacks * fix(secure-headers): isolate CSP callbacks by header * update the tests --------- Co-authored-by: Yusuke Wada <yusuke@kamawada.com>
1 parent c85aead commit 402eb3a

2 files changed

Lines changed: 86 additions & 7 deletions

File tree

src/middleware/secure-headers/index.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,81 @@ describe('Secure Headers Middleware', () => {
481481
})
482482
})
483483

484+
describe('CSP with combined modes', () => {
485+
it('keeps the enforced policy when report-only uses a nonce', async () => {
486+
const app = new Hono()
487+
app.use(
488+
'/test',
489+
secureHeaders({
490+
contentSecurityPolicy: {
491+
defaultSrc: ["'self'"],
492+
},
493+
contentSecurityPolicyReportOnly: {
494+
scriptSrc: ["'self'", NONCE],
495+
},
496+
})
497+
)
498+
app.all('*', (c) => c.text('test'))
499+
500+
const res = await app.request('/test')
501+
502+
expect(res.status).toBe(200)
503+
expect(res.headers.get('Content-Security-Policy')).toBe("default-src 'self'")
504+
expect(res.headers.get('Content-Security-Policy-Report-Only')).toMatch(
505+
/^script-src 'self' 'nonce-[a-zA-Z0-9+/]+=*'$/
506+
)
507+
})
508+
509+
it('keeps the report-only policy when the enforced policy uses a nonce', async () => {
510+
const app = new Hono()
511+
app.use(
512+
'/test',
513+
secureHeaders({
514+
contentSecurityPolicy: {
515+
scriptSrc: ["'self'", NONCE],
516+
},
517+
contentSecurityPolicyReportOnly: {
518+
defaultSrc: ["'self'"],
519+
},
520+
})
521+
)
522+
app.all('*', (c) => c.text('test'))
523+
524+
const res = await app.request('/test')
525+
526+
expect(res.status).toBe(200)
527+
expect(res.headers.get('Content-Security-Policy')).toMatch(
528+
/^script-src 'self' 'nonce-[a-zA-Z0-9+/]+=*'$/
529+
)
530+
expect(res.headers.get('Content-Security-Policy-Report-Only')).toBe("default-src 'self'")
531+
})
532+
533+
it('supports nonces in both policies', async () => {
534+
const app = new Hono()
535+
app.use(
536+
'/test',
537+
secureHeaders({
538+
contentSecurityPolicy: {
539+
scriptSrc: ["'self'", NONCE],
540+
},
541+
contentSecurityPolicyReportOnly: {
542+
styleSrc: ["'self'", NONCE],
543+
},
544+
})
545+
)
546+
app.all('*', (c) => c.text('test'))
547+
548+
const res = await app.request('/test')
549+
const csp = res.headers.get('Content-Security-Policy')
550+
const reportOnly = res.headers.get('Content-Security-Policy-Report-Only')
551+
const nonce = csp?.match(/'nonce-([^']+)'/)?.[1]
552+
553+
expect(res.status).toBe(200)
554+
expect(nonce).toBeTruthy()
555+
expect(reportOnly).toContain(`'nonce-${nonce}'`)
556+
})
557+
})
558+
484559
// OUR NEW REPORT-URI TESTS
485560
describe('CSP report-uri directive', () => {
486561
it('should set report-uri with single endpoint', async () => {

src/middleware/secure-headers/secure-headers.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -182,15 +182,21 @@ export const secureHeaders = (customOptions?: SecureHeadersOptions): MiddlewareH
182182
const callbacks: SecureHeadersCallback[] = []
183183

184184
if (options.contentSecurityPolicy) {
185-
const [callback, value] = getCSPDirectives(options.contentSecurityPolicy)
185+
const [callback, value] = getCSPDirectives(
186+
options.contentSecurityPolicy,
187+
'Content-Security-Policy'
188+
)
186189
if (callback) {
187190
callbacks.push(callback)
188191
}
189192
headersToSet.push(['Content-Security-Policy', value as string])
190193
}
191194

192195
if (options.contentSecurityPolicyReportOnly) {
193-
const [callback, value] = getCSPDirectives(options.contentSecurityPolicyReportOnly)
196+
const [callback, value] = getCSPDirectives(
197+
options.contentSecurityPolicyReportOnly,
198+
'Content-Security-Policy-Report-Only'
199+
)
194200
if (callback) {
195201
callbacks.push(callback)
196202
}
@@ -238,7 +244,8 @@ function getFilteredHeaders(options: SecureHeadersOptions): [string, string][] {
238244
}
239245

240246
function getCSPDirectives(
241-
contentSecurityPolicy: ContentSecurityPolicyOptions
247+
contentSecurityPolicy: ContentSecurityPolicyOptions,
248+
headerName: 'Content-Security-Policy' | 'Content-Security-Policy-Report-Only'
242249
): [SecureHeadersCallback | undefined, string | string[]] {
243250
const callbacks: ((ctx: Context, values: string[]) => void)[] = []
244251
const resultValues: string[] = []
@@ -270,10 +277,7 @@ function getCSPDirectives(
270277
: [
271278
(ctx, headersToSet) =>
272279
headersToSet.map((values) => {
273-
if (
274-
values[0] === 'Content-Security-Policy' ||
275-
values[0] === 'Content-Security-Policy-Report-Only'
276-
) {
280+
if (values[0] === headerName) {
277281
const clone = values[1].slice() as unknown as string[]
278282
callbacks.forEach((cb) => {
279283
cb(ctx, clone)

0 commit comments

Comments
 (0)