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.

Useful links on CSP:

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.

The first step when designing your own CSP rules is therefore to identify which resources you want to authorize.

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.

The second step is to design rules that do not block hydration.

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).

The last step is to take into account the differences between SSR and SSG.

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 as script-src https://example.com, the script from https://evildomain.com will be blocked because evildomain 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.
In summary, external elements are manageable via whitelisting, but inline elements are a true challenge for Nuxt. We need a mechanism to tell CSP which inline elements are legit and which are not.

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.

Using '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:

  1. 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.
  2. 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.
  3. 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 than script-src and style-src.
    A common mistake is to try to whitelist an external image by nonce via the img-src policy: this will not work.
This has several critical implications for Nuxt:
  1. 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.
  2. 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.

CSP Level 2 Nonces and Hashes will be uneffective for most Nuxt applications.
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.

For Nuxt, this solves the hydration problem, because Nuxt Security pre-authorizes your root script (the entry script).

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':

  1. 'strict-dynamic' only works for the 'script-src' policy. A common mistake is to try to set 'strict-dynamic' on the style-src policy: it will not work.
  2. '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 the style-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 the img-src policy properly.
  3. '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, the easiest way to insert an external script is to do it via useHead.
    See our section below on the useHead composable for further details.
In summary, Nuxt applications are perfectly capable of running with Strict CSP under two conditions:
  1. You use 'strict-dynamic' on the script-src policy
  2. You use 'useHead' to insert 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 useHead composable

useHead allows you to insert scripts in the <head> tag of your HTML document. It is an isomorphic function, i.e. it runs transparently both on the server-side and the client-side. In addition, it is executed by the Nuxt root script, which is already authorized under 'strict-dynamic'.

It is therefore the best place to insert external scripts for your app.

