From 23af05a3352a57169adaa14310ab77ed395bdd17 Mon Sep 17 00:00:00 2001 From: Pascal Sthamer <10992664+P4sca1@users.noreply.github.com> Date: Wed, 31 Jul 2024 13:02:43 +0200 Subject: [PATCH 1/8] test: use nullish coalescing operator If the CSP header is malformed or does not exist, the value will be undefined. --- test/ssrNonce.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/ssrNonce.test.ts b/test/ssrNonce.test.ts index b98b3e9e..a9d99412 100644 --- a/test/ssrNonce.test.ts +++ b/test/ssrNonce.test.ts @@ -13,7 +13,7 @@ describe('[nuxt-security] Nonce', async () => { const res = await fetch('/') const cspHeaderValue = res.headers.get('content-security-policy') - const nonce = cspHeaderValue?.match(/'nonce-(.*?)'/)![1] + const nonce = cspHeaderValue?.match(/'nonce-(.*?)'/)?.[1] const text = await res.text() const nonceMatch = `nonce="${nonce}"`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') @@ -44,7 +44,7 @@ describe('[nuxt-security] Nonce', async () => { const res = await fetch('/use-head') const cspHeaderValue = res.headers.get('content-security-policy') - const nonce = cspHeaderValue!.match(/'nonce-(.*?)'/)![1] + const nonce = cspHeaderValue!.match(/'nonce-(.*?)'/)?.[1] const text = await res.text() const nonceMatch = `nonce="${nonce}"`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') @@ -70,7 +70,7 @@ describe('[nuxt-security] Nonce', async () => { const res = await fetch('/with-styling') const cspHeaderValue = res.headers.get('content-security-policy') - const nonce = cspHeaderValue?.match(/'nonce-(.*?)'/)![1] + const nonce = cspHeaderValue?.match(/'nonce-(.*?)'/)?.[1] const text = await res.text() const nonceMatch = `nonce="${nonce}"`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') From eb097d0838c57447f4bdc3ca4b3abdbd48b77ea9 Mon Sep 17 00:00:00 2001 From: Pascal Sthamer <10992664+P4sca1@users.noreply.github.com> Date: Wed, 31 Jul 2024 13:10:14 +0200 Subject: [PATCH 2/8] test: add test cases for server-only components --- .../ssrNonce/components/ServerComponent.server.vue | 10 ++++++++++ test/fixtures/ssrNonce/pages/server-component.vue | 5 +++++ test/ssrNonce.test.ts | 14 ++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 test/fixtures/ssrNonce/components/ServerComponent.server.vue create mode 100644 test/fixtures/ssrNonce/pages/server-component.vue diff --git a/test/fixtures/ssrNonce/components/ServerComponent.server.vue b/test/fixtures/ssrNonce/components/ServerComponent.server.vue new file mode 100644 index 00000000..ad7324ed --- /dev/null +++ b/test/fixtures/ssrNonce/components/ServerComponent.server.vue @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/test/fixtures/ssrNonce/pages/server-component.vue b/test/fixtures/ssrNonce/pages/server-component.vue new file mode 100644 index 00000000..ef3098ad --- /dev/null +++ b/test/fixtures/ssrNonce/pages/server-component.vue @@ -0,0 +1,5 @@ + diff --git a/test/ssrNonce.test.ts b/test/ssrNonce.test.ts index a9d99412..1c8d8cd3 100644 --- a/test/ssrNonce.test.ts +++ b/test/ssrNonce.test.ts @@ -97,4 +97,18 @@ describe('[nuxt-security] Nonce', async () => { expect(injectedNonces).toBe(null) expect(cspNonces).toBe(null) }) + + it('works with server-only components', async () => { + const res = await fetch('/server-component') + + const cspHeaderValue = res.headers.get('content-security-policy') + const nonce = cspHeaderValue?.match(/'nonce-(.*?)'/)?.[1] + + const text = await res.text() + + expect(res).toBeDefined() + expect(res).toBeTruthy() + expect(nonce).toBeDefined() + expect(text).toMatch(`${nonce}`) + }) }) From c38a710fbdf6a091e8eec2eb6c5be89208f7adae Mon Sep 17 00:00:00 2001 From: Pascal Sthamer <10992664+P4sca1@users.noreply.github.com> Date: Wed, 31 Jul 2024 13:16:31 +0200 Subject: [PATCH 3/8] fix: log warning when removing static nonce from CSP header --- src/runtime/nitro/plugins/50-updateCsp.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/runtime/nitro/plugins/50-updateCsp.ts b/src/runtime/nitro/plugins/50-updateCsp.ts index 17cc7aa1..c16f793c 100644 --- a/src/runtime/nitro/plugins/50-updateCsp.ts +++ b/src/runtime/nitro/plugins/50-updateCsp.ts @@ -1,6 +1,7 @@ import { defineNitroPlugin } from '#imports' import { resolveSecurityRules } from '../context' import type { ContentSecurityPolicyValue } from '../../../types/headers' +import { useLogger } from '@nuxt/kit' /** * This plugin updates the CSP directives with the nonce and hashes generated by the server. @@ -23,6 +24,8 @@ export default defineNitroPlugin((nitroApp) => { }) function updateCspVariables(csp: ContentSecurityPolicyValue, nonce?: string, scriptHashes?: Set, styleHashes?: Set) { + const logger = useLogger('nuxt-security') + const generatedCsp = Object.fromEntries(Object.entries(csp).map(([directive, value]) => { // Return boolean values unchanged if (typeof value === 'boolean') { @@ -31,7 +34,13 @@ function updateCspVariables(csp: ContentSecurityPolicyValue, nonce?: string, scr // Make sure nonce placeholders are eliminated const sources = (typeof value === 'string') ? value.split(' ').map(token => token.trim()).filter(token => token) : value const modifiedSources = sources - .filter(source => !source.startsWith("'nonce-") || source === "'nonce-{{nonce}}'") + .filter(source => { + if (source.startsWith("'nonce-") && source !== "'nonce-{{nonce}}'") { + logger.warn('Removing static nonce from CSP header.') + return false + } + return true + }) .map(source => { if (source === "'nonce-{{nonce}}'") { return nonce ? `'nonce-${nonce}'` : '' From 2b0cf0f84ea74b5496dc10b9d8f710d4e1d1408e Mon Sep 17 00:00:00 2001 From: Pascal Sthamer <10992664+P4sca1@users.noreply.github.com> Date: Wed, 31 Jul 2024 13:31:21 +0200 Subject: [PATCH 4/8] fix: skip nonce generation and csp header update for NuxtIsland requests --- src/runtime/nitro/plugins/40-cspSsrNonce.ts | 29 ++++++++++++++++----- src/runtime/nitro/plugins/50-updateCsp.ts | 6 +++++ src/utils/island.ts | 5 ++++ 3 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 src/utils/island.ts diff --git a/src/runtime/nitro/plugins/40-cspSsrNonce.ts b/src/runtime/nitro/plugins/40-cspSsrNonce.ts index ba97c305..2bb1a7f2 100644 --- a/src/runtime/nitro/plugins/40-cspSsrNonce.ts +++ b/src/runtime/nitro/plugins/40-cspSsrNonce.ts @@ -1,6 +1,7 @@ import { defineNitroPlugin } from '#imports' -import crypto from 'node:crypto' +import { randomBytes } from 'node:crypto' import { resolveSecurityRules } from '../context' +import { isIslandRequst } from '../../../utils/island' const LINK_RE = /]*?>)/gi const SCRIPT_RE = /]*?>)/gi @@ -17,18 +18,32 @@ export default defineNitroPlugin((nitroApp) => { return } + // Genearate a 16-byte random nonce for each request. nitroApp.hooks.hook('request', (event) => { + if (isIslandRequst(event)) { + // When rendering server-only (NuxtIsland) components, each component will trigger a request event. + // The request context is shared between the event that renders the actual page and the island request events. + // We only generate the nonce once for the page event. + return + } + const rules = resolveSecurityRules(event) if (rules.enabled && rules.nonce && !import.meta.prerender) { - const nonce = crypto.randomBytes(16).toString('base64') + const nonce = randomBytes(16).toString('base64') event.context.security!.nonce = nonce } }) + // Set the nonce attribute on all script, style, and link tags. nitroApp.hooks.hook('render:html', (html, { event }) => { // Exit if no CSP defined const rules = resolveSecurityRules(event) - if (!rules.enabled || !rules.headers || !rules.headers.contentSecurityPolicy || !rules.nonce) { + if ( + !rules.enabled || + !rules.headers || + !rules.headers.contentSecurityPolicy || + !rules.nonce + ) { return } @@ -37,17 +52,17 @@ export default defineNitroPlugin((nitroApp) => { type Section = 'body' | 'bodyAppend' | 'bodyPrepend' | 'head' const sections = ['body', 'bodyAppend', 'bodyPrepend', 'head'] as Section[] for (const section of sections) { - html[section] = html[section].map(element => { + html[section] = html[section].map((element) => { // Add nonce to all link tags - element = element.replace(LINK_RE, (match, rest)=>{ + element = element.replace(LINK_RE, (match, rest) => { return `{ + element = element.replace(SCRIPT_RE, (match, rest) => { return ` \ No newline at end of file diff --git a/playground/pages/island.vue b/playground/pages/island.vue new file mode 100644 index 00000000..9f18b75e --- /dev/null +++ b/playground/pages/island.vue @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/src/runtime/nitro/plugins/60-recombineHtml.ts b/src/runtime/nitro/plugins/60-recombineHtml.ts index a7f4ed01..6cc082a3 100644 --- a/src/runtime/nitro/plugins/60-recombineHtml.ts +++ b/src/runtime/nitro/plugins/60-recombineHtml.ts @@ -24,11 +24,13 @@ export default defineNitroPlugin((nitroApp) => { // Let's insert the CSP meta tag just after the first tag which should be the charset meta let insertIndex = 0 - const metaCharsetMatch = html.head[0].match(/^/mdi) - if (metaCharsetMatch && metaCharsetMatch.indices) { - insertIndex = metaCharsetMatch.indices[0][1] + if (html.head.length > 0) { + const metaCharsetMatch = html.head[0].match(/^/mdi) + if (metaCharsetMatch && metaCharsetMatch.indices) { + insertIndex = metaCharsetMatch.indices[0][1] + } + html.head[0] = html.head[0].slice(0, insertIndex) + `` + html.head[0].slice(insertIndex) } - html.head[0] = html.head[0].slice(0, insertIndex) + `` + html.head[0].slice(insertIndex) } }) }) \ No newline at end of file From 57ff90bc6ce8e3a3a5ac614a15d9d053805a109d Mon Sep 17 00:00:00 2001 From: Pascal Sthamer <10992664+P4sca1@users.noreply.github.com> Date: Sat, 3 Aug 2024 13:50:55 +0200 Subject: [PATCH 7/8] Replace isIslandRequest util with check if nonce already exist Signed-off-by: Pascal Sthamer <10992664+P4sca1@users.noreply.github.com> --- src/runtime/nitro/plugins/40-cspSsrNonce.ts | 5 ++--- src/utils/island.ts | 5 ----- 2 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 src/utils/island.ts diff --git a/src/runtime/nitro/plugins/40-cspSsrNonce.ts b/src/runtime/nitro/plugins/40-cspSsrNonce.ts index 6cf32f34..1f8f2321 100644 --- a/src/runtime/nitro/plugins/40-cspSsrNonce.ts +++ b/src/runtime/nitro/plugins/40-cspSsrNonce.ts @@ -1,7 +1,6 @@ import { defineNitroPlugin } from '#imports' import { randomBytes } from 'node:crypto' import { resolveSecurityRules } from '../context' -import { isIslandRequest } from '../../../utils/island' const LINK_RE = /]*?>)/gi const SCRIPT_RE = /]*?>)/gi @@ -20,10 +19,10 @@ export default defineNitroPlugin((nitroApp) => { // Genearate a 16-byte random nonce for each request. nitroApp.hooks.hook('request', (event) => { - if (isIslandRequest(event)) { + if (event.context.security?.nonce) { // When rendering server-only (NuxtIsland) components, each component will trigger a request event. // The request context is shared between the event that renders the actual page and the island request events. - // We only generate the nonce once for the page event. + // Make sure to only generate the nonce once. return } diff --git a/src/utils/island.ts b/src/utils/island.ts deleted file mode 100644 index 9edda5aa..00000000 --- a/src/utils/island.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { H3Event } from 'h3' - -export function isIslandRequest(event: H3Event) { - return event.path.startsWith('/__nuxt_island/') -} \ No newline at end of file From 1a5ada98f74b8adf1a7de2d10a586495be0688d1 Mon Sep 17 00:00:00 2001 From: Pascal Sthamer <10992664+P4sca1@users.noreply.github.com> Date: Sat, 3 Aug 2024 14:07:55 +0200 Subject: [PATCH 8/8] fix: use console warn instead of useLogger Signed-off-by: Pascal Sthamer <10992664+P4sca1@users.noreply.github.com> --- src/runtime/nitro/plugins/50-updateCsp.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/runtime/nitro/plugins/50-updateCsp.ts b/src/runtime/nitro/plugins/50-updateCsp.ts index 8a77e36f..50bd3377 100644 --- a/src/runtime/nitro/plugins/50-updateCsp.ts +++ b/src/runtime/nitro/plugins/50-updateCsp.ts @@ -1,7 +1,6 @@ import { defineNitroPlugin } from '#imports' import { resolveSecurityRules } from '../context' import type { ContentSecurityPolicyValue } from '../../../types/headers' -import { useLogger } from '@nuxt/kit' /** * This plugin updates the CSP directives with the nonce and hashes generated by the server. @@ -30,8 +29,6 @@ export default defineNitroPlugin((nitroApp) => { }) function updateCspVariables(csp: ContentSecurityPolicyValue, nonce?: string, scriptHashes?: Set, styleHashes?: Set) { - const logger = useLogger('nuxt-security') - const generatedCsp = Object.fromEntries(Object.entries(csp).map(([directive, value]) => { // Return boolean values unchanged if (typeof value === 'boolean') { @@ -42,7 +39,7 @@ function updateCspVariables(csp: ContentSecurityPolicyValue, nonce?: string, scr const modifiedSources = sources .filter(source => { if (source.startsWith("'nonce-") && source !== "'nonce-{{nonce}}'") { - logger.warn('Removing static nonce from CSP header.') + console.warn('[nuxt-security] removing static nonce from CSP header') return false } return true