Skip to content

Commit

Permalink
Merge pull request #492 from Baroshem/chore/2.0.0
Browse files Browse the repository at this point in the history
Chore/2.0.0
  • Loading branch information
Baroshem authored Sep 19, 2024
2 parents 2d51282 + 4c577d1 commit 6005b46
Show file tree
Hide file tree
Showing 31 changed files with 612 additions and 146 deletions.
15 changes: 0 additions & 15 deletions docs/content/1.documentation/1.getting-started/1.setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,3 @@ security: {
```

You can find more about configuring `nuxt-security` [here](/documentation/getting-started/configuration).

## Using with Nuxt DevTools

In order to make this module work with Nuxt DevTools add following configuration to your projects:

```js{}[nuxt.config.ts]
export default defineNuxtConfig({
modules: ['nuxt-security', '@nuxt/devtools'],
security: {
headers: {
crossOriginEmbedderPolicy: process.env.NODE_ENV === 'development' ? 'unsafe-none' : 'require-corp',
},
},
});
```
31 changes: 0 additions & 31 deletions docs/content/1.documentation/2.headers/1.csp.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,15 +215,6 @@ export default defineNuxtConfig({
- `"'nonce-{{nonce}}'"` placeholder: Include this value in any individual policy that you want to be governed by nonce.


::alert{type="warning"}
Our default recommendation is to avoid using the `"'nonce-{{nonce}}'"` placeholder on `style-src` policy.
<br>
⚠ This is because Nuxt's mechanism for Client-Side hydration of styles could be blocked by CSP in that case.
<br>
For further discussion and alternatives, please refer to our [Advanced Section on Strict CSP](/documentation/advanced/strict-csp).
::


_Note: Nonce only works for SSR. The `nonce` option and the `"'nonce-{{nonce}}'"` placeholders are ignored when you build your app for SSG via `nuxi generate`._


Expand Down Expand Up @@ -304,28 +295,6 @@ Please see below our section on [Integrity Hashes For SSG](#integrity-hashes-for
_Note: Hashes only work for SSG. The `ssg` options are ignored when you build your app for SSR via `nuxi build`._



## Hot reload during development

If you have enabled `nonce-{{nonce}}` on `style-src`, you will need to disable it in order to allow hot reloading during development.

```ts
export default defineNuxtConfig({
security: {
nonce: true,
headers: {
contentSecurityPolicy: {
'style-src': process.env.NODE_ENV === 'development' ?
["'self'", "'unsafe-inline'"] :
["'self'", "'unsafe-inline'", "nonce-{{nonce}}"]
}
}
}
})
```

Note that this is not necessary if you use our default configuration settings.

## Per-route configuration

All Content Security Policy options can be defined on a per-route level.
Expand Down
16 changes: 13 additions & 3 deletions docs/content/1.documentation/3.middleware/4.cors-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ You can also disable the middleware globally or per route by setting `corsHandle
CORS handler accepts following configuration options:

```ts
interface H3CorsOptions {
origin?: '*' | 'null' | (string | RegExp)[] | ((origin: string) => boolean);
interface CorsOptions = {
origin?: '*' | string | string[];
useRegExp?: boolean;
methods?: '*' | HTTPMethod[];
allowHeaders?: '*' | string[];
exposeHeaders?: '*' | string[];
Expand All @@ -65,7 +66,16 @@ interface H3CorsOptions {

- Default: `${serverUrl}`

The Access-Control-Allow-Origin response header indicates whether the response can be shared with requesting code from the given origin.
The Access-Control-Allow-Origin response header indicates whether the response can be shared with requesting code from the given origin. Use `'*'` to allow all origins. You can pass a single origin, or a list of origins.

### `useRegExp`

Set to `true` to parse all origin values into a regular expression using `new RegExp(origin, 'i')`.
You cannot use RegExp instances directly as origin values, because the nuxt config needs to be serializable.
When using regular expressions, make sure to escape dots in origins correctly. Otherwise a dot will match every character.

The following matches `https://1.foo.example.com`, `https://a.b.c.foo.example.com`, but not `https://foo.example.com`.
`'(.*)\\.foo.example\\.com'`

### `methods`

Expand Down
39 changes: 5 additions & 34 deletions docs/content/1.documentation/5.advanced/2.faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,43 +245,14 @@ Next, you need to configure your img tag to include the `crossorigin` attribute:
ℹ Read more about it [here](https://github.com/Baroshem/nuxt-security/issues/138#issuecomment-1497883915).
::

## Using nonce with CSP for Nuxt Image
## Nuxt Image

Having securely configured images is crucial for modern web applications. Check out how to do it below:

```ts
// nuxt.config.ts

security: {
nonce: true,
headers: {
contentSecurityPolicy: {
'img-src': ["'self'", 'data:', 'https:'],
'script-src': [
"'self'", // backwards compatibility for older browsers that don't support strict-dynamic
"'nonce-{{nonce}}'",
"'strict-dynamic'"
],
'script-src-attr': ["'self'"]
}
}
}
```

And then configure `NuxtImg` like following:

```vue
<template>
<NuxtImg src="https://localhost:8000/api/image/xyz" :nonce="nonce" />
</template>
<script lang="ts" setup>
const nonce = useNonce()
</script>
```
When using `<NuxtImg>` or `<NuxtPicture>`, an inline script will be used for error handling during SSR.
This will lead to CSP issues if `unsafe-inline` is not allowed and the image fails to load.
Using nonces for inline event handlers is not supported, so currently there is no workaround.

::alert{type="info"}
ℹ Read more about it [here](https://github.com/Baroshem/nuxt-security/issues/218#issuecomment-1736940913).
ℹ Read more about it [here](https://github.com/nuxt/image/issues/1011#issuecomment-2242761992).
::

## Issue on Firefox when using IFrame
Expand Down
18 changes: 9 additions & 9 deletions docs/content/1.documentation/5.advanced/3.strict-csp.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ export defaultNuxtConfig({
### The `useScript` composable
Starting from Nuxt 3.11, it is possible to insert any external script in one single line with the new `useScript` composable.
The Nuxt Scripts module allows you to insert any external script in one single line with its `useScript` composable.
```ts
useScript('https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js')
Expand All @@ -324,24 +324,24 @@ The `useScript` method has several key features:
- It does not insert inline event handlers, therefore CSP will never block the script from executing after load
- It is designed to load and execute asynchronously, which means you don't have to write code to check whether the script has finished loading before using it
For all of these reasons, we strongly recommend `useScript` as the best way to load your external scripts in a CSP-compatible way.
In addition, Nuxt Scripts provide easy integration of `useScript` into any Nuxt application:
- A number of standard scripts are already pre-packaged
- You can load your scripts globally in `nuxt.config.ts`
- `useScript` is auto-imported
The `unjs/unhead` repo has a [detailed section here](https://unhead.unjs.io/usage/composables/use-script) on how to use `useScript`.
For all of these reasons, we strongly recommend using the Nuxt Scripts module as the best way to load your external scripts in a CSP-compatible way.
Check out their examples and find out how easy it is to include Google Analytics in your application:
Check out their examples on [@nuxt/scripts](https://scripts.nuxt.com) and find out how easy it is to include Google Analytics in your application:
```ts
import { useScript } from 'unhead'
const { gtag } = useScript({
src: 'https://www.google-analytics.com/analytics.js',
}, {
const { gtag } = useScript('https://www.google-analytics.com/analytics.js', {
use: () => ({ gtag: window.gtag })
})
// Now use any feature of Google's gtag() function as you wish
// Instead of writing complex code to find and check window.gtag
```
If you don't want to install the Nuxt Scripts module, you can still use the uderlying native `useScript` method. You will need to `import { useScript } from '@unhead/vue'` in order to use it.
### The `useHead` composable
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "nuxt-security",
"version": "2.0.0-rc.9",
"version": "2.0.0",
"license": "MIT",
"type": "module",
"homepage": "https://nuxt-security.vercel.app",
Expand Down Expand Up @@ -56,19 +56,19 @@
"defu": "^6.1.1",
"nuxt-csurf": "^1.5.1",
"pathe": "^1.0.0",
"unplugin-remove": "^1.0.2",
"unplugin-remove": "^1.0.3",
"xss": "^1.0.14"
},
"devDependencies": {
"@nuxt/eslint-config": "^0.3.10",
"@nuxt/module-builder": "^0.6.0",
"@nuxt/module-builder": "^0.8.3",
"@nuxt/schema": "^3.11.2",
"@nuxt/test-utils": "^3.12.0",
"@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>
1 change: 0 additions & 1 deletion playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ export default defineNuxtConfig({
// Global configuration
security: {
headers: {
crossOriginEmbedderPolicy: false,
xXSSProtection: '0'
},
rateLimiter: {
Expand Down
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>
2 changes: 1 addition & 1 deletion src/defaultConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const defaultSecurityConfig = (serverlUrl: string, strict: boolean) => {
headers: {
crossOriginResourcePolicy: 'same-origin',
crossOriginOpenerPolicy: 'same-origin',
crossOriginEmbedderPolicy: 'credentialless',
crossOriginEmbedderPolicy: process.env.NODE_ENV === 'development' ? 'unsafe-none' : 'credentialless',
contentSecurityPolicy: {
'base-uri': ["'none'"],
'font-src': ["'self'", 'https:', 'data:'],
Expand Down
35 changes: 28 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,21 +51,28 @@ 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
})
}

// Add meta header for Vite in development
if (import.meta.dev) {
html.head.push(
`<meta property="csp-nonce" nonce="${nonce}">`,
)
}
})
})
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)
}
})
})
Loading

0 comments on commit 6005b46

Please sign in to comment.