useHead({  script: [    { src: 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js',      crossorigin: 'anonymous'    },    // Insert any external script with strict-dynamic    { textContent: "console.log('Hello World')" }    // Insert an inline script  ]})

With 'strict-dynamic', these scripts will be allowed as children of the Nuxt root script.

Alternatively, if you cannot use useHead, you can also insert tags directly in the DOM (e.g. via document.createElement()). As long as the function is invoked within your Nuxt application, 'strict-dynamic' will allow execution.

SSR mode

In SSR mode, Nuxt Security will whitelist your scripts automatically by inserting the nonce on the server-side.

Because nonces can be used to whitelist external resources, your scripts will be allowed without any additional configuration on your part if the nonce option is set to true.

In SSR mode, useHead will always allow you to load your external scripts under Strict CSP.

SSG mode

In SSG mode however, there is no nonce. Because 'strict-dynamic' cancels external whitelists, you will need to insert an integrity attribute to each of your external scripts in order to allow them:

useHead({  script: [    { 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 hashes for your external scripts    }  ]})

Nuxt Security will be clever enough to pick up the integrity attributes and add them to the script-src policy if the ssg: hashScripts option is set to true.

In SSG mode, useHead will always allow you to load your external scripts under Strict CSP if you can provide their integrity hashes.
If you cannot determine the integrity hash of at least one of your external scripts, please read our recipes section below

Alternative Recipes

Because whitelisting of external resources can become difficult, especially in the SSG case, this section is here to help you find alternatives and solutions.

Checklist

1. Do you have the integrity hashes for all of your external scripts ?

Background: The server-side needs to whitelist all external resources via integrity hashes.

✅ If the answer is yes, you can deploy your application 🚀

🚫 If the answer is no, go to the next question

2. Are you generating a static SPA without pre-rendering ?

Background: if you are generating a static SPA without pre-rendering (i.e. nuxi generate with Nuxt option ssr: false), you are only deploying an HTML shell on the server. The client-side will be solely responsible for inserting the external scripts, so the server-side will not be a problem.

✅ If the answer is yes, you can deploy your application 🚀

🚫 If the answer is no, go to the next question

3. Are you using a Nitro preset that generates CSP headers ?

Background: if you are using a Nitro preset that generates CSP headers, the static server will send the CSP policy before the <meta http-equiv> tag is processed by useHead on the client-side. This will block external scripts.

For further explanation, please see our section below on CSP headers

✅ If the answer is no, you can deploy your application 🚀

🚫 If the answer is yes, go to the next paragraph and check our Solutions

Solutions

  • Option 1: set useHead for client-mode only:
useHead({  script: [    { src: 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js',      crossorigin: 'anonymous',    }  ]}, {   mode: 'client' // Load the script 'strict-dynamically' on client-side only})
  • Option 2: 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 3: 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 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 yourselfcat bootstrap-copy.js | openssl dgst -sha384 -binary | openssl base64 -A# Copy the file in the public folder of your Nuxt applicationcp 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    }  ]})
  • Option 5: wrap the script yourself in a client-side Nuxt plugin
In SSG mode, 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          "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        ]      }    }  }})
If your Nuxt application is not inserting inline scripts on the client-side, you can still get a Level 2 Strict CSP without using '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        ]      }    }  }})
Although valid from a CSP syntax perspective, allowing 'unsafe-inline' on 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 because nuxi 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

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.

Nuxt Security supports CSP via HTTP headers for Nitro Presets that output HTTP headers.

If you deploy your SSG site on Vercel or Netlify, you will benefit automatically from CSP Headers.
CSP will be delivered via HTTP headers, in addition to the standard <meta http-equiv> approach.

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 the index 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. via reloadNuxtApp().

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).

If you implement per-route CSP, you will need to enforce a hard reload upon navigation for the new CSP to enter into action.
From an application design perspective, it is better to use a single Strict CSP that will apply to all of your application pages.

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 the developers community 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 checking your configuration against the following template:

export default defineNuxtConfig({  security: {    nonce: true, // Enables HTML nonce support in SSR mode    ssg: {      hashScripts: true, // Enables CSP hash support for scripts in SSG mode      hashStyles: false // Disables CSP hash support for styles in SSG mode (recommended)    },    // You can use nonce and ssg simultaneously    // Nuxt Security will take care of choosing the adequate parameters when you build for either SSR or SSG    headers: {      contentSecurityPolicy: {        'script-src': [          "'self'",  // Fallback value, will be ignored by browsers level 3          "https://domain.com/external-script.js", // Fallback value, will be ignored by browsers level 3          "'unsafe-inline'", // Fallback value, will be ignored by browsers level 2 & 3          "'strict-dynamic'", // Strict CSP via 'strict-dynamic', supported by browsers level 3          "'nonce-{{nonce}}'" // Enables CSP nonce support for scripts in SSR mode, supported browsers level 2 & 3        ],        'style-src': [          "'self'", // Enables loading of stylesheets hosted on self origin          "https://domain.com/file.css", // Use fully-qualified filenames rather than the https: generic          "https://trusted-domain.com", // Avoid using domain stubs unless you can fully trust them          "'unsafe-inline'" // Recommended default for most Nuxt apps, but make sure 'img-src' is properly set up          //"'nonce-{{nonce}}'" // Disables CSP nonce support, otherwise would cancel 'unsafe-inline'          // You can re-enable if your application does not modify inline styles dynamically        ],        "img-src": [          "'self'", // Enables loading of images hosted on self origin          "https://domain.com/img.png", // Use fully-qualified filenames rather than the https: generic          "https://trusted-domain.com", // Avoid using domain stubs unless you can fully trust them          "blob:" // If you use Blob to construct images dynamically from javascript          // Qualifying img-src properly mitigates strongly against 'unsafe-inline' in style-src        ],        'font-src': [          "'self'",  // Enables loading of fonts hosted on self origin          "https://domain.com/font.woff", // Use fully-qualified filenames rather than the https: generic          "https://trusted-domain.com" // Avoid using domain stubs unless you can fully trust them        ],        "worker-src": [          "'self'", // Enables loading service worker from self origin,          "blob:" // If you use PWA, it is likely that the worker will be instantiated from Blob        ],        "connect-src": [          "'self'", // Enables fetching from self origin          "https://api.domain.com/service", // Use largest prefix possible on API routes          "wss://api.domain.com/messages" // Add Websocket qualifiers if used        ],        "object-src": [          "'none'"        ],        "base-uri": [          "'none'"        ]        // Do not use default-src      }    }  }})