From 950000697ee9a4e00f5b5e827d877351a972ec87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Tue, 17 Oct 2023 15:34:36 +0200 Subject: [PATCH 1/4] add test evidencing nonce issue in SSG --- test/fixtures/nonce/nuxt.config.ts | 3 +++ test/fixtures/nonce/pages/prerendered.vue | 5 +++++ test/nonce.test.ts | 17 +++++++++++++++++ 3 files changed, 25 insertions(+) create mode 100644 test/fixtures/nonce/pages/prerendered.vue diff --git a/test/fixtures/nonce/nuxt.config.ts b/test/fixtures/nonce/nuxt.config.ts index 07158bfe..964404a9 100644 --- a/test/fixtures/nonce/nuxt.config.ts +++ b/test/fixtures/nonce/nuxt.config.ts @@ -15,6 +15,9 @@ export default defineNuxtConfig({ security: { nonce: false } + }, + '/prerendered': { + prerender: true } }, security: { diff --git a/test/fixtures/nonce/pages/prerendered.vue b/test/fixtures/nonce/pages/prerendered.vue new file mode 100644 index 00000000..a6736089 --- /dev/null +++ b/test/fixtures/nonce/pages/prerendered.vue @@ -0,0 +1,5 @@ + diff --git a/test/nonce.test.ts b/test/nonce.test.ts index 5b6f64e2..9b877186 100644 --- a/test/nonce.test.ts +++ b/test/nonce.test.ts @@ -78,4 +78,21 @@ describe('[nuxt-security] Nonce', async () => { expect(nonce).toBeDefined() expect(elementsWithNonce).toBe(expectedNonceElements + 1) // one extra for the style tag }) + + it('removes the nonces in pre-render mode', async() => { + const res = await fetch('/index') + + const body = await res.text() + const injectedNonces = body.match(/ nonce="(.*?)"/) + const meta = body.match(//) + const content = meta?.[1] + const cspNonces = content?.match(/'nonce-(.*?)'/) + + expect(res).toBeDefined() + expect(res).toBeTruthy() + expect(content).toBeDefined() + expect(injectedNonces).toBe(null) + expect(cspNonces).toBe(null) + expect(content).toBe("base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; object-src 'none'; script-src-attr 'self' 'strict-dynamic'; style-src 'self' ; upgrade-insecure-requests; script-src 'self' 'strict-dynamic'") + }) }) From 0fe16bb697874375a52dd04ac4e8c57d495ed084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Tue, 17 Oct 2023 15:55:17 +0200 Subject: [PATCH 2/4] fix: apply new test to SSG pre-rendered page in fixture --- playground/nuxt.config.ts | 14 ++++++++++++-- src/runtime/nitro/plugins/99-cspNonce.ts | 23 +++++++++++++++++++++++ test/nonce.test.ts | 2 +- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 39e7b8dc..9bd9de98 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -19,10 +19,20 @@ export default defineNuxtConfig({ // Global configuration security: { headers: { - xXSSProtection: '0' + xXSSProtection: '0', + contentSecurityPolicy: { + 'style-src': ["'self'", "'nonce-{{nonce}}'"], + 'script-src': [ + "'self'", // backwards compatibility for older browsers that don't support strict-dynamic + "'nonce-{{nonce}}'", + "'strict-dynamic'" + ], + 'script-src-attr': ["'self'", "'nonce-{{nonce}}'", "'strict-dynamic'"] + } }, rateLimiter: { tokensPerInterval: 10 - } + }, + nonce: true } }) diff --git a/src/runtime/nitro/plugins/99-cspNonce.ts b/src/runtime/nitro/plugins/99-cspNonce.ts index 4eedea42..47753a92 100644 --- a/src/runtime/nitro/plugins/99-cspNonce.ts +++ b/src/runtime/nitro/plugins/99-cspNonce.ts @@ -4,6 +4,7 @@ import type { ModuleOptions } from '../../../types' import { useRuntimeConfig } from '#imports' +import { tryUseNuxt } from '@nuxt/kit' interface NuxtRenderHTMLContext { island?: boolean @@ -23,6 +24,10 @@ const tagNotPrecededByQuotes = (tag: string) => new RegExp(`(? function (nitro) { nitro.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { + console.log('pre', isPrerendering(event)) + if (isPrerendering(event)) { + return + } const nonce = parseNonce(`${event.node.res.getHeader('Content-Security-Policy')}`) if (!nonce) { return } @@ -54,4 +59,22 @@ export default function (nitro) { } return null } + + /** + * Only enable behavior if Content Security pPolicy is enabled, + * initial page is prerendered and generated file type is HTML. + * @param event H3Event + * @param options ModuleOptions + * @returns boolean + */ + function isPrerendering(event: H3Event): boolean { + const nitroPrerenderHeader = 'x-nitro-prerender' + + // Page is not prerendered + if (!event.node.req.headers[nitroPrerenderHeader]) { + return false + } + + return true + } } diff --git a/test/nonce.test.ts b/test/nonce.test.ts index 9b877186..4361684c 100644 --- a/test/nonce.test.ts +++ b/test/nonce.test.ts @@ -80,7 +80,7 @@ describe('[nuxt-security] Nonce', async () => { }) it('removes the nonces in pre-render mode', async() => { - const res = await fetch('/index') + const res = await fetch('/prerendered') const body = await res.text() const injectedNonces = body.match(/ nonce="(.*?)"/) From 20b096635eac7a8363ebeabb4958d62626c12ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Tue, 17 Oct 2023 19:46:10 +0200 Subject: [PATCH 3/4] fix nonce issue - in SSG mode, modify the 99-cspNonce nitro plugin - in SSR mode, modify the headers for prerendered routes --- src/module.ts | 17 ++++++++++++++++- src/runtime/nitro/plugins/99-cspNonce.ts | 11 +++++++---- test/nonce.test.ts | 3 +-- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/module.ts b/src/module.ts index 33e61845..4a2e72bb 100644 --- a/src/module.ts +++ b/src/module.ts @@ -84,6 +84,11 @@ export default defineNuxtModule({ setSecurityRouteRules(nuxt, securityOptions) + // Remove Content-Security-Policy header in pre-rendered routes + // When pre-rendered, the CSP is provided via html instead + // If kept, this would block the site from rendering + removeCspHeaderForPrerenderedRoutes(nuxt) + if (nuxt.options.security.requestSizeLimiter) { addServerHandler({ handler: normalize( @@ -125,7 +130,6 @@ export default defineNuxtModule({ ) }) } - if (nuxt.options.security.nonce) { addServerHandler({ handler: normalize( @@ -209,6 +213,17 @@ const setSecurityRouteRules = (nuxt: Nuxt, securityOptions: ModuleOptions) => { } } +const removeCspHeaderForPrerenderedRoutes = (nuxt: Nuxt) => { + const nitroRouteRules = nuxt.options.nitro.routeRules + for (const route in nitroRouteRules) { + const routeRules = nitroRouteRules[route] + if (routeRules.prerender) { + routeRules.headers = routeRules.headers || {} + routeRules.headers['Content-Security-Policy'] = '' + } + } +} + const registerSecurityNitroPlugins = ( nuxt: Nuxt, securityOptions: ModuleOptions diff --git a/src/runtime/nitro/plugins/99-cspNonce.ts b/src/runtime/nitro/plugins/99-cspNonce.ts index 47753a92..431b217d 100644 --- a/src/runtime/nitro/plugins/99-cspNonce.ts +++ b/src/runtime/nitro/plugins/99-cspNonce.ts @@ -24,8 +24,13 @@ const tagNotPrecededByQuotes = (tag: string) => new RegExp(`(? function (nitro) { nitro.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { - console.log('pre', isPrerendering(event)) if (isPrerendering(event)) { + // In SSG mode, do not inject nonces in html + // However first make sure we erase nonce placeholders from CSP meta + html.head = html.head.map((meta) => { + if (!meta.startsWith(' function (nitro) { } /** - * Only enable behavior if Content Security pPolicy is enabled, - * initial page is prerendered and generated file type is HTML. + * Detect if page is being pre-rendered * @param event H3Event - * @param options ModuleOptions * @returns boolean */ function isPrerendering(event: H3Event): boolean { diff --git a/test/nonce.test.ts b/test/nonce.test.ts index 4361684c..45a85a29 100644 --- a/test/nonce.test.ts +++ b/test/nonce.test.ts @@ -87,12 +87,11 @@ describe('[nuxt-security] Nonce', async () => { const meta = body.match(//) const content = meta?.[1] const cspNonces = content?.match(/'nonce-(.*?)'/) - + expect(res).toBeDefined() expect(res).toBeTruthy() expect(content).toBeDefined() expect(injectedNonces).toBe(null) expect(cspNonces).toBe(null) - expect(content).toBe("base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; object-src 'none'; script-src-attr 'self' 'strict-dynamic'; style-src 'self' ; upgrade-insecure-requests; script-src 'self' 'strict-dynamic'") }) }) From f5dce18655cf9f119577ca4f83c73fe83cc95423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Tue, 17 Oct 2023 19:51:33 +0200 Subject: [PATCH 4/4] remove temporary code - revert playground config setup to basis - remove uneccessary tryUseNuxt import in 99-cspNonce --- playground/nuxt.config.ts | 11 +---------- src/runtime/nitro/plugins/99-cspNonce.ts | 1 - 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 9bd9de98..f2204a21 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -19,16 +19,7 @@ export default defineNuxtConfig({ // Global configuration security: { headers: { - xXSSProtection: '0', - contentSecurityPolicy: { - 'style-src': ["'self'", "'nonce-{{nonce}}'"], - 'script-src': [ - "'self'", // backwards compatibility for older browsers that don't support strict-dynamic - "'nonce-{{nonce}}'", - "'strict-dynamic'" - ], - 'script-src-attr': ["'self'", "'nonce-{{nonce}}'", "'strict-dynamic'"] - } + xXSSProtection: '0' }, rateLimiter: { tokensPerInterval: 10 diff --git a/src/runtime/nitro/plugins/99-cspNonce.ts b/src/runtime/nitro/plugins/99-cspNonce.ts index 431b217d..648efaf5 100644 --- a/src/runtime/nitro/plugins/99-cspNonce.ts +++ b/src/runtime/nitro/plugins/99-cspNonce.ts @@ -4,7 +4,6 @@ import type { ModuleOptions } from '../../../types' import { useRuntimeConfig } from '#imports' -import { tryUseNuxt } from '@nuxt/kit' interface NuxtRenderHTMLContext { island?: boolean