Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improve CSP compliance #257

Merged
merged 5 commits into from
Oct 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading