Skip to content

Commit

Permalink
Merge pull request #177 from trijpstra-fourlights/feat/nonce-improvem…
Browse files Browse the repository at this point in the history
…ents

fix: nonce quirks when using `ssr`
  • Loading branch information
Baroshem committed Jul 18, 2023
2 parents 87880b0 + e38aa6b commit 0136784
Show file tree
Hide file tree
Showing 14 changed files with 107 additions and 30 deletions.
30 changes: 26 additions & 4 deletions docs/content/2.security/1.headers.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Headers
description: ''
---

A set of **global** Nuxt `routeRules` that will add appriopriate security headers to your response that will make your application more secure by default. All headers can be overriden by using the module configuration. Check out all the available types [here](https://github.com/Baroshem/nuxt-security/blob/main/src/types.ts).
A set of **global** Nuxt `routeRules` that will add appropriate security headers to your response that will make your application more secure by default. All headers can be overriden by using the module configuration. Check out all the available types [here](https://github.com/Baroshem/nuxt-security/blob/main/src/types.ts).

It will help you solve [this](https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html#use-appropriate-security-headers) security problem.

Expand Down Expand Up @@ -72,18 +72,27 @@ export default defineNuxtConfig({
nonce: true,
headers: {
contentSecurityPolicy: {
'style-src': [
"'self'", // fallback value for older browsers, automatically removed if `strict-dynamic` is supported.
"'nonce-{{nonce}}'",
],
'script-src': [
"'self'", // fallback value for older browsers, automatically removed if `strict-dynamic` is supported.
"'nonce-{{nonce}}'",
"'strict-dynamic'"
],
'script-src-attr': [
"'self'", // fallback value for older browsers, automatically removed if `strict-dynamic` is supported.
"'nonce-{{nonce}}'",
"'strict-dynamic'"
]
}
}
}
})
```

This will add a `nonce` attribute to all `<script>` and `<link>` tags in your application.
This will add a `nonce` attribute to all `<script>`, `<link>` and `<style>` tags in your application.
The `nonce` value is generated per request and is added to the CSP header. This behaviour can be tweaked on a route level by using the `routeRules` option:

```ts
Expand All @@ -101,10 +110,23 @@ export default defineNuxtConfig({

#### Using `nonce` in your application code

If you are dynamically adding script or link tags in your application, for example using the `useHead` composable, you can get the current valid nonce value using:
##### With the `useHead` composable
If you are dynamically adding script or link tags in your application using the `useHead` composable, all nonce values will be automatically added.
However, take note that due to [a current bug in unjs/unhead](https://github.com/unjs/unhead/issues/136), you'll need to add a workaround **when using ssr** to prevent double loading and executing of your scripts when using nonce.

```ts
// workaround unjs/unhead bug for double injection when using nonce
// by setting the mode to 'server'
// see: https://github.com/unjs/unhead/issues/136
useHead({ script: [{ src: 'https://example.com/script.js' }] }, { mode: 'server' })
```

##### Directly inserting tags into DOM

If you are unable or unwilling to use `useHead` and are inserting directly into the DOM (e.g. `document.createElement`), you can get the current valid nonce value using the `useNonce` composable:

```ts
const nonce = useCookie('nonce')
const nonce = useNonce()
```

## Cross-Origin-Embedder-Policy
Expand Down
4 changes: 4 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ export default defineNuxtModule<ModuleOptions>({
});
}

nuxt.hook('imports:dirs', (dirs) => {
dirs.push(normalize(resolve(runtimeDir, 'composables')))
});

const csrfConfig = nuxt.options.security.csrf;
if (csrfConfig) {
if (Object.keys(csrfConfig).length) {
Expand Down
5 changes: 5 additions & 0 deletions src/runtime/composables/nonce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { useNuxtApp, useCookie } from '#imports'

export function useNonce () {
return useNuxtApp().ssrContext?.event?.context.nonce ?? useCookie('nonce').value
}
4 changes: 4 additions & 0 deletions src/runtime/nitro/plugins/cspNonce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export default <NitroAppPlugin> function (nitro) {
// Add nonce attribute to all script tags
html.head = html.head.map(script => script.replaceAll(tagNotPrecededByQuotes('script'), `<script nonce="${nonce}"`))
html.bodyAppend = html.bodyAppend.map(script => script.replaceAll(tagNotPrecededByQuotes('script'), `<script nonce="${nonce}"`))

// Add nonce attribute to all style tags
html.head = html.head.map(style => style.replaceAll(tagNotPrecededByQuotes('style'), `<style nonce="${nonce}"`))
html.bodyAppend = html.bodyAppend.map(style => style.replaceAll(tagNotPrecededByQuotes('style'), `<style nonce="${nonce}"`))
})

function parseNonce (content: string) {
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/server/middleware/cspNonceHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default defineEventHandler((event) => {
let nonce: string | undefined
switch (nonceConfig?.mode) {
case 'check': {
nonce = getCookie(event, 'nonce')
nonce = event.context.nonce ?? getCookie(event, 'nonce')

if (!nonce) {
return sendError(event, createError({ statusCode: 401, statusMessage: 'Nonce is not set' }))
Expand All @@ -31,6 +31,7 @@ export default defineEventHandler((event) => {
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
}
}
Expand Down
10 changes: 10 additions & 0 deletions test/fixtures/nonce/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,13 @@
<NuxtPage />
</div>
</template>

<script lang="ts" setup>
useHead({
script: [
{ src: '/api/generated-script' }
]
// workaround for double loads in ssr when using nonce
// see: https://github.com/unjs/unhead/issues/136
}, { mode: 'server' })
</script>
11 changes: 1 addition & 10 deletions test/fixtures/nonce/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,6 @@
import MyModule from '../../../src/module'

export default defineNuxtConfig({
app: {
head: {
script: [
{ src: '/loader.js' },
{ src: '/api/generated-script' },
{ innerHTML: 'var inlineLiteral = \'<script>console.log("example")</script>\'' }
]
}
},

modules: [
MyModule
],
Expand All @@ -31,6 +21,7 @@ export default defineNuxtConfig({
nonce: true,
headers: {
contentSecurityPolicy: {
'style-src': ["'self'", "'nonce-{{nonce}}'"],
'script-src': [
"'self'", // backwards compatibility for older browsers that don't support strict-dynamic
"'nonce-{{nonce}}'",
Expand Down
13 changes: 7 additions & 6 deletions test/fixtures/nonce/pages/use-head.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
</template>

<script lang="ts" setup>
const nonce = useCookie('nonce')
useHead({
script: () => ({
src: '/loader.js',
nonce: nonce.value
})
})
script: () => {
return {
key: 'loader',
src: 'loader.js'
}
}
}, { mode: 'server' }) // workaround double load on ssr, see https://github.com/unjs/unhead/issues/136
</script>
13 changes: 13 additions & 0 deletions test/fixtures/nonce/pages/with-inline-script.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<template>
<div>
test
</div>
</template>

<script setup>
useHead({
script: [
{ innerHTML: "var inlineLiteral = '\x3Cscript>console.log(\"example\")'" }
]
}, { mode: 'server' })
</script>
9 changes: 9 additions & 0 deletions test/fixtures/nonce/pages/with-styling.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<template>
<div>test</div>
</template>

<style>
div {
color: red;
}
</style>
2 changes: 1 addition & 1 deletion test/fixtures/nonce/public/external.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// this file is simulated to be an external file
// it will be loaded by the loader.js
alert('hello from external')
console.log('hello from external')
Empty file.
2 changes: 1 addition & 1 deletion test/fixtures/nonce/public/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ function loader() {
script.src = 'external.js';

// add to the DOM
document.body.appendChild(script);
document.head.appendChild(script);
}

loader();
31 changes: 24 additions & 7 deletions test/nonce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,21 @@ describe('[nuxt-security] Nonce', async () => {
rootDir: fileURLToPath(new URL('./fixtures/nonce', import.meta.url))
})

const expectedNonceElements = 7 // 1 from app.vue/useHead, 6 for nuxt

it('injects `nonce` attribute in response', 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 elementsWithNonce = text.match(new RegExp(`nonce="${nonce}"`, 'g'))?.length ?? 0

expect(res).toBeDefined()
expect(res).toBeTruthy()
expect(nonce).toBeDefined()
expect(elementsWithNonce).toBe(9)
expect(elementsWithNonce).toBe(expectedNonceElements)
})

it('does not renew nonce if mode is `check`', async () => {
Expand All @@ -41,15 +43,15 @@ 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 elementsWithNonce = text.match(new RegExp(`nonce="${nonce}"`, 'g'))?.length ?? 0

expect(res).toBeDefined()
expect(res).toBeTruthy()
expect(nonce).toBeDefined()
expect(elementsWithNonce).toBe(11)
expect(elementsWithNonce).toBe(expectedNonceElements + 1) // 1 extra for loader.js in useHead
})

it('removes the nonce from the CSP header when nonce is disabled', async () => {
Expand All @@ -59,15 +61,30 @@ describe('[nuxt-security] Nonce', async () => {
const noncesInCsp = cspHeaderValue?.match(/'nonce-(.*?)'/)?.length ?? 0

expect(noncesInCsp).toBe(0)
expect(cspHeaderValue).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' https: 'unsafe-inline'; upgrade-insecure-requests; script-src 'self' 'strict-dynamic'")
expect(cspHeaderValue).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'")
})

it('does not add nonce to literal strings', async () => {
const res = await fetch('/')
const res = await fetch('/with-inline-script')

const text = await res.text()
const untouchedLiteral = text.includes('var inlineLiteral = \'<script>console.log("example")</script>\'')
const untouchedLiteral = text.includes('var inlineLiteral = \'<script>console.log("example")\'')

expect(untouchedLiteral).toBe(true)
})

it('injects `nonce` attribute in style tags', async () => {
const res = await fetch('/with-styling')

const cspHeaderValue = res.headers.get('content-security-policy')
const nonce = cspHeaderValue?.match(/'nonce-(.*?)'/)![1]

const text = await res.text()
const elementsWithNonce = text.match(new RegExp(`nonce="${nonce}"`, 'g'))?.length ?? 0

expect(res).toBeDefined()
expect(res).toBeTruthy()
expect(nonce).toBeDefined()
expect(elementsWithNonce).toBe(expectedNonceElements + 1) // one extra for the style tag
})
})

1 comment on commit 0136784

@vercel
Copy link

@vercel vercel bot commented on 0136784 Jul 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

nuxt-security – ./

nuxt-security.vercel.app
nuxt-security-baroshem.vercel.app
nuxt-security-git-main-baroshem.vercel.app

Please sign in to comment.