diff --git a/docs/content/1.documentation/1.getting-started/2.configuration.md b/docs/content/1.documentation/1.getting-started/2.configuration.md index 586ef049..60dc5de1 100644 --- a/docs/content/1.documentation/1.getting-started/2.configuration.md +++ b/docs/content/1.documentation/1.getting-started/2.configuration.md @@ -24,7 +24,7 @@ interface ModuleOptions { basicAuth: BasicAuth | false; enabled: boolean; csrf: CsrfOptions | false; - nonce: NonceOptions | false; + nonce: boolean; removeLoggers?: RemoveOptions | false; ssg?: Ssg; } diff --git a/docs/content/1.documentation/2.headers/1.csp.md b/docs/content/1.documentation/2.headers/1.csp.md index 282df6ff..33dfd96c 100644 --- a/docs/content/1.documentation/2.headers/1.csp.md +++ b/docs/content/1.documentation/2.headers/1.csp.md @@ -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'"], @@ -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) } } }) diff --git a/src/runtime/composables/nonce.ts b/src/runtime/composables/nonce.ts index abe84b06..53061edc 100644 --- a/src/runtime/composables/nonce.ts +++ b/src/runtime/composables/nonce.ts @@ -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 } diff --git a/src/runtime/server/middleware/cspNonceHandler.ts b/src/runtime/server/middleware/cspNonceHandler.ts index 994fed91..1354383f 100644 --- a/src/runtime/server/middleware/cspNonceHandler.ts +++ b/src/runtime/server/middleware/cspNonceHandler.ts @@ -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) diff --git a/src/types/index.ts b/src/types/index.ts index 7e3f788f..64361a47 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; @@ -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; } @@ -30,5 +30,5 @@ export interface NuxtSecurityRouteRules { xssValidator?: XssValidator | false; corsHandler?: CorsOptions | false; allowedMethodsRestricter?: AllowedHTTPMethods | false; - nonce?: NonceOptions | false; + nonce?: boolean; } diff --git a/src/types/middlewares.ts b/src/types/middlewares.ts index a290d07d..c45a6efe 100644 --- a/src/types/middlewares.ts +++ b/src/types/middlewares.ts @@ -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 :( diff --git a/test/nonce.test.ts b/test/nonce.test.ts index 45a85a29..c44b1cab 100644 --- a/test/nonce.test.ts +++ b/test/nonce.test.ts @@ -16,7 +16,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() @@ -24,7 +25,7 @@ describe('[nuxt-security] Nonce', async () => { 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') @@ -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 () => { @@ -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() @@ -71,7 +73,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() @@ -79,7 +82,7 @@ describe('[nuxt-security] Nonce', async () => { 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()