Skip to content

Commit

Permalink
Merge pull request #257 from vejja/fix/csp-nonce-cookie
Browse files Browse the repository at this point in the history
improve CSP compliance
  • Loading branch information
Baroshem committed Oct 27, 2023
2 parents f0f1357 + 97c37c1 commit f57ed56
Show file tree
Hide file tree
Showing 7 changed files with 22 additions and 52 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface ModuleOptions {
basicAuth: BasicAuth | false;
enabled: boolean;
csrf: CsrfOptions | false;
nonce: NonceOptions | false;
nonce: boolean;
removeLoggers?: RemoveOptions | false;
ssg?: Ssg;
}
Expand Down
9 changes: 4 additions & 5 deletions docs/content/1.documentation/2.headers/1.csp.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,6 @@ export default defineNuxtConfig({
? [
"'self'", // backwards compatibility for older browsers that don't support strict-dynamic
"'nonce-{{nonce}}'",
"'strict-dynamic'",
]
: // In dev mode, we allow unsafe-inline so that hot reloading keeps working
["'self'", "'unsafe-inline'"],
Expand Down Expand Up @@ -193,11 +192,11 @@ The `nonce` value is generated per request and is added to the CSP header. This
```ts
export default defineNuxtConfig({
routeRules: {
'/api/custom-route': {
nonce: false // do not check nonce for this route (1)
'/custom-route': {
nonce: false // do not generate nonce for this route (1)
},
'/api/other-route': {
nonce: { mode: 'check' } // do not generate a new nonce for this route, but check it against the existing one (2)
'/other-route': {
nonce: true // generate a new nonce for this route (2)
}
}
})
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/composables/nonce.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useNuxtApp, useCookie } from '#imports'
import { useNuxtApp } from '#imports'

export function useNonce () {
return useNuxtApp().ssrContext?.event?.context.nonce ?? useCookie('nonce').value
return useNuxtApp().ssrContext?.event?.context.nonce
}
32 changes: 3 additions & 29 deletions src/runtime/server/middleware/cspNonceHandler.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,15 @@
import crypto from 'node:crypto'
import { createError, defineEventHandler, getCookie, sendError, setCookie } from 'h3'
import { defineEventHandler } from 'h3'
// @ts-ignore
import { getRouteRules } from '#imports'

export type NonceOptions = {
enabled: boolean;
mode: 'renew' | 'check';
value: undefined | (() => string);
}

export default defineEventHandler((event) => {
let csp = `${event.node.res.getHeader('Content-Security-Policy')}`
const routeRules = getRouteRules(event)

if (routeRules.security.nonce !== false) {
const nonceConfig: NonceOptions = routeRules.security.nonce

// See if we are checking the nonce against the current value, or if we are renewing the nonce value
let nonce: string | undefined
switch (nonceConfig?.mode) {
case 'check': {
nonce = event.context.nonce ?? getCookie(event, 'nonce')

if (!nonce) {
return sendError(event, createError({ statusCode: 401, statusMessage: 'Nonce is not set' }))
}

break
}
case 'renew':
default: {
nonce = nonceConfig?.value ? nonceConfig.value() : Buffer.from(crypto.randomUUID()).toString('base64')
setCookie(event, 'nonce', nonce, { sameSite: true, secure: true })
event.context.nonce = nonce
break
}
}
const nonce = crypto.randomBytes(16).toString('base64')
event.context.nonce = nonce

// Set actual nonce value in CSP header
csp = csp.replaceAll('{{nonce}}', nonce as string)
Expand Down
6 changes: 3 additions & 3 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ModuleOptions as CsrfOptions } from 'nuxt-csurf'
import type { Options as RemoveOptions } from 'unplugin-remove/types'

import { SecurityHeaders } from './headers'
import { AllowedHTTPMethods, BasicAuth, CorsOptions, NonceOptions, RateLimiter, RequestSizeLimiter, XssValidator } from './middlewares'
import { AllowedHTTPMethods, BasicAuth, CorsOptions, RateLimiter, RequestSizeLimiter, XssValidator } from './middlewares'

export type Ssg = {
hashScripts?: boolean;
Expand All @@ -19,7 +19,7 @@ export interface ModuleOptions {
basicAuth: BasicAuth | false;
enabled: boolean;
csrf: CsrfOptions | false;
nonce: NonceOptions | false;
nonce: boolean;
removeLoggers?: RemoveOptions | false;
ssg?: Ssg;
}
Expand All @@ -30,5 +30,5 @@ export interface NuxtSecurityRouteRules {
xssValidator?: XssValidator | false;
corsHandler?: CorsOptions | false;
allowedMethodsRestricter?: AllowedHTTPMethods | false;
nonce?: NonceOptions | false;
nonce?: boolean;
}
6 changes: 0 additions & 6 deletions src/types/middlewares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,6 @@ export type BasicAuth = {
message: string;
}

export type NonceOptions = {
enabled: boolean;
mode?: 'renew' | 'check';
value?: (() => string);
}

export type HTTPMethod = 'GET' | 'POST' | 'DELETE' | 'PATCH' | 'POST' | string;

// Cannot use the H3CorsOptions from `h3` as it breaks the build process for some reason :(
Expand Down
15 changes: 9 additions & 6 deletions test/nonce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@ describe('[nuxt-security] Nonce', async () => {
const nonce = cspHeaderValue?.match(/'nonce-(.*?)'/)![1]

const text = await res.text()
const elementsWithNonce = text.match(new RegExp(`nonce="${nonce}"`, 'g'))?.length ?? 0
const nonceMatch = `nonce="${nonce}"`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const elementsWithNonce = text.match(new RegExp(nonceMatch, 'g'))?.length ?? 0

expect(res).toBeDefined()
expect(res).toBeTruthy()
expect(nonce).toBeDefined()
expect(elementsWithNonce).toBe(expectedNonceElements)
})

it('does not renew nonce if mode is `check`', async () => {
it('renews nonce even if mode is `check`', async () => {
// Make sure a nonce exists by doing the initial request
const originalRes = await fetch('/')
const originalCsp = originalRes.headers.get('content-security-policy')
Expand All @@ -36,7 +37,7 @@ describe('[nuxt-security] Nonce', async () => {
expect(res).toBeDefined()
expect(res).toBeTruthy()
expect(res.ok).toBe(true)
expect(res.headers.get('content-security-policy')).toBe(originalCsp)
expect(res.headers.get('content-security-policy')).not.toBe(originalCsp)
})

it('injects `nonce` attribute in response when using useHead composable', async () => {
Expand All @@ -46,7 +47,8 @@ describe('[nuxt-security] Nonce', async () => {
const nonce = cspHeaderValue!.match(/'nonce-(.*?)'/)![1]

const text = await res.text()
const elementsWithNonce = text.match(new RegExp(`nonce="${nonce}"`, 'g'))?.length ?? 0
const nonceMatch = `nonce="${nonce}"`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const elementsWithNonce = text.match(new RegExp(nonceMatch, 'g'))?.length ?? 0

expect(res).toBeDefined()
expect(res).toBeTruthy()
Expand All @@ -71,15 +73,16 @@ describe('[nuxt-security] Nonce', async () => {
const nonce = cspHeaderValue?.match(/'nonce-(.*?)'/)![1]

const text = await res.text()
const elementsWithNonce = text.match(new RegExp(`nonce="${nonce}"`, 'g'))?.length ?? 0
const nonceMatch = `nonce="${nonce}"`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const elementsWithNonce = text.match(new RegExp(nonceMatch, 'g'))?.length ?? 0

expect(res).toBeDefined()
expect(res).toBeTruthy()
expect(nonce).toBeDefined()
expect(elementsWithNonce).toBe(expectedNonceElements + 1) // one extra for the style tag
})

it('removes the nonces in pre-render mode', async() => {
it('removes the nonces in pre-render mode', async () => {
const res = await fetch('/prerendered')

const body = await res.text()
Expand Down

0 comments on commit f57ed56

Please sign in to comment.