11

TLDR: I'm having trouble with setting up CSP for NextJS using Material-UI (server side rendering) and served by Nginx (using reverse proxy).

Currently I have issues with loading Material-UI stylesheet, and loading my own styles

using makeStyles from @material-ui/core/styles

NOTE:

default.conf (nginx)

# https://www.acunetix.com/blog/web-security-zone/hardening-nginx/

upstream nextjs_upstream {
  server localhost:3000;

  # We could add additional servers here for load-balancing
}

server {
  listen $PORT default_server;

  # redirect http to https. use only in production
  # if ($http_x_forwarded_proto != 'https') {
  #   rewrite ^(.*) https://$host$request_uri redirect;
  # }

  server_name _;

  server_tokens off;

  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection 'upgrade';
  proxy_set_header Host $host;
  proxy_cache_bypass $http_upgrade;

  # hide how is app powered. In this case hide NextJS is running behind the scenes.
  proxy_hide_header X-Powered-By;

  # set client request body buffer size to 1k. Usually 8k
  client_body_buffer_size 1k;
  client_header_buffer_size 1k;
  client_max_body_size 1k;
  large_client_header_buffers 2 1k;

  # ONLY respond to requests from HTTPS
  add_header Strict-Transport-Security "max-age=31536000; includeSubdomains; preload";

  # to prevent click-jacking
  add_header X-Frame-Options "DENY";

  # don't load scripts or CSS if their MIME type as indicated by the server is incorrect
  add_header X-Content-Type-Options nosniff;

  add_header 'Referrer-Policy' 'no-referrer';

  # Content Security Policy (CSP) and X-XSS-Protection (XSS)
  add_header Content-Security-Policy "default-src 'none'; script-src 'self'; object-src 'none'; style-src 'self' https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap ; form-action 'none'; frame-ancestors 'none'; base-uri 'none';" always;
  add_header X-XSS-Protection "1; mode=block";

  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_prefer_server_ciphers on;

  location / {
    # limit request types to HTTP GET
    # ignore everything else
    limit_except GET { deny all; }

    proxy_pass http://nextjs_upstream;
  }
}
sideshowbarker
  • 81,827
  • 26
  • 193
  • 197
Clumsy-Coder
  • 542
  • 1
  • 7
  • 20
  • We now have official documentation for handling nonces and Content Security Policy: https://nextjs.org/docs/app/building-your-application/configuring/content-security-policy – leerob Sep 01 '23 at 23:23

5 Answers5

12

The solution I found was to add nonce value to the inline js and css in _document.tsx

_document.tsx

Generate a nonce using uuid v4 and convert it to base64 using crypto nodejs module. Then create Content Security Policy and add the generated nonce value. Create a function to accomplish to create a nonce and generate CSP and return the CSP string along with the nonce

Add the generated CSP in the HTML Head and add meta tags.

import React from 'react';
import Document, { Html, Head, Main, NextScript } from 'next/document';
import { ServerStyleSheets } from '@material-ui/core/styles';
import crypto from 'crypto';
import { v4 } from 'uuid';

// import theme from '@utils/theme';

/**
 * Generate Content Security Policy for the app.
 * Uses randomly generated nonce (base64)
 *
 * @returns [csp: string, nonce: string] - CSP string in first array element, nonce in the second array element.
 */
const generateCsp = (): [csp: string, nonce: string] => {
  const production = process.env.NODE_ENV === 'production';

  // generate random nonce converted to base64. Must be different on every HTTP page load
  const hash = crypto.createHash('sha256');
  hash.update(v4());
  const nonce = hash.digest('base64');

  let csp = ``;
  csp += `default-src 'none';`;
  csp += `base-uri 'self';`;
  csp += `style-src https://fonts.googleapis.com 'unsafe-inline';`; // NextJS requires 'unsafe-inline'
  csp += `script-src 'nonce-${nonce}' 'self' ${production ? '' : "'unsafe-eval'"};`; // NextJS requires 'self' and 'unsafe-eval' in dev (faster source maps)
  csp += `font-src https://fonts.gstatic.com;`;
  if (!production) csp += `connect-src 'self';`;

  return [csp, nonce];
};

export default class MyDocument extends Document {
  render(): JSX.Element {
    const [csp, nonce] = generateCsp();

    return (
      <Html lang='en'>
        <Head nonce={nonce}>
          {/* PWA primary color */}
          {/* <meta name='theme-color' content={theme.palette.primary.main} /> */}
          <meta property='csp-nonce' content={nonce} />
          <meta httpEquiv='Content-Security-Policy' content={csp} />
          <link
            rel='stylesheet'
            href='https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap'
          />
        </Head>
        <body>
          <Main />
          <NextScript nonce={nonce} />
        </body>
      </Html>
    );
  }
}

