From 32e15fd53aa93ab9b44968e34ea95a09cc65e62c Mon Sep 17 00:00:00 2001 From: Latte Date: Tue, 21 Jul 2026 17:43:59 +0900 Subject: [PATCH 1/3] test(secure-headers): cover combined CSP callbacks --- .../secure-headers/combined-csp.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/middleware/secure-headers/combined-csp.test.ts diff --git a/src/middleware/secure-headers/combined-csp.test.ts b/src/middleware/secure-headers/combined-csp.test.ts new file mode 100644 index 0000000000..91cc4223d1 --- /dev/null +++ b/src/middleware/secure-headers/combined-csp.test.ts @@ -0,0 +1,53 @@ +import { Hono } from '../../hono' +import { NONCE, secureHeaders } from '.' + +describe('Secure Headers Middleware with combined CSP modes', () => { + it('keeps the enforced policy when report-only uses a nonce', async () => { + const app = new Hono() + app.use( + '*', + secureHeaders({ + contentSecurityPolicy: { + defaultSrc: ["'self'"], + }, + contentSecurityPolicyReportOnly: { + scriptSrc: ["'self'", NONCE], + }, + }) + ) + app.get('/', (c) => c.text('test')) + + const res = await app.request('/') + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Security-Policy')).toBe("default-src 'self'") + expect(res.headers.get('Content-Security-Policy-Report-Only')).toMatch( + /^script-src 'self' 'nonce-[a-zA-Z0-9+/]+=*'$/ + ) + }) + + it('supports nonces in both policies', async () => { + const app = new Hono() + app.use( + '*', + secureHeaders({ + contentSecurityPolicy: { + scriptSrc: ["'self'", NONCE], + }, + contentSecurityPolicyReportOnly: { + styleSrc: ["'self'", NONCE], + }, + }) + ) + app.get('/', (c) => c.text('test')) + + const res = await app.request('/') + const csp = res.headers.get('Content-Security-Policy') + const reportOnly = res.headers.get('Content-Security-Policy-Report-Only') + const nonce = csp?.match(/'nonce-([^']+)'/)?.[1] + + expect(res.status).toBe(200) + expect(nonce).toBeTruthy() + expect(reportOnly).toContain(`'nonce-${nonce}'`) + }) +}) From faf1e2d551013f240eb89496f6e8a2b120fd4ebe Mon Sep 17 00:00:00 2001 From: Latte Date: Tue, 21 Jul 2026 17:45:08 +0900 Subject: [PATCH 2/3] fix(secure-headers): isolate CSP callbacks by header --- .../secure-headers/secure-headers.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/middleware/secure-headers/secure-headers.ts b/src/middleware/secure-headers/secure-headers.ts index cb81a0fdf3..8e7eb4e624 100644 --- a/src/middleware/secure-headers/secure-headers.ts +++ b/src/middleware/secure-headers/secure-headers.ts @@ -182,7 +182,10 @@ export const secureHeaders = (customOptions?: SecureHeadersOptions): MiddlewareH const callbacks: SecureHeadersCallback[] = [] if (options.contentSecurityPolicy) { - const [callback, value] = getCSPDirectives(options.contentSecurityPolicy) + const [callback, value] = getCSPDirectives( + options.contentSecurityPolicy, + 'Content-Security-Policy' + ) if (callback) { callbacks.push(callback) } @@ -190,7 +193,10 @@ export const secureHeaders = (customOptions?: SecureHeadersOptions): MiddlewareH } if (options.contentSecurityPolicyReportOnly) { - const [callback, value] = getCSPDirectives(options.contentSecurityPolicyReportOnly) + const [callback, value] = getCSPDirectives( + options.contentSecurityPolicyReportOnly, + 'Content-Security-Policy-Report-Only' + ) if (callback) { callbacks.push(callback) } @@ -238,7 +244,8 @@ function getFilteredHeaders(options: SecureHeadersOptions): [string, string][] { } function getCSPDirectives( - contentSecurityPolicy: ContentSecurityPolicyOptions + contentSecurityPolicy: ContentSecurityPolicyOptions, + headerName: 'Content-Security-Policy' | 'Content-Security-Policy-Report-Only' ): [SecureHeadersCallback | undefined, string | string[]] { const callbacks: ((ctx: Context, values: string[]) => void)[] = [] const resultValues: string[] = [] @@ -270,10 +277,7 @@ function getCSPDirectives( : [ (ctx, headersToSet) => headersToSet.map((values) => { - if ( - values[0] === 'Content-Security-Policy' || - values[0] === 'Content-Security-Policy-Report-Only' - ) { + if (values[0] === headerName) { const clone = values[1].slice() as unknown as string[] callbacks.forEach((cb) => { cb(ctx, clone) From c3fbfbf29bd7a0f9c13a15f0b4954844021841b4 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Fri, 24 Jul 2026 17:40:00 +0900 Subject: [PATCH 3/3] update the tests --- .../secure-headers/combined-csp.test.ts | 53 ------------- src/middleware/secure-headers/index.test.ts | 75 +++++++++++++++++++ 2 files changed, 75 insertions(+), 53 deletions(-) delete mode 100644 src/middleware/secure-headers/combined-csp.test.ts diff --git a/src/middleware/secure-headers/combined-csp.test.ts b/src/middleware/secure-headers/combined-csp.test.ts deleted file mode 100644 index 91cc4223d1..0000000000 --- a/src/middleware/secure-headers/combined-csp.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Hono } from '../../hono' -import { NONCE, secureHeaders } from '.' - -describe('Secure Headers Middleware with combined CSP modes', () => { - it('keeps the enforced policy when report-only uses a nonce', async () => { - const app = new Hono() - app.use( - '*', - secureHeaders({ - contentSecurityPolicy: { - defaultSrc: ["'self'"], - }, - contentSecurityPolicyReportOnly: { - scriptSrc: ["'self'", NONCE], - }, - }) - ) - app.get('/', (c) => c.text('test')) - - const res = await app.request('/') - - expect(res.status).toBe(200) - expect(res.headers.get('Content-Security-Policy')).toBe("default-src 'self'") - expect(res.headers.get('Content-Security-Policy-Report-Only')).toMatch( - /^script-src 'self' 'nonce-[a-zA-Z0-9+/]+=*'$/ - ) - }) - - it('supports nonces in both policies', async () => { - const app = new Hono() - app.use( - '*', - secureHeaders({ - contentSecurityPolicy: { - scriptSrc: ["'self'", NONCE], - }, - contentSecurityPolicyReportOnly: { - styleSrc: ["'self'", NONCE], - }, - }) - ) - app.get('/', (c) => c.text('test')) - - const res = await app.request('/') - const csp = res.headers.get('Content-Security-Policy') - const reportOnly = res.headers.get('Content-Security-Policy-Report-Only') - const nonce = csp?.match(/'nonce-([^']+)'/)?.[1] - - expect(res.status).toBe(200) - expect(nonce).toBeTruthy() - expect(reportOnly).toContain(`'nonce-${nonce}'`) - }) -}) diff --git a/src/middleware/secure-headers/index.test.ts b/src/middleware/secure-headers/index.test.ts index b7cded3a8c..e4dc4c8fbe 100644 --- a/src/middleware/secure-headers/index.test.ts +++ b/src/middleware/secure-headers/index.test.ts @@ -481,6 +481,81 @@ describe('Secure Headers Middleware', () => { }) }) + describe('CSP with combined modes', () => { + it('keeps the enforced policy when report-only uses a nonce', async () => { + const app = new Hono() + app.use( + '/test', + secureHeaders({ + contentSecurityPolicy: { + defaultSrc: ["'self'"], + }, + contentSecurityPolicyReportOnly: { + scriptSrc: ["'self'", NONCE], + }, + }) + ) + app.all('*', (c) => c.text('test')) + + const res = await app.request('/test') + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Security-Policy')).toBe("default-src 'self'") + expect(res.headers.get('Content-Security-Policy-Report-Only')).toMatch( + /^script-src 'self' 'nonce-[a-zA-Z0-9+/]+=*'$/ + ) + }) + + it('keeps the report-only policy when the enforced policy uses a nonce', async () => { + const app = new Hono() + app.use( + '/test', + secureHeaders({ + contentSecurityPolicy: { + scriptSrc: ["'self'", NONCE], + }, + contentSecurityPolicyReportOnly: { + defaultSrc: ["'self'"], + }, + }) + ) + app.all('*', (c) => c.text('test')) + + const res = await app.request('/test') + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Security-Policy')).toMatch( + /^script-src 'self' 'nonce-[a-zA-Z0-9+/]+=*'$/ + ) + expect(res.headers.get('Content-Security-Policy-Report-Only')).toBe("default-src 'self'") + }) + + it('supports nonces in both policies', async () => { + const app = new Hono() + app.use( + '/test', + secureHeaders({ + contentSecurityPolicy: { + scriptSrc: ["'self'", NONCE], + }, + contentSecurityPolicyReportOnly: { + styleSrc: ["'self'", NONCE], + }, + }) + ) + app.all('*', (c) => c.text('test')) + + const res = await app.request('/test') + const csp = res.headers.get('Content-Security-Policy') + const reportOnly = res.headers.get('Content-Security-Policy-Report-Only') + const nonce = csp?.match(/'nonce-([^']+)'/)?.[1] + + expect(res.status).toBe(200) + expect(nonce).toBeTruthy() + expect(reportOnly).toContain(`'nonce-${nonce}'`) + }) + }) + // OUR NEW REPORT-URI TESTS describe('CSP report-uri directive', () => { it('should set report-uri with single endpoint', async () => {