From 8130a67da1dceb3a6faca4359ec34630ffba4c9f Mon Sep 17 00:00:00 2001 From: Hiroki Isogai Date: Tue, 24 Sep 2024 12:01:05 +0900 Subject: [PATCH] feat(secureHeader): add CSP Report-Only mode support (#3413) --- src/middleware/secure-headers/index.test.ts | 512 +++++++++--------- .../secure-headers/secure-headers.ts | 15 +- 2 files changed, 275 insertions(+), 252 deletions(-) diff --git a/src/middleware/secure-headers/index.test.ts b/src/middleware/secure-headers/index.test.ts index 5d055830..0dbccd98 100644 --- a/src/middleware/secure-headers/index.test.ts +++ b/src/middleware/secure-headers/index.test.ts @@ -38,6 +38,7 @@ describe('Secure Headers Middleware', () => { expect(res.headers.get('Origin-Agent-Cluster')).toEqual('?1') expect(res.headers.get('Permissions-Policy')).toBeNull() expect(res.headers.get('Content-Security-Policy')).toBeFalsy() + expect(res.headers.get('Content-Security-Policy-ReportOnly')).toBeFalsy() }) it('all headers enabled', async () => { @@ -48,6 +49,9 @@ describe('Secure Headers Middleware', () => { contentSecurityPolicy: { defaultSrc: ["'self'"], }, + contentSecurityPolicyReportOnly: { + defaultSrc: ["'self'"], + }, crossOriginEmbedderPolicy: true, permissionsPolicy: { camera: [], @@ -78,6 +82,7 @@ describe('Secure Headers Middleware', () => { expect(res.headers.get('Cross-Origin-Embedder-Policy')).toEqual('require-corp') expect(res.headers.get('Permissions-Policy')).toEqual('camera=()') expect(res.headers.get('Content-Security-Policy')).toEqual("default-src 'self'") + expect(res.headers.get('Content-Security-Policy-Report-Only')).toEqual("default-src 'self'") }) it('specific headers disabled', async () => { @@ -189,257 +194,6 @@ describe('Secure Headers Middleware', () => { 'magnetometer=("https://a.example.com" "https://b.example.com")' ) }) - it('CSP Setting', async () => { - const app = new Hono() - app.use( - '/test', - secureHeaders({ - contentSecurityPolicy: { - defaultSrc: ["'self'"], - baseUri: ["'self'"], - fontSrc: ["'self'", 'https:', 'data:'], - frameAncestors: ["'self'"], - imgSrc: ["'self'", 'data:'], - objectSrc: ["'none'"], - scriptSrc: ["'self'"], - scriptSrcAttr: ["'none'"], - styleSrc: ["'self'", 'https:', "'unsafe-inline'"], - }, - }) - ) - - app.all('*', async (c) => { - c.res.headers.set('Strict-Transport-Security', 'Hono') - return c.text('header updated') - }) - - const res = await app.request('/test') - expect(res.headers.get('Content-Security-Policy')).toEqual( - "default-src 'self'; base-uri 'self'; font-src 'self' https: data:; frame-ancestors 'self'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'" - ) - }) - - it('CSP Setting one only', async () => { - const app = new Hono() - app.use( - '/test', - secureHeaders({ - contentSecurityPolicy: { - defaultSrc: ["'self'"], - }, - }) - ) - - app.all('*', async (c) => { - return c.text('header updated') - }) - - const res = await app.request('/test') - expect(res.headers.get('Content-Security-Policy')).toEqual("default-src 'self'") - }) - - it('No CSP Setting', async () => { - const app = new Hono() - app.use('/test', secureHeaders({ contentSecurityPolicy: {} })) - - app.all('*', async (c) => { - return c.text('header updated') - }) - - const res = await app.request('/test') - expect(res.headers.get('Content-Security-Policy')).toEqual('') - }) - - it('CSP with reportTo', async () => { - const app = new Hono() - app.use( - '/test1', - secureHeaders({ - reportingEndpoints: [ - { - name: 'endpoint-1', - url: 'https://example.com/reports', - }, - ], - contentSecurityPolicy: { - defaultSrc: ["'self'"], - reportTo: 'endpoint-1', - }, - }) - ) - - app.use( - '/test2', - secureHeaders({ - reportTo: [ - { - group: 'endpoint-1', - max_age: 10886400, - endpoints: [{ url: 'https://example.com/reports' }], - }, - ], - contentSecurityPolicy: { - defaultSrc: ["'self'"], - reportTo: 'endpoint-1', - }, - }) - ) - - app.use( - '/test3', - secureHeaders({ - reportTo: [ - { - group: 'g1', - max_age: 10886400, - endpoints: [ - { url: 'https://a.example.com/reports' }, - { url: 'https://b.example.com/reports' }, - ], - }, - { - group: 'g2', - max_age: 10886400, - endpoints: [ - { url: 'https://c.example.com/reports' }, - { url: 'https://d.example.com/reports' }, - ], - }, - ], - contentSecurityPolicy: { - defaultSrc: ["'self'"], - reportTo: 'g2', - }, - }) - ) - - app.use( - '/test4', - secureHeaders({ - reportingEndpoints: [ - { - name: 'e1', - url: 'https://a.example.com/reports', - }, - { - name: 'e2', - url: 'https://b.example.com/reports', - }, - ], - contentSecurityPolicy: { - defaultSrc: ["'self'"], - reportTo: 'e1', - }, - }) - ) - - app.all('*', async (c) => { - return c.text('header updated') - }) - - const res1 = await app.request('/test1') - expect(res1.headers.get('Reporting-Endpoints')).toEqual( - 'endpoint-1="https://example.com/reports"' - ) - expect(res1.headers.get('Content-Security-Policy')).toEqual( - "default-src 'self'; report-to endpoint-1" - ) - - const res2 = await app.request('/test2') - expect(res2.headers.get('Report-To')).toEqual( - '{"group":"endpoint-1","max_age":10886400,"endpoints":[{"url":"https://example.com/reports"}]}' - ) - expect(res2.headers.get('Content-Security-Policy')).toEqual( - "default-src 'self'; report-to endpoint-1" - ) - - const res3 = await app.request('/test3') - expect(res3.headers.get('Report-To')).toEqual( - '{"group":"g1","max_age":10886400,"endpoints":[{"url":"https://a.example.com/reports"},{"url":"https://b.example.com/reports"}]}, {"group":"g2","max_age":10886400,"endpoints":[{"url":"https://c.example.com/reports"},{"url":"https://d.example.com/reports"}]}' - ) - expect(res3.headers.get('Content-Security-Policy')).toEqual("default-src 'self'; report-to g2") - - const res4 = await app.request('/test4') - expect(res4.headers.get('Reporting-Endpoints')).toEqual( - 'e1="https://a.example.com/reports", e2="https://b.example.com/reports"' - ) - 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') - }) it('Remove X-Powered-By', async () => { const app = new Hono() @@ -469,4 +223,260 @@ describe('Secure Headers Middleware', () => { expect(poweredby2).toEqual('Hono') expect(await res2.text()).toEqual('Hono is cool') }) + + describe.each([ + { cspSettingName: 'contentSecurityPolicy', cspHeaderName: 'Content-Security-Policy' }, + { + cspSettingName: 'contentSecurityPolicyReportOnly', + cspHeaderName: 'Content-Security-Policy-Report-Only', + }, + ])('CSP Setting ($cspSettingName)', ({ cspSettingName, cspHeaderName }) => { + it('CSP Setting', async () => { + const app = new Hono() + app.use( + '/test', + secureHeaders({ + [cspSettingName]: { + defaultSrc: ["'self'"], + baseUri: ["'self'"], + fontSrc: ["'self'", 'https:', 'data:'], + frameAncestors: ["'self'"], + imgSrc: ["'self'", 'data:'], + objectSrc: ["'none'"], + scriptSrc: ["'self'"], + scriptSrcAttr: ["'none'"], + styleSrc: ["'self'", 'https:', "'unsafe-inline'"], + }, + }) + ) + + app.all('*', async (c) => { + c.res.headers.set('Strict-Transport-Security', 'Hono') + return c.text('header updated') + }) + + const res = await app.request('/test') + expect(res.headers.get(cspHeaderName)).toEqual( + "default-src 'self'; base-uri 'self'; font-src 'self' https: data:; frame-ancestors 'self'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'" + ) + }) + + it('CSP Setting one only', async () => { + const app = new Hono() + app.use( + '/test', + secureHeaders({ + [cspSettingName]: { + defaultSrc: ["'self'"], + }, + }) + ) + + app.all('*', async (c) => { + return c.text('header updated') + }) + + const res = await app.request('/test') + expect(res.headers.get(cspHeaderName)).toEqual("default-src 'self'") + }) + + it('No CSP Setting', async () => { + const app = new Hono() + app.use('/test', secureHeaders({ [cspSettingName]: {} })) + + app.all('*', async (c) => { + return c.text('header updated') + }) + + const res = await app.request('/test') + expect(res.headers.get(cspHeaderName)).toEqual('') + }) + + it('CSP with reportTo', async () => { + const app = new Hono() + app.use( + '/test1', + secureHeaders({ + reportingEndpoints: [ + { + name: 'endpoint-1', + url: 'https://example.com/reports', + }, + ], + [cspSettingName]: { + defaultSrc: ["'self'"], + reportTo: 'endpoint-1', + }, + }) + ) + + app.use( + '/test2', + secureHeaders({ + reportTo: [ + { + group: 'endpoint-1', + max_age: 10886400, + endpoints: [{ url: 'https://example.com/reports' }], + }, + ], + [cspSettingName]: { + defaultSrc: ["'self'"], + reportTo: 'endpoint-1', + }, + }) + ) + + app.use( + '/test3', + secureHeaders({ + reportTo: [ + { + group: 'g1', + max_age: 10886400, + endpoints: [ + { url: 'https://a.example.com/reports' }, + { url: 'https://b.example.com/reports' }, + ], + }, + { + group: 'g2', + max_age: 10886400, + endpoints: [ + { url: 'https://c.example.com/reports' }, + { url: 'https://d.example.com/reports' }, + ], + }, + ], + [cspSettingName]: { + defaultSrc: ["'self'"], + reportTo: 'g2', + }, + }) + ) + + app.use( + '/test4', + secureHeaders({ + reportingEndpoints: [ + { + name: 'e1', + url: 'https://a.example.com/reports', + }, + { + name: 'e2', + url: 'https://b.example.com/reports', + }, + ], + [cspSettingName]: { + defaultSrc: ["'self'"], + reportTo: 'e1', + }, + }) + ) + + app.all('*', async (c) => { + return c.text('header updated') + }) + + const res1 = await app.request('/test1') + expect(res1.headers.get('Reporting-Endpoints')).toEqual( + 'endpoint-1="https://example.com/reports"' + ) + expect(res1.headers.get(cspHeaderName)).toEqual("default-src 'self'; report-to endpoint-1") + + const res2 = await app.request('/test2') + expect(res2.headers.get('Report-To')).toEqual( + '{"group":"endpoint-1","max_age":10886400,"endpoints":[{"url":"https://example.com/reports"}]}' + ) + expect(res2.headers.get(cspHeaderName)).toEqual("default-src 'self'; report-to endpoint-1") + + const res3 = await app.request('/test3') + expect(res3.headers.get('Report-To')).toEqual( + '{"group":"g1","max_age":10886400,"endpoints":[{"url":"https://a.example.com/reports"},{"url":"https://b.example.com/reports"}]}, {"group":"g2","max_age":10886400,"endpoints":[{"url":"https://c.example.com/reports"},{"url":"https://d.example.com/reports"}]}' + ) + expect(res3.headers.get(cspHeaderName)).toEqual("default-src 'self'; report-to g2") + + const res4 = await app.request('/test4') + expect(res4.headers.get('Reporting-Endpoints')).toEqual( + 'e1="https://a.example.com/reports", e2="https://b.example.com/reports"' + ) + expect(res4.headers.get(cspHeaderName)).toEqual("default-src 'self'; report-to e1") + }) + + it('CSP nonce for script-src', async () => { + const app = new Hono() + app.use( + '/test', + secureHeaders({ + [cspSettingName]: { + 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(cspHeaderName) + 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({ + [cspSettingName]: { + 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(cspHeaderName) + 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({ + [cspSettingName]: { + 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(cspHeaderName) + 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') + }) + }) }) diff --git a/src/middleware/secure-headers/secure-headers.ts b/src/middleware/secure-headers/secure-headers.ts index c29515d5..be84609f 100644 --- a/src/middleware/secure-headers/secure-headers.ts +++ b/src/middleware/secure-headers/secure-headers.ts @@ -65,6 +65,7 @@ type overridableHeader = boolean | string interface SecureHeadersOptions { contentSecurityPolicy?: ContentSecurityPolicyOptions + contentSecurityPolicyReportOnly?: ContentSecurityPolicyOptions crossOriginEmbedderPolicy?: overridableHeader crossOriginResourcePolicy?: overridableHeader crossOriginOpenerPolicy?: overridableHeader @@ -148,6 +149,7 @@ export const NONCE: ContentSecurityPolicyOptionHandler = (ctx) => { * * @param {Partial} [customOptions] - The options for the secure headers middleware. * @param {ContentSecurityPolicyOptions} [customOptions.contentSecurityPolicy] - Settings for the Content-Security-Policy header. + * @param {ContentSecurityPolicyOptions} [customOptions.contentSecurityPolicyReportOnly] - Settings for the Content-Security-Policy-Report-Only header. * @param {overridableHeader} [customOptions.crossOriginEmbedderPolicy=false] - Settings for the Cross-Origin-Embedder-Policy header. * @param {overridableHeader} [customOptions.crossOriginResourcePolicy=true] - Settings for the Cross-Origin-Resource-Policy header. * @param {overridableHeader} [customOptions.crossOriginOpenerPolicy=true] - Settings for the Cross-Origin-Opener-Policy header. @@ -185,6 +187,14 @@ export const secureHeaders = (customOptions?: SecureHeadersOptions): MiddlewareH headersToSet.push(['Content-Security-Policy', value as string]) } + if (options.contentSecurityPolicyReportOnly) { + const [callback, value] = getCSPDirectives(options.contentSecurityPolicyReportOnly) + if (callback) { + callbacks.push(callback) + } + headersToSet.push(['Content-Security-Policy-Report-Only', value as string]) + } + if (options.permissionsPolicy && Object.keys(options.permissionsPolicy).length > 0) { headersToSet.push([ 'Permissions-Policy', @@ -258,7 +268,10 @@ function getCSPDirectives( : [ (ctx, headersToSet) => headersToSet.map((values) => { - if (values[0] === 'Content-Security-Policy') { + if ( + values[0] === 'Content-Security-Policy' || + values[0] === 'Content-Security-Policy-Report-Only' + ) { const clone = values[1].slice() as unknown as string[] callbacks.forEach((cb) => { cb(ctx, clone)