The recommended best practice for modern CSP policies relies on hashes, nonces, and
'strict-dynamic'
. Unfortunately, these features conflict with modern SPAs. This article proposes three concrete strategies to deploy secure CSP policies for SPAs. The first relies on a simple policy allowing 'self'
, which is acceptable under certain circumstances. The second strategy enables CSP hashes by rewriting the main page to use a script loader. The third strategy inserts nonces into a dynamically served main page.Recapping CSP
The first article in this series offered an in-depth look at using CSP as a second line of defense against XSS attacks.
We covered the configuration of CSP using URL-based entries but also highlighted why such policies are often insecure. In response, we discussed Google's "universal CSP policy", shown below, which offers an excellent trade-off between security and complexity:
Content-Security-Policy: script-src 'report-sample' 'nonce-3YCIqzKGd5cxaIoTibrW/A' 'unsafe-inline' 'strict-dynamic' https: http: 'unsafe-eval'; object-src 'none'; base-uri 'self'; report-uri /webchat/_/cspreport
This policy relies on nonces to identify an initial set of legitimate script blocks and script files in the page. Once these scripts execute, they can rely on the automatic trust propagation mechanism of
'strict-dynamic'
to load additional scripts.Unfortunately, this type of policy does not work well with Single Page Applications, as we pointed out in the conclusion of the first article.
So, what is up with that? Let's take a look at the challenges of configuring CSP in SPAs and discuss a couple of solutions.
The Challenges with SPAs
Single Page Applications load a single
index.html
, which then bootstraps the necessary JavaScript code to launch the application. The code snippet below shows the index.html
page of an Angular application:<!doctype html> <html lang="en"> <head> ... </head> <body> <app-root></app-root> <script src="runtime.7b63b9fd40098a2e8207.js" defer></script> <script src="polyfills.00096ed7d93ed26ee6df.js" defer></script> <script src="main.8e56a2a77fee2657fb91.js" defer></script> </body> </html>
To deploy CSP in a SPA, we first have to tell the browser that the application's JavaScript bundle is a legitimate resource that can be loaded. In the snippet above, the bundle consists of three separate JavaScript files.
One way to do that is by approving scripts coming from the application's origin (
https://example.com
). A policy with a script-src https://example.com
directive would allow the loading of these files.Unfortunately, as we discussed in the previous article, such URL-based policies are often insecure and are deprecated. Additionally, URL-based expressions cannot be used together with
'strict-dynamic'
, which prevents the use of automatic trust propagation.One alternative is the use of hashes, a CSP Level 2 feature. However, the application's JavaScript bundle is hosted as a remote file, and hashes only work on inline code blocks. So CSP hashes are not compatible with SPAs.
CSP Level 2 also supports nonces, which are compatible with the loading of remote resources. However, one requirement for nonces is that they are unique on every page load. In essence, this means that the server has to inject a fresh nonce every time it serves a page. Doing so is easy for dynamic server-side applications but not very compatible with serving a static
index.html
file for a SPA. So nonces are also not compatible with SPAs.With both hashes and nonces out, there is no straightforward way for SPAs to use modern CSP policies. Additionally, they are incapable of using
'strict-dynamic'
for loading additional scripts. The lack of support for 'strict-dynamic'
makes it impossible to reliably incorporate third-party components such as a Twitter timeline.Right about now, I'm sure you're somewhat disappointed with CSP, and righteously so. But don't worry, we're only getting started. Let's take a look at three concrete strategies to implement CSP in a SPA.
Keep It Simple
The first strategy for enabling CSP in SPAs is straightforward. If the SPA only needs to load its application bundle and no third-party resources, the following CSP policy could be a very simple solution:
script-src 'self'
This policy allows the application to load JavaScript files from its own origin. Such a policy suffices to load all of the additional resources of an isolated, self-contained application. You will often encounter such applications in enterprise settings, where the dynamic integration of remote components is less common.
But weren't URL-based policies ineffective and open to bypasses?
Yes, they are, under the right circumstances. The paper that described numerous bypass attacks against CSP outlines a few scenarios where approving
'self'
is problematic. For example, CSP can be bypassed if ...- The application's origin also hosts vulnerable libraries
- The application's origin also hosts JSONP endpoints
- The application's origin hosts untrusted files uploaded by users
However, these threats are not an issue if the application's origin contains nothing else but the statically deployed application bundle. And if there are no bypasses, this policy is still considered secure.
To summarize, if you're building an isolated SPA with nothing else running in the same origin, this policy is a straightforward way to deploy CSP.
However, if you rely on third-party components, you will likely need to support
'strict-dynamic'
. In that case, you can rely on one of the following two strategies.Using 'strict-dynamic'
with Hashes
'strict-dynamic'
When a policy is configured with
'strict-dynamic'
, all script code approved by a hash or a nonce is allowed to load additional dependencies. This mechanism is extremely useful to allow a third-party component to load additional code or to enable lazy-loading of application components.Unfortunately,
'strict-dynamic'
causes browsers to ignore URL-based entries, so it cannot be used in conjunction with 'self'
. This means that when we enable 'strict-dynamic'
, we have to find a different way to allow the loading of the application's bundle. Loading the bundle with hashes is not an option because hashes cannot be used with remote code files. Instead, we can modify the
index.html
file to include an inline code block, which we can approve with a hash. This inline code block contains a script loader, which uses proper DOM APIs to load additional script code. This behavior is automatically approved by having 'strict-dynamic'
in the policy. The code snippets below show the modified
index.html
of an Angular application and the corresponding CSP policy. The HTML page contains two code blocks: The script loader and the code for loading a Twitter timeline. The corresponding CSP policy approves both blocks with a hash:<!doctype html> <html lang="en"> <head> ... </head> <body> <app-root></app-root> <script> let scripts = ["runtime.7b63b9fd40098a2e8207.js", "polyfills.00096ed7d93ed26ee6df.js", "main.8e56a2a77fee2657fb91.js"]; scripts.forEach(function(scriptUrl) { var s = document.createElement('script'); s.src = scriptUrl; s.async = false; // preserve execution order. document.body.appendChild(s); }); </script> <script> window.twttr = (function(d, s, id) { ... }(document, "script", "twitter-wjs")); </script> </body> </html>
Content-Security-Policy: script-src 'sha256-qaOxCJong9pt6ICami7oNScwNCv2sn3HUTzbEaQ3vrU=' 'sha256-BYW1ZgvEbfyQi82B604a0EdxK+Od5iqb/I2hgknBhiw=' 'strict-dynamic'
This workaround is not pretty but quite effective: It enables a modern CSP policy on a statically deployed SPA. At the time of writing, the strict-csp package offers experimental support for transforming any HTML file to use a script loader as described here. This package is also available as a webpack plugin. Of course, you can also perform this task in a more manual fashion.
To summarize, adding an inline script loader enables the use of hashes and
.'strict-dynamic'
Finally, note that CSP Level 3 may support the use of hashes for remote code files. However, at the time of writing, (widespread) support for that feature is still far off.
Using 'strict-dynamic'
with Nonces
'strict-dynamic'
Nonces are a more flexible alternative to hashes, as they can also be used on remote script files. Nonces are also quite compatible with
'strict-dynamic'
but must be unique on every page load. Unfortunately, that requirement clashes with a statically deployed index.html
.To enable the use of nonces in a SPA, we have to serve our
index.html
dynamically to insert a fresh nonce in each response. This process sounds complicated but is not that difficult in practice.The code example below shows a minimal NodeJS Express server that dynamically serves our main Angular application file. The Express server uses the express-csp-header middleware to configure a policy and handle nonce generation. The nonce is passed along to the view rendering engine, which inserts it into the page. The modified
index.html
, now stored as index.ejs
, is included below:const express = require("express"); const { expressCspHeader, NONCE } = require('express-csp-header'); const app = express(); const port = 3000; app.set('view engine', 'ejs'); app.use(expressCspHeader({ directives: { "script-src": [NONCE, "'strict-dynamic'"] } })); // Rewrite index.html app.get("/", (req, res) => { res.render(`views/index`, { nonce: req.nonce }); }) app.listen(port, () => {});
<!doctype html> <html lang="en"> <head> ... </head> <body> <app-root></app-root> <script nonce="<%= nonce %>" src="runtime.7b63b9fd40098a2e8207.js" defer></script> <script nonce="<%= nonce %>" src="polyfills.00096ed7d93ed26ee6df.js" defer></script> <script nonce="<%= nonce %>" src="main.8e56a2a77fee2657fb91.js" defer></script> <!-- Bootstrap the Twitter code according to https://developer.twitter.com/en/docs/twitter-for-websites/javascript-api/guides/set-up-twitter-for-websites --> <script nonce="<%= nonce %>"> window.twttr = (function(d, s, id) { ... }(document, "script", "twitter-wjs")); </script> </body> </html>
At first glance, running a dynamic server for serving a SPA seems quite complicated. However, if you take a closer look, the Express server is quite simple. Additionally, it is completely stateless, making it easy to deploy as a stateless function on various cloud platforms.
Note that only the main HTML file needs to be served dynamically. All other resources, such as JS or CSS files, can still be served statically.
To summarize, serving
dynamically enables the use of nonces andindex.html
'.'strict-dynamic
Learn web security through a hands-on exploration of some of the most notorious threats.
Download the free ebookOverview of CSP for SPAs
In a nutshell, deploying modern CSP policies with SPAs is perfectly feasible, albeit with a bit more effort than you would expect.
In this article, we covered two scenarios: isolated applications and more complex applications with dynamic code loading. We recap our recommendations for both scenarios below.
Isolated applications without third-party components
Use a simple
'self'
policy that approves the application's origin. Note that this policy is only secure if nothing else is hosted in the application's origin.Applications relying on third-party components
To enable the use of
'strict-dynamic'
, initial scripts must be approved with a hash or a nonce. To use hashes, the SPA's main page has to be rewritten at build time, but can be served statically. To use nonces, the SPA's main page requires slight modifications at build time and has to be dynamically served by a web server.Try out the most powerful authentication platform for free.
Get started →Coming Up Next
I know you're excited to get started. But before you dive into CSP yourself, take a look at the next article in this series, where we offer practical tips and tricks for deploying CSP.
About the author
Philippe De Ryck
Web Security Expert, Founder of Pragmatic Web Security