Strict CSP
An advanced guide on Strict Content Security Policy
Foreword
This section is provided as a guide to help you understand the implications of a Strict CSP for your Nuxt application.
Strict CSP is known to be hard to implement, especially for JS frameworks. You can read more about Strict CSP here.
Our recommended values provide sensible defaults that will enable Strict CSP in your application with minimum effort.
However you may encounter situations where you want to finetune your settings, which may imply some level of code refactoring. This guide walks you through the elements that you need to take into account.
- W3C standards
- MDN documentation
- OWASP CSP cheatsheet
- Auth0 blog series on CSP, SPAs and finetuning
- Google's CSP Overview
- Google's CSP Evaluator
The CSP challenges
For modern Javascript frameworks such as Nuxt, a proprer implementation of CSP needs to solve three different challenges simultaneously:
Resource Control
The goal of CSP is to prevent your site from loading unauthorized scripts and stylesheets.
In most cases, you don't have significant control over the resources that Nuxt is trying to load.
Sometimes, you don't even know which resources are being loaded, and how they are being loaded (external or inlined), unless you start digging really deep to find out.
Hydration
Nuxt is designed as an isomorphic framework, which means that HTML is rendered first server-side, and then client-side via the hydration mechanism.
While Nuxt Security can make a lot of the heavy-lifting to ensure everything is fine on the server-side, hydration is a mechanism by which the browser will sometimes inject scripts and styles in your application.
Because CSP is designed to prevent scripts and styles injection, hydration requires carefully-crafted policies.
Rendering Mode
Nuxt is an SSR-first framework, but also provides support for SSG mode.
In SSR mode, Nuxt is in charge serving your application (via the Nitro server), and therefore can control how the CSP policies are delivered. However in SSG mode, your files are delivered by your own static provider, so we cannot leverage Nitro to control CSP.
This is why Nuxt Security uses different mechanisms for SSR (nonces) and SSG (hashes).
Inline vs. External
CSP treats inline elements and external resources differently.
Inline elements
Inline elements are elements which are directly inserted in your HTML. Examples include:
<!-- An inlined script -->
<script>console.log('Hello World')></script>
<!-- An inlined style -->
<style>h1 { color: blue }</style>
External resources
External resources are elements that are loaded from a server. Examples include:
<!-- An image loaded from the example.com domain -->
<img src="https://example.com/my-image.png">
<!-- A script loaded from your origin, this is still an 'external' resource -->
<script src="/_nuxt/entry.065a09b.js" />
<!-- A stylesheet loaded from a CDN -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" />
Why they differ
The difference between external and inlined elements is important in CSP terminology because of 2 reasons:
1. The danger is not the same.
- An external resource can be dangerous because it can load a malicious script (e.g.
<script src="https://evildomain.com/malicious.js">
).
CSP has a simple and efficient mechanism against this issue: you need to whitelist your external resources.
If you set your CSP policy asscript-src https://example.com
, the script fromhttps://evildomain.com
will be blocked becauseevildomain
is not in the whitelist. - An inline resource is more dangerous because it can lead to XSS injection. Your code could be vulnerable to an inline script injection such as
<script>doSomethingEvil()</script>
.
For inline elements, CSP has no easy way to determine whether they are legit or not.
Therefore CSP adopts a different (and quite brutal) first-line of defense against this issue: it forbids all inline elements by default.
2. The problem for Nuxt is not the same
- Nuxt applications typically insert lots of external and inline elements in a dynamic manner.
While the insertion of external resources is not a huge problem (they can be whitelisted), the insertion of inline elements is a real issue with Nuxt because CSP then blocks the whole application.
Allowing inline elements for Nuxt
Fortunately, CSP provides several mechanisms to allow the dynamic insertion of legit inline elements.
The available mechanisms depend on the CSP version that you are using. There are three versions available, the most recent being CSP Level 3.
CSP Level 3 is supported by most modern browsers and should be the default approach. You can check adoption on caniuse
Each level is backwards-compatible with the previous ones, meaning that
- If your browser supports Level 3, you can still write Level 1 policies
- If your browser only supports Level 2, it will ignore Level 3 syntax
'unsafe-inline' (CSP Level 1)
In the oldest version (CSP Level 1), the only way to allow inline elements was to use the 'unsafe-inline'
directive.
This meant that all inline elements were allowed. This disabled the protection that CSP offered against inline injection, but it was the only way to make Nuxt work under CSP.
In other words, CSP Level 1 has a binary approach to inline elements: either they are all denied, or they are all allowed.
Obviously, allowing all unsafe inline scripts is not recommended: as it name implies, it is unsafe. But as a last resort and if everything else fails, you still have this option.
'unsafe-inline'
is unsafe and therefore not recommended.
However CSP Level 2 has safe alternatives.
See below how you can indicate which inline elements should be allowed.
Nonces and Hashes (CSP Level 2)
CSP Level 2 introduced two mechanisms to determine which inline elements are allowed: nonces and hashes.
Nonces
A nonce is a unique random number which is generated by the server for each request. This nonce is added to each inline element and when the response arrives, the browser will see :
<script nonce="somerandomstring">console.log('Hello World')</script>
The server also sends the nonce in the HTTP headers:
Content-Security-Policy: script-src 'nonce-somerandomstring'
Now the browser can compare the two nonce values to determine that the inline element is valid.
Hashes
A hash is the SHA hash of the inline code. Hashes are not added to the inline elements, but the browser can hash the content of the inline script itself:
<script>console.log('Hello, World !!')</script>
<!-- [Take that string and hash it] -->
The server sends the hashed value in the HTTP headers:
Content-Security-Policy: script-src 'sha256-......'
Now the browser can compare the hash received in the headers with the value it computed itself to determine that the inline element is valid.
With CSP Level 2, we can now indicate which inline elements should be allowed and which ones should be rejected.
Implications for Nuxt
However, there are many important details that you should know:
- Hashes and Nonces cancel
'unsafe-inline'
. In other words, all inline elements must have a nonce or hash.
Therefore for Nuxt applications to work, it is critical that every single inline element is included. If one inline element is omitted, it will be blocked. - Hashes and Nonces are primarily intended for inline elements. External resources are still supposed to be whitelisted the old way, i.e. by including the domain name or file name in the policy. However, CSP Level 2 added the option to also whitelist external resources by nonce, but not by hash. So:
- If you use nonce: Inline and external elements can be whitelisted by nonce. External elements can also be whitelisted by name.
- If you use hash: Inline elements can be whitelisted by hash. External elements still need to be whitelisted by name.
- Hashes and Nonces only work on scripts and styles. It is useless to use them on other tags (
<img>
,<frame>
,<object>
etc.), or to inlude them in any policy other thanscript-src
andstyle-src
.
A common mistake is to try to whitelist an external image by nonce via theimg-src
policy: this will not work.
- SSR vs SSG: Using SSR is easier because the nonce whitelists both inline and external elements. But if you use SSG, you will still need to whitelist external elements by name.
- Hydration: This problem remains largely unsolved, because now all inline elements must have a nonce or hash. If the client-side hydration mechanism tries to insert an element, that element will be blocked:
- All inserted inline elements will be blocked;
- Inserted external elements will be blocked, except if you have whitelisted them by name (even in the SSR case)
Therefore, a Level 2 configuration file should look like:
export defaultNuxtConfig({
security: {
headers: {
contentSecurityPolicy: {
"script-src": [
"'nonce-{{nonce}}'",
// nonce will allow inline scripts that are inserted server-side
// But the application will block if client-side hydration tries to insert a script
"https:example.com"
// example.com must still be whitelisted by name to support SSG
// example.com must still be whitelisted by name to support hydration
]
}
}
}
})
Some simple Nuxt applications will be able to operate under that scheme, but as you can see, hydration constraints defeat most of the solutions brought by CSP Level 2.
However they lay the foundation for the real solution which is CSP Level 3.
See below how you can unblock hydration on the client-side.
'strict-dynamic' (CSP Level 3)
Hydration solved
CSP Level 3 was designed by folks at Google who were facing the problems described above, and who came up with a solution: 'strict-dynamic'
.
What 'strict-dynamic'
does, is it allows a pre-authorized parent script to insert any child script. If the parent script is approved by its nonce or hash, then all children scripts do not need to carry a nonce or hash anymore.
So when hydration time comes, the Nuxt root script can now insert any inline or external script.
Important details
However, before you rush to this miraculous solution, you need to know the additional limitations that came with 'strict-dynamic'
:
'strict-dynamic'
only works for the 'script-src' policy. A common mistake is to try to set'strict-dynamic'
on thestyle-src
policy: it will not work.'strict-dynamic'
can only authorize scripts. In other words, a script inserted by Nuxt will be allowed, but a style inserted by Nuxt will be rejected.
In practice, this means that if your Nuxt application is dynamically modifying styles, you will need to go back to Level 1 with'unsafe-inline'
for styles, and in that case you will need to be careful not to add any nonce or hash to thestyle-src
policy (because, remember, nonces and hashes cancel'unsafe-inline'
).
Allowing'unsafe-inline'
for styles is widely considered as acceptable. Known exploits are not common and center around the injection of image urls in CSS. This risk can be eliminated if you set theimg-src
policy properly.'strict-dynamic'
cancels external whitelists. It is not possible to allow external scripts by name anymore if you use'strict-dynamic'
. Old-type external whitelists such as'self' https://example.com
are simply ignored when'strict-dynamic'
is used.
In pratice, this means that external scripts must either:- be inserted by an authorized parent ('strict-dynamic' enters into play);
- or be individually whitelisted by nonce (as in CSP Level 2) or integrity hash (a new CSP Level 3 feature).
In Nuxt 3.11+, the easiest way to insert an external script is to do it viauseScript
. See our section below on theuseScript
composable
If you are using Nuxt before version 3.11, you can also use theuseHead
composable.
In summary
- You use
'strict-dynamic'
on thescript-src
policy - You use
'useScript'
to insert your external scripts
With this setup, a Level 3 configuration file is much simpler:
export defaultNuxtConfig({
security: {
headers: {
contentSecurityPolicy: {
"script-src": [
"'nonce-{{nonce}}'",
// The nonce allows the root script
"'strict-dynamic'"
// All scripts inserted by the root script will also be allowed
]
}
}
}
})
Whitelisting external resources
The useScript
composable
The Nuxt Scripts module allows you to insert any external script in one single line with its useScript
composable.
useScript('https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js')
The useScript
method has several key features:
- It is an isomorphic function, i.e. you don't have to worry whether it is executed on the server-side or the client-side
- It is loaded by the Nuxt root script, which means your script will always be loaded under
'strict-dynamic'
- 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
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
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 on @nuxt/scripts and find out how easy it is to include Google Analytics in your application:
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
For versions of Nuxt before 3.11, you can still use the older useHead
approach to load your external scripts.
Compared to useScript
, useHead
provides a reduced set of features :
- It is also an isomorphic function
- It is also executed by the Nuxt root script, so your external script will be loaded under
'strict-dynamic'
.
However:
- It inserts inline event handlers, which means that execution could be denied after load
- You will have to write custom code to determine when loading has finished if you need to extract globals from the
window
object
Despite these limitations, it is still possible to load external scripts with some minor tweaks:
- Include the external script via
useHead
useHead({
script: [
// Insert any external script with strict-dynamic
{
src: 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js',
crossorigin: 'anonymous'
}
]
}, {
mode: 'client' // Required only for SSG mode
})
Note: you will need to set the { mode: 'client' }
option if you want to ensure compatibility with SSG mode.
- If required, modify your
nuxt.config.ts
file to allow theonload
event handler:
defineNuxtConfig({
security: {
headers: {
contentSecurityPolicy: {
'script-src-attr': [
"'unsafe-hashes'",
"'sha256-jp2rwKRAEWWbK5cz0grQYZbTZyihHbt00dy2fY8AuWY='",
], // Allow the useHead 'onload' inline event handler
}
}
}
})
Note: you will need to set the 'unsafe-hashes'
values only if your script relies on the onload
event to execute.
Debugging external resources
Because whitelisting of external resources can become difficult, especially in the SSG case, this section is here to help you find alternatives and solutions.
The Strict CSP Checklist
1. Did you keep all default options ?
For an optimal experience, we recommend that you stick with the default values of
- the
contentSecurityPolicy
option - the
nonce
option - the
ssg
option
2. Are you using 'strict-dynamic'
?
If you have modified the "script-src"
values of the contentSecurityPolicy
option, please make sure that
- you did not delete the
'strict-dynamic'
or the'nonce-{{nonce}}'
values. - you did not disable the
nonce
or thessg
options.
If you cannot, or do not want to use 'strict-dynamic'
mode, be prepared for things to potentially break under CSP.
In that case, please see Without strict-dynamic below.
3. Do you need to execute the script on the server ?
We recommend that you insert your external resources with useScript
.
This is necessary to ensure that your script is loaded by the client-side under 'strict-dynamic'
, and therefore executes in a CSP-compatible way.
In some rare cases however, you might want the server-side to load the script.
Please make sure you understand the implications first:
- Are you sure the server needs to load the script ? Most external resources are designed to be executed on the client-side only, so this should normally be unnecessary.
- Do you need to load your script via HTML tag insertion ? If your script is executed by the server, it is likely that you should have bundled your server-side script by way of a NPM module rather than loading it from a URL.
4. Are you deploying with SSR ?
If this is the case, you are not supposed to encounter any issue because your script will be whitelisted by nonce.
With useScript
, you can even run your script isomorphically on the server and on the client by enabling the { trigger: 'server' }
option:
useScript({
src: 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js',
crossorigin: 'anonymous',
}, {
trigger: 'server', // Use this option so that the script tag will be injected by the server,
stub() {
if (process.server) {
// This code will be executed on the server
// See documentation at: https://unhead.unjs.io/usage/composables/use-script#ssr-stubbing
}
}
})
5. Are you deploying with SSG ?
If this is the case, clearly you should not be seeking to execute the script on the server-side (you don't have a server...).
The universal solution is then to select client-only mode by including your script with useScript
.
useScript
.useScript
in client-only mode, please continue reading below.6. In SSG, are you unable to use client-only mode ?
In other words, you have a specific requirement that forces you to use the server to inject the <script>
tag, instead of letting the client insert the tag.
In that case, you will need to know the integrity
hash of your external scripts.
With useScript
you can write:
useScript({
src: 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js',
crossorigin: 'anonymous',
integrity: 'sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL' // Add the integrity hash of your external script, so that it will execute on the client
}, {
trigger: 'server' // This option will inject the script in the HTML static source
})
Alternative Solutions
- Option 1: verify if there is an existing Nuxt module that already wraps your script:
npm install @bootstrap-vue-next/nuxt
export default defineNuxtConfig({
modules: ['@bootstrap-vue-next/nuxt'],
bootstrapVueNext: {
composables: true, // Will include all composables
},
css: ['bootstrap/dist/css/bootstrap.min.css'],
})
- Option 2: verify if your script can be installed via npm module rather than
<script>
tags:
npm install @stripe/stripe-js
<script setup>
import { loadStripe } from '@stripe/stripe-js';
onMounted(async() => {
const stripe = await loadStripe('pk_test_xxxxxxxx');
})
</script>
- Option 3: wrap the script yourself in a client-side Nuxt plugin
import { useScript } from 'unhead';
export default defineNuxtPlugin((nuxtApp) => {
const { $script } = useScript(
{
src: 'https://example.com/script.js',
async: true,
defer: true,
}
)
return {
provide: {
script: $script
},
}
})
Your script will be loaded globally and available in your application via:
const { $script } = useNuxtApp()
- Option 4: create a copy of the current version of script, hash it yourself and serve it locally
# Suppose you have made a local copy of the bootstrap javascript distributable
# Calculate the integrity hash yourself
cat bootstrap-copy.js | openssl dgst -sha384 -binary | openssl base64 -A
# Copy the file in the public folder of your Nuxt application
cp bootstrap-copy.js /my-project/public
useHead({
script: [
{
src: '/bootstrap-copy.js', // Serve the copy from self-origin
crossorigin: 'anonymous',
integrity: 'sha384-.....' // The hash that you have calculated above
}
]
})
strict-dynamic
requires extra care to ensure Strict CSP.
If all above alternatives have failed, you might have to abandon
strict-dynamic
Without 'strict-dynamic'
If you cannot use 'strict-dynamic'
for your app, you can still use the useHead
composable.
In that case, you are going back to CSP Level 2, which means that you will need to take into account the following constraints:
1. Client-side Hydration might break
Because you don't have 'strict-dynamic'
anymore, there is a possibility that Nuxt may seek to insert inline scripts on the client side that will get rejected by CSP.
It is not always the case though, so depending on how your application is designed, you might still get Strict CSP via nonces or hashes and a fully-functional application.
2. You will need to whitelist external scripts manually
- If you are using SSR, Nuxt Security will do this for you automatically by inserting the nonce on the server-side.
- If you are using SSG however, you will need to whitelist the external scripts either by name or by integrity hash.
For maximum compatibility between the two modes, it is easier to whitelist the external scripts by name:
export defaultNuxtConfig({
security: {
nonce: true,
ssg: {
hashScripts: true // In the SSG case, inline scripts generated by the server will be allowed by hash
},
headers: {
contentSecurityPolicy: {
"script-src": [
"'nonce-{{nonce}}'", // Nonce placeholders in the SSR case will allow inline scripts generated on the server
"'self'", // Allow external scripts from self origin
"sha384-....." // Whitelist your external scripts by integrity hash in the SSG case
"https://domain.com/external-script.js", // Or by fully-qualified name, this is compatible with both SSR and SSG
"https://trusted-domain.com" // Or by domain if you can fully trust the domain, will also be compatible with both SSR and SSG
]
}
}
}
})
'strict-dynamic'
.
You will need to be careful to whitelist all of your external scripts.
Not Strict CSP
If all of the above solutions have failed, you will need to go back to CSP Level 1 (not strict) with 'unsafe-inline'
.
export defaultNuxtConfig({
security: {
nonce: false,
ssg: {
hashScripts: false // Disable hashes because they would cancel 'unsafe-inline'
},
headers: {
contentSecurityPolicy: {
"script-src": [
"'unsafe-inline'", // Allow unsafe inline scripts: unsafe !
//"'nonce-{{nonce}}'", // Disable nonce placeholders in the SSR case because they would cancel 'unsafe-inline'
"https://domain.com/external-script.js", // Whitelist your external scripts by fully-qualified name
"https://trusted-domain.com" // Or by domain if you can fully trust the domain
]
}
}
}
})
script-src
is unsafe.
This setup is not a Script CSP
Additional considerations
CSP Headers
There are two ways for the CSP policies to be transmitted from server to browser: either via the response's HTTP headers or via the document HTML's <meta http-equiv>
tag.
It is generally considered safer to send CSP policies via HTTP headers, because in that case, the browser can check the policies before parsing the HTML. On the opposite, when the HTML <meta>
tag is used, this tag will be read after the first elements of the <head>
of the document.
Nuxt Security uses a different approach, depending on whether SSR or SSG is used.
- In SSR mode we use the HTTP headers mechanism. This is because
nuxi build
spins off a Nitro server instance that can be used to modify the HTTP headers on the fly. - In SSG mode we use the HTML
<meta>
mechanism. This is becausenuxi generate
does not build a server. It only builds a collection of static HTML files that you then need to upload to a static server or a CDN. We have therefore have no control whatsoever over the HTTP headers that this external server will generate, and we need to resort to the fallback HTML approach.
CSP Headers for SSG via Nitro Presets
Nuxt Security supports CSP via HTTP headers for Nitro Presets that generate HTTP headers.
When using the SSG mode, some static hosting services such as Vercel or Netlify provide the ability to specify a configuration file that governs the value of the headers that will be generated. When these hosting services benefit from a Nitro Preset, it is possible for Nuxt Security to predict the value of the CSP headers for each page and write the value to the configuration file.
This feature is enabled by default with the ssg: exportToPresets
option.
CSP will be delivered via HTTP headers, in addition to the standard
<meta http-equiv>
approach. If you want to disable the meta tag, so that only the HTTP headers are used, you can do so with the ssg: meta
option.CSP Headers for SSG via prerenderedHeaders
hook
Nuxt Security allows you to generate your own headers rules with the nuxt-security:prerenderedHeaders
buildtime hook.
If you do not deploy with a Nitro preset, or if you have specific requirements that are not met by the ssg: exportToPresets
default, you can use this hook to generate your headers configuration file yourself.
See our documentation on the prerenderedPages hook
<meta http-equiv>
approach.CSP Headers for Hybrid Pre-Rendered Pages
Nuxt Security supports CSP via HTTP headers for pre-rendered pages of Hybrid applications.
This feature is enabled by default with the ssg: nitroHeaders
option.
<meta http-equiv>
approach.
If you want to disable the meta tag, so that only the HTTP headers are used, you can do so with the
ssg: meta
option.Per Route CSP
Nuxt Security gives you the ability to define per-route CSP. For instance, you can have Strict CSP on the admin section of your application, and a more relaxed policy on the blog section.
However you should keep in mind that defining a per-route CSP can lead to issues:
- When a user loads your Nuxt application for the first time from the
/index
page, the CSP headers returned by the server will be those of theindex
page. - When the user then navigates to the
/admin
section of your site, this navigation happens on the client-side. No additional request is being made to the server. Consequently, the CSP policy in force is still the one of the/index
page. In order to refresh the policies, you need to force a hard-reload of the page, e.g. viareloadNuxtApp()
or via<NuxtLink :external="true">
.
These considerations are equally true for SSR (where the server needs to be hit again to recalculate the CSP headers), and for SSG (where the server needs to be hit again to serve the new static HTML file).
Please see our FAQ section on Updating Headers on a specific route
Conclusion
In order to obtain a Strict CSP on Nuxt apps, we need to use strict-dynamic
. This mode disallows the ability for scripts to insert inline styles, and cancels the ability to whitelist external resources by name. In conjunction with the fact that nonces and hashes disable the 'unsafe-inline'
mode, this leaves us with very few options to customize our CSP policies.
On the other hand, it obliges application developers to adopt a standardized mindset when thinking about CSP. Less configuration options means less potential loopholes that malicious actors can seek to exploit.
With this in mind, we recommend that you implement your Strict CSP policy by starting from our default configuration values, and modifying only the required values.