0
0
mirror of https://github.com/honojs/hono.git synced 2024-11-21 18:18:57 +01:00

feat(secureHeader): add CSP Report-Only mode support (#3413)

This commit is contained in:
Hiroki Isogai 2024-09-24 12:01:05 +09:00 committed by GitHub
parent fe0a82a615
commit 8130a67da1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 275 additions and 252 deletions

View File

@ -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')
})
})
})

View File

@ -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<SecureHeadersOptions>} [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)