Understanding and implementing a proper Content Security Policy with Next.js and Styled Components

Rees MorrisRees Morris ยท April 23, 2021

A few weeks ago, I was tasked with optimising our Next.js frontend to achieve the best possible Mozilla Observatory grade.

At the time, we were receiving the worst possible rating on the site; our frontend didn't have any of the recommended security headers in place, and frankly it was the first time I'd even heard of most of them.

For most of the required headers, the Observatory provides a fairly detailed guide on what header to set and what value it should have. Despite this, there was one header in particular which really proved to be a challenge for our setup...

Content Security Policy

The purpose of the Content Security Policy (CSP) header is to add an additional layer of protection between a website and browser by blocking or limiting specific directives on a website - primarily focused on limiting the origin of external resources (scripts, styles, fonts, etc.).

For instance, you might only want images and fonts to be loaded if their URL origin is from the same website, or from a pre-defined domain (such as a CDN): or even both. CSP esentially forces you to 'whitelist' those resource origins, with the goal of mitigating the possible damage from any malicious attacks.

The main issue for websites running CSS-in-JS solutions like styled-components is that CSP strongly discourages inline styling due to the XSS risks it can pose, to a point where you would need to explicitly declare your styling as 'unsafe-inline' just for the browser to render it. Of course, we could simply stick with that and call it a day - but at that point (as the name suggests) we're essentially cheating on our own test just to get a better grade. There's a paper on it too, if you're interested to learn more.

In this post, I'm going to break down and showcase and most secure way to implement a CSP on a website running Next.js and styled-components. This is a follow-along guide, so do feel free to get stuck in!

If you are using the built-in styled-jsx library with Next.js, you may not need this guide! There's a fairly well hidden example repo which can solve this in just a few lines of code.

Setup

I'm going to assume that you already have a Next.js application running with styled-components set up. If not, but you still want in, I've created a 'follow-along' branch with everything you'll need to get started.

One of the major caveats of implementing a proper CSP header with Next.js and styled-components is that automatic static optimisation must be disabled across the application. This is because we will need to generate a nonce on the server to be read via the ctx.res property on a custom document page, which is not possible for pages that are prerendered (see the caveats on static optimisation).

If this is a deal breaker for your application (ie. if you are running a completely prerendered blog or are strongly against disabling static optimisation across the entire site), your only solution will be to use the 'unsafe-inline' value in your SCP header:

style-src 'unsafe-inline'

If your website is completely static, it's likely you won't even need CSP anyway.

Once automatic static optimisation has been disabled for your project (see the Next.js docs for the easiest way to do this), you will be able to proceed with this guide.

This guide provides code examples for websites running solely on Next.js, and websites that also have a custom Node.js server set up as well. If you're using the follow-along repo, you'll want to use the "With Express" option.

1. Understanding CSP

A typical CSP header looks like this:

Content-Security-Policy: <directive> <values>; <directive> <values>;

As mentioned above, there are a variety of directives and values that allow you to fine-tune the policies of how different aspects of your website behave (images, fonts, scripts, etc.).

The main directive is default-src, which acts as the fallback if a more specific directive is missing. For instance, assuming a CSP header is present and the browser wants to check your site's policy on what images should be loaded, it will look for the img-src directive first. If the directive isn't present, the browser will fall back to using the values of default-src instead.

Due to this, a good rule of thumb is to start with only this directive, and expand your policy outwards until the website returns to its normal behaviour. We'll be assigning the directive the value of 'none' (notice the single quotes around the string), which block all fetch directives from running. There's a bit more information on what 'none' represents here.

You should notice that all of the styling has disappeared, and any JavaScript code (such as button events or navigating between pages on the client side) has stopped working.

2. Breaking down CSP Errors

Taking a look at the console, there are various warnings all related to the CSP header we just put in place. The first error in my case is related to styled-components. The error message may vary depending on your browser, though mine (Chrome) states:

Refused to apply inline style because it violates the following Content Security Policy directive: "default-src 'self'".

Either the 'unsafe-inline' keyword, a hash ('sha256-8rBi2tfimEXn67OXb9Bu+mEGs0onxPhYkouodSo3CLc='), or a nonce ('nonce-...') is required to enable inline execution. Note also that 'style-src' was not explicitly set, so 'default-src' is used as a fallback.

Let's break down what this message is telling us. The first sentence is our browser's way of explaining that it is refusing to render the inline styling on one of our DOM elements because inline styling isn't specifically allowed by our SCP.

The next sentence explains how we can resolve this, either by adding an 'unsafe-inline' value to the CSP header, or by adding a sha256 / nonce attribute to our DOM elements. In order for inline styles to be rendered, we must implement one of these three strategies.

The final sentence simply explains that the browser first tried using the style-src directive (which is responsible for handling styling policies), but had to fall back to our default-src directive as style-src wasn't present in our CSP header.

You might also see quite a few errors grouped together, with the message:

Refused to load the script '<URL>' because it violates the following Content Security Policy directive: "default-src 'none'". Note that 'script-src-elem' was not explicitly set, so 'default-src' is used as a fallback.

