Skip to content

Commit

Permalink
Merge pull request #502 from P4sca1/main
Browse files Browse the repository at this point in the history
  • Loading branch information
Baroshem authored Aug 6, 2024
2 parents 7811a00 + 1a5ada9 commit e6df1ac
Show file tree
Hide file tree
Showing 9 changed files with 90 additions and 17 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions playground/components/ServerComponent.server.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<template>
<div>
<h1>Server-only Nuxt-Island component</h1>
<p>Nonce: <span id="server-nonce">{{ nonce }}</span></p>
</div>
</template>

<script setup lang="ts">
const nonce = useNonce()
</script>
6 changes: 6 additions & 0 deletions playground/pages/island.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<template>
<div>
Island Page
<ServerComponent />
</div>
</template>
28 changes: 21 additions & 7 deletions src/runtime/nitro/plugins/40-cspSsrNonce.ts
Original file line number Diff line number Diff line change
@@ -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 = /<link([^>]*?>)/gi
Expand All @@ -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
}

Expand All @@ -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 `<link nonce="${nonce}"` + rest
})
// Add nonce to all script tags
element = element.replace(SCRIPT_RE, (match, rest)=>{
element = element.replace(SCRIPT_RE, (match, rest) => {
return `<script nonce="${nonce}"` + rest
})
// Add nonce to all style tags
element = element.replace(STYLE_RE, (match, rest)=>{
element = element.replace(STYLE_RE, (match, rest) => {
return `<style nonce="${nonce}"` + rest
})
return element
Expand Down
14 changes: 13 additions & 1 deletion src/runtime/nitro/plugins/50-updateCsp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import type { ContentSecurityPolicyValue } from '../../../types/headers'
*/
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('render:html', (response, { event }) => {
if (response.island) {
// When rendering server-only (NuxtIsland) components, do not update CSP headers.
// The CSP headers from the page that the island components are mounted into are used.
return
}

const rules = resolveSecurityRules(event)
if (rules.enabled && rules.headers) {
const headers = rules.headers
Expand All @@ -31,7 +37,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}}'") {
console.warn('[nuxt-security] removing static nonce from CSP header')
return false
}
return true
})
.map(source => {
if (source === "'nonce-{{nonce}}'") {
return nonce ? `'nonce-${nonce}'` : ''
Expand Down
10 changes: 6 additions & 4 deletions src/runtime/nitro/plugins/60-recombineHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/^<meta charset="(.*?)">/mdi)
if (metaCharsetMatch && metaCharsetMatch.indices) {
insertIndex = metaCharsetMatch.indices[0][1]
if (html.head.length > 0) {
const metaCharsetMatch = html.head[0].match(/^<meta charset="(.*?)">/mdi)
if (metaCharsetMatch && metaCharsetMatch.indices) {
insertIndex = metaCharsetMatch.indices[0][1]
}
html.head[0] = html.head[0].slice(0, insertIndex) + `<meta http-equiv="Content-Security-Policy" content="${headerValue}">` + html.head[0].slice(insertIndex)
}
html.head[0] = html.head[0].slice(0, insertIndex) + `<meta http-equiv="Content-Security-Policy" content="${headerValue}">` + html.head[0].slice(insertIndex)
}
})
})
10 changes: 10 additions & 0 deletions test/fixtures/ssrNonce/components/ServerComponent.server.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<template>
<div>
<h1>Server-only Nuxt-Island component</h1>
<p>Nonce: <span id="server-nonce">{{ nonce }}</span></p>
</div>
</template>

<script setup lang="ts">
const nonce = useNonce()
</script>
5 changes: 5 additions & 0 deletions test/fixtures/ssrNonce/pages/server-component.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div>
<ServerComponent />
</div>
</template>
20 changes: 17 additions & 3 deletions test/ssrNonce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '\\$&')
Expand Down Expand Up @@ -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, '\\$&')
Expand All @@ -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, '\\$&')
Expand All @@ -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(`<span id="server-nonce">${nonce}</span>`)
})
})

0 comments on commit e6df1ac

Please sign in to comment.