// `getInitialProps` belongs to `_document` (instead of `_app`),
MyDocument.getInitialProps = async (ctx) => {
  const sheets = new ServerStyleSheets();
  const originalRenderPage = ctx.renderPage;

  ctx.renderPage = () =>
    originalRenderPage({
      enhanceApp: (App) => (props) => sheets.collect(<App {...props} />),
    });

  const initialProps = await Document.getInitialProps(ctx);

  return {
    ...initialProps,
    // Styles fragment is rendered after the app and page rendering finish.
    styles: [...React.Children.toArray(initialProps.styles), sheets.getStyleElement()],
  };
};

source: https://github.com/vercel/next.js/blob/master/examples/with-strict-csp/pages/_document.js

nginx config

make sure to remove adding header regarding Content Security Policy. It might override the CSP in _document.jsx file.


alternative solutions

Creating a custom server and injecting nonce and Content Security Policy that can be accessed in _document.tsx

Clumsy-Coder
  • 542
  • 1
  • 7
  • 20
4

Its recommended practice to set Content Security Policy in the Headers instead of meta tags. In NextJS you can set the CSP in headers by modifying your next.config.js.

Here is an example of adding CSP headers.

// next.config.js

const { nanoid } = require('nanoid');
const crypto = require('crypto');

const generateCsp = () => {
  const hash = crypto.createHash('sha256');
  hash.update(nanoid());
  const production = process.env.NODE_ENV === 'production';

  return `default-src 'self'; style-src https://fonts.googleapis.com 'self' 'unsafe-inline'; script-src 'sha256-${hash.digest(
    'base64'
  )}' 'self' 'unsafe-inline' ${
    production ? '' : "'unsafe-eval'"
  }; font-src https://fonts.gstatic.com 'self' data:; img-src https://lh3.googleusercontent.com https://res.cloudinary.com https://s.gravatar.com 'self' data:;`;
};

module.exports = {
  ...
  headers: () => [
    {
      source: '/(.*)',
      headers: [
        {
          key: 'Content-Security-Policy',
          value: generateCsp()
        }
      ]
    }
  ]
};

Next Documentation: https://nextjs.org/docs/advanced-features/security-headers

Subash
  • 7,098
  • 7
  • 44
  • 70
2

Yeah, in order to use CSP with Material-UI (and JSS), you need to use a nonce.

Since you have SSR, I see 2 opts:

  1. You can publish CSP header at server side using next-secure-headers package or even Helmet. I hope you find a way how to pass nonce from Next to the Material UI.

  2. You can publish CSP header in nginx config (how do you do it now) and generate 'nonce' by nginx even it works as reverse proxy. You need to have ngx_http_sub_module or ngx_http_substitutions_filter_module in nginx.
    TL;DR; details how it works pls see in https://scotthelme.co.uk/csp-nonce-support-in-nginx/ (it's a little bit more complicated way then just to use $request_id nginx var)

granty
  • 7,234
  • 1
  • 14
  • 21
2

Nextjs config supports CSP headers:

https://nextjs.org/docs/advanced-features/security-headers

gazdagergo
  • 6,187
  • 1
  • 31
  • 45
1

SOLUTION FOR CLIENT RENDERED APPS

Got this working with middleware and getInitialProps. You only have to SSR the <Head>{...}</Head> for this to work.

pages/_middleware.js

import {NextResponse} from 'next/server';
import {v4 as uuid} from 'uuid';

function csp(req, res) {
  const nonce = `nonce-${Buffer.from(uuid()).toString('base64')}`;
  const isProduction = process.env.NODE_ENV === 'production';
  const devScriptPolicy = ['unsafe-eval']; // NextJS uses react-refresh in dev
  res.headers.append('Content-Security-Policy', [
    ['default-src', 'self', nonce],
    ['script-src',  'self', nonce].concat(isProduction ? [] : devScriptPolicy),
    ['connect-src', 'self', nonce],
    ['img-src', 'self', nonce],
    ['style-src', 'self', nonce],
    ['base-uri',  'self', nonce],
    ['form-action', 'self', nonce],
  ].reduce((prev, [directive, ...policy]) => {
    return `${prev}${directive} ${policy.filter(Boolean).map(src => `'${src}'`).join(' ')};`
  }, ''));
}

export const middleware = (req) => {
  const res = NextResponse.next();
  csp(req, res);
  return res;
}

pages/_app.js

import Head from 'next/head';

const DisableSSR = ({children}) => {
  return (
    <div suppressHydrationWarning>
      {typeof window === 'undefined' ? null : children}
    </div>
  );
}

const Page = ({ Component, pageProps, nonce }) => {
  return (
    <div>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <meta property="csp-nonce" content={nonce} />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <DisableSSR>
        <Component {...pageProps} />
      </DisableSSR>
    </div>
  );
}

Page.getInitialProps = async ({ctx: {req, res}}) => {
  const csp = {};
  res.getHeaders()['content-security-policy']?.split(';').filter(Boolean).forEach(part => {
    const [directive, ...source] = part.split(' ');
    csp[directive] = source.map(s => s.slice(1, s.length - 1));
  });
  return {
    nonce: csp['default-src']?.find(s => s.startsWith('nonce-')).split('-')[1],
  };
};

export default Page;
Clumsy-Coder
  • 542
  • 1
  • 7
  • 20
amedley
  • 121
  • 10