diff --git a/package.json b/package.json index a94f13c5..836cc7b4 100644 --- a/package.json +++ b/package.json @@ -67,8 +67,8 @@ "@types/node": "^18.18.1", "eslint": "^8.50.0", "nuxt": "^3.11.2", - "vitest": "^1.3.1", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "vitest": "^1.3.1" }, "stackblitz": { "installDependencies": false, diff --git a/playground/components/ServerComponent.server.vue b/playground/components/ServerComponent.server.vue new file mode 100644 index 00000000..ad7324ed --- /dev/null +++ b/playground/components/ServerComponent.server.vue @@ -0,0 +1,10 @@ + + + \ 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/40-cspSsrNonce.ts b/src/runtime/nitro/plugins/40-cspSsrNonce.ts index e5b636e9..e796de65 100644 --- a/src/runtime/nitro/plugins/40-cspSsrNonce.ts +++ b/src/runtime/nitro/plugins/40-cspSsrNonce.ts @@ -1,5 +1,5 @@ import { defineNitroPlugin } from '#imports' -import crypto from 'node:crypto' +import { randomBytes } from 'node:crypto' import { resolveSecurityRules } from '../context' const LINK_RE = /]*?>)/gi @@ -17,18 +17,32 @@ export default defineNitroPlugin((nitroApp) => { return } + // Genearate a 16-byte random nonce for each request. nitroApp.hooks.hook('request', (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. + // Make sure to only generate the nonce once. + 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 +51,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/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 b98b3e9e..1c8d8cd3 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, '\\$&') @@ -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}`) + }) })