This error is the reason our JavaScript code isn't working: the browser is expecting the script-src-elem directive to list the URL origin of our code (localhost), but our CSP is missing this.

You may see a few other errors as well, related to other missing directives, but we'll turn our attention to fixing the blocked JavaScript code first.

Based on the error, we'll want to update our CSP to also return a script-src-elem directive. Because the JavaScript is on the same origin as our website (both on localhost), we can simply use the 'self' value here. This will allow any scripts hosted on the same origin to execute, so we don't need to worry about updating our CSP when we deploy to a live domain.

You may notice that the JavaScript is still not working after implementing this, but the error messages have now changed:

Uncaught EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "default-src 'none'".

The error message is letting us know that our code is now being blocked because our script-src directive is missing the 'unsafe-eval' value. Or, worded the other way around, the browser has detected use of the eval() function and has blocked the code from executing due to the high risk it poses.

This error isn't actually being thrown by your code (unless you do use the eval() function, in which case you should remove it and return back here) but instead by the development environment of Next.js, which uses eval() "to generate and rebuild [eval source maps] during development".

3. Creating a CSP Generator function

To get our JavaScript working in development, we'll need to fix the issue regarding the missing 'unsafe-eval' value. As this value should only be present in the development environment, we'll create a short helper function to return our CSP values:

// Return a Content Security Policy string
const generateCSP = (): string => {
  // Define the directives/values
  const csp: Partial<Record<CSP, string>> = {
    'default-src': `'none'`,
    'script-src-elem': `'self'`
  };

  // Override directives outside production
  if (process.env.NODE_ENV !== 'production') {
    csp['script-src'] = `'self' 'unsafe-eval'`;
  }

  // Convert to string and return
  return Object.entries(csp).reduce(
    (acc, [key, val]) => `${acc} ${key} ${val};`,
    ''
  );
};

The directives we originally defined in the CSP header have now been moved to the csp object, and we're now also defining the script-src directive to have the 'unsafe-eval' value if we're not in the production environment.

If you're using TypeScript, the full CSP types can be found in this Gist. Also note that the Object.entries() method is not supported by IE11, so you may need to use a polyfill or different method if you require legacy browser support.

Let's update our middleware to use this function:

Now you'll notice that the JavaScript code is now working (hooray for progress!), but we also have an "Unhandled Runtime Error" message on the page. You may also notice a lot more inline styling errors, since the styled-components JavaScript code is now executing. The error we're most interested in is this:

Refused to connect to '<URL>' because it violates the following Content Security Policy directive: "default-src 'none'". Note that 'connect-src' was not explicitly set, so 'default-src' is used as a fallback.

We'll need to define a connect-src directive to allow those URLs to be fetched. Let's add it to our scp object in the generateSCP() function, since we'll also want this in production. As the error messages show all of the URLs being from the same origin, we can again use the 'self' value:

const csp: Partial<Record<CSP, string>> = {
  'default-src': `'none'`,
  'script-src-elem': `'self'`,
  'connect-src': `'self'`
};

Our JavaScript now looks to be working without any other warnings on the page! The next step is to get our styling working.

4. Fixing styling with a nonce

In order for the browser to allow us to use inline scripts and styles without using the 'unsafe-inline' value, we need to convince the browser that the scripts and styles it sees are genuine and not maliciously injected by a third party. This is where the nonce attribute comes in.

By definition, a nonce is a randomly generated token that should only be used once. The idea is that we'll generate the nonce on the server side and attach it to our SCP headers as an allowed value (something like 'nonce-xyz'). We'll then pass it through to the frontend and attach it to all of our scripts and styles using the nonce attribute. Before executing any scripts/styles, the browser will then compare the attribute on the element with the value in the SCP header. If the two match, they are considered genuine and will be allowed to run. Simple!

If you are developing for IE11, the nonce attribute is not supported by the browser. With that said, the Content-Security-Policy header is not supported either (only X-Content-Security-Policy is supported). This means that the use of inline styling will not affect visitors using IE11, though it may be worth considering another approach using the sandbox directive.

Let's get started! The first step is to generate the random token. I'll be using the built-in crypto library for this, though feel free to use any similar library (such as uuid) if you already have one installed.

The MDN Docs recommend the nonce to be a "base64-encoded string of at least 128 bits of data from a cryptographically secure random number generator," so we'll do just that:

import { randomBytes } from 'crypto';

const generateNonce = () => {
  return randomBytes(16).toString('base64');
};

Next we'll want to return the nonce with our SCP header by updating our generateCSP() function to accept the nonce as an argument. We'll also take this opportunity to append the style-src and script-src directives to our csp object since we know we'll need both of those. Because we're overriding the script-src directive in development, we'll also want to attach the nonce to that as well.

// Return a Content Security Policy string
const generateCSP = (nonce: string): string => {
  // Define the directives/values
  const csp: Partial<Record<CSP, string>> = {
    'default-src': `'none'`,
    'script-src-elem': `'self' 'nonce-${nonce}'`,
    'connect-src': `'self'`,
    'style-src': `'self' 'nonce-${nonce}'`,
    'script-src': `'self' 'nonce-${nonce}'`
  };

  // Override directives outside production
  if (process.env.NODE_ENV !== 'production') {
    csp['script-src'] = `'self' 'unsafe-eval' 'nonce-${nonce}'`;
  }

  // Convert to string and return
  return Object.entries(csp).reduce(
    (acc, [key, val]) => `${acc} ${key} ${val};`,
    ''
  );
};

Next, let's generate the nonce and pass it into the generateCSP() function.

Inside the render function, we can then pull the nonce:

Next, we'll want to make our nonce available to all scripts and styles within Next.js itself. We simply need to pass the value as an attribute to the Head and NextScript components:

<Html lang='en-GB'>
  <Head nonce={nonce} />
  <body>
    <Main />
    <NextScript nonce={nonce} />
  </body>
</Html>

After refreshing the page, we still don't have any styling on the site. This is because we've passed our nonce through to all scripts that Next.js loads, but styled-components styles are loaded with webpack.

5. Defining __webpack_nonce__

Certain libraries, including styled-components, allow for the nonce attribute to be provided via a __webpack_nonce__ variable, which is used primarily by webpack to inject the nonce attribute to any scripts loads.

To add this to our frontend, we simply need to inject it via a <script> tag in our pages/_document file's return function, not forgetting to add our nonce to the script itself.

return (
  <Html lang='en-GB'>
    <Head nonce={nonce}>
      <script
        nonce={nonce}
        dangerouslySetInnerHTML={{
          __html: `window.__webpack_nonce__ = "${nonce}"`
        }}
      />
    </Head>
    <body>
      <Main />
      <NextScript nonce={nonce} />
    </body>
  </Html>
);

Although it might sound counter-productive exposing the nonce value in the source code, it's actually completely safe from XSS attacks. Attackers would still need to know the nonce value in advance before they can read the value of the __webpack_nonce__ variable, since their injected scripts would be blocked without it.

If you refresh the page, you'll notice the styling appears ๐ŸŽ‰! However, you may have noticed a slight issue: for a brief moment after the page loads, the styling is completely missing before it flashes in. If you disable JavaScript, you'll notice the styling is missing permanently.

This is due to the fact that our __webpack_nonce__ variable only exists on the client side; we're embedding it as a <script> tag on the server, so styled-components doesn't actually know the value of the nonce until the app hydrates on the client side. As a result, our CSP policy is blocking the styling from rendering during the initial server render.

Unfortunately, there is no clean way of passing our nonce value directly through to styled-components yet. I opened a GitHub issue which should hopefully allow us to pass the nonce through directly, but for now we'll need to use a workaround. After investigating how styled-components fetches the nonce, it checks the environment for a window object containing the __webpack_nonce__ variable.

Whilst the window variable (rightfully) only exists on the client, we'll need to 'mock' it on our server so that the server-rendered components are also able to see the variable.

After reloading the page one final time, you'll now notice the styles are displaying immediately. Whilst this isn't the best strategy by any means, I'll be sure to update this post if the issue mentioned above is resolved.

And that's all there is to it! I hope this post has been insightful enough to help explain not only how to set strong Content Security Policy headers with Next.js and styled-components, but also what their purpose is for in general.

Go forth and make strong websites!

Bonus: Google Fonts

If you're using the 'follow-along' repository, or just have Google Fonts embedded into your website generally, you may notice the fonts are still not loading.

This is again due to the fact that we haven't whitelisted the domain that the fonts come from. Thankfully with CSP headers, we don't need to whitelist a specific URI, but can whitelist an entire domain (or subdirectory!) instead.

Taking a look at the console, the styling is missing because the Google Fonts domain is missing in our style-src directive. Let's add it to our generateCSP function:

'style-src': `'self' 'nonce-${nonce}' https://fonts.googleapis.com`

After reloading, you'll notice another error related to a missing font-src directive value. Let's add that as well:

'font-src': `https://fonts.gstatic.com`

Notice that this time we didn't include the 'self' value? If you aren't hosting any fonts within the project itself (meaning no fonts will be loaded from the same frontend URL), there's really no need to add it.

And after reloading one final time, the fonts should appear!

Bonus 2: Maximising our CSP

The Mozilla Observatory recommends adding a few extra headers that we haven't yet specified in order to maximise the strength of our CSP.

The frame-ancestors directive can be used to prevent clickjacking, by limiting which origins will be able to embed ours in an <iframe> or similar.

'frame-ancestors': `'none'`

The base-uri directive limits which origins will be able to reference ours in a <base> element, which could be used to trick our site into loading untrusted scripts.

'base-uri': `'none'`

The form-action directive restricts which origins <form> elements are allowed to submit to, which can prevent sensitive data from being submitted to a malicious domain. This only affects the action attribute on forms (and not JavaScript that might call preventDefault()), so you'll only need to include 'self' (or another origin) here if your forms need to be able to submit without JavaScript.

'form-action': `'none'`

And, just like that, we've got an amazingly strong Content Security Policy setup. Neat!