remix: Security - Can't use CSP that blocks 'unsafe-inline';

To improve security and mitigate cross site scripting threats, sites should set a Content Security Policy (CSP). I would like to restrict to script-src: self to block inline scripts, but Remix crashes on hydration when this is set.

Remix version: 0.17.2

Workaround: don’t hydrate, server rendered pages still work 👌 🚀

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Reactions: 12
  • Comments: 32 (15 by maintainers)

Most upvoted comments

Any word on this? I’d like to set a content security policy for a new project and this is preventing us from doing so.

I think we can get rid of this inline script.

  1. Put __remixContext into a json script tag
  2. Put the matches in the same __remixContext (they’re already there I think)
  3. Move the inline module into the client entry bundle
  4. use dynamic import of the matches
  5. assign the __remixRouteModules
  6. dynamically import the app’s client entry
  7. make sure all dynamically imported entry modules are modulepreloaded to avoid waterfalls

To fix "Warning: Prop nonce did not match. Server: “” Client: “irn2qSvqJ-0xG9HuIos8DrJjQBtqYMk8L3PVz4qyG74muQ” ", you need to add:

	if(typeof document !== "undefined") {
		cspScriptNonce = "";
	}

Full code:

export default function App() {
	const { cspScriptNonce } = useLoaderData<RootLoaderData>();
	if(typeof document !== "undefined") {
		cspScriptNonce = "";
	}
	return (
	  <html lang="en">
		<head>
		  <Meta />
		  <Links />
		</head>
		<body>
		  <Outlet />
		  <ScrollRestoration nonce={cspScriptNonce} />
		  <Scripts nonce={cspScriptNonce} />
		  <LiveReload nonce={cspScriptNonce} port={8002} />
		</body>
	  </html>
	);
  }

I was going to suggest changing to use script[type="application/json"] If that worked with the script-src: 'self' CSP, good to know that works, maybe Remix should switch to that, also since there’s only one script for the content it could use something like:

<script type="application/json" id="remix-content">data here</script>

And then query by the ID.

How come <Links/> does not support nonce?

@ngbrown Sorry for being unclear. I was referring to your earlier comment saying

Also, react-dom generates some error messages because it doesn’t handle rehydration checking correctly on nonce since it can’t be read as an attribute.

I thought that what you meant by that was that there is an error in the browser console, namely:

Warning: Prop `nonce` did not match. Server: "" Client: "irn2qSvqJ-0xG9HuIos8DrJjQBtqYMk8L3PVz4qyG74muQ"

Nonce doesn’t work if you want caching as it needs to be generated for every single request, otherwise it’s trivial to bypass. I have a section on “Nonce-based CSP” in the post I linked.

@cskeppstedt I’m pretty sure the fifth argument wasn‘t added until pretty recently, and only in 2.x.

Edit: Turning this into a mini-tutorial as I figured out my derp: I mounted the <NonceProvider> with nonce={nonce} instead of value={nonce}.

In addition, the way Remix adds dynamic scripts requires 'script-src-elem' to be defined in addition to 'script-src'.


@samcolby @ahuth How are you piecing this together? Adding the nonce itself doesn’t seem to be enough. Is there a guide somewhere?

I’ve added …

server.mjs

import express from 'express'
import helmet from 'helmet'

const server = express()

server.use((request, response, next) => {
    response.locals.cspNonce = crypto.randomBytes(32).toString('base64')
    next()
})

// ...

server.use(helmet({
    contentSecurityPolicy: {
        directives: {
            'script-src': [
                "'strict-dynamic'",
                (request, response) => `'nonce-${response.locals.cspNonce}'`
            ],
            'script-src-elem': [
                "'strict-dynamic'",
                (request, response) => `'nonce-${response.locals.cspNonce}'`
            ]
        }
    }
}))

// ...

server.all('*', createRequestHandler({
    build,

    getLoadContext: (request, response) => ({
        cspNonce: response.locals.cspNonce
    })
}))

providers/nonce.js

import { createContext, useContext } from 'react'
const NonceContext = createContext('')
export const useNonce = () => useContext(NonceContext)
export default NonceContext.Provider

entry.server.js (both bot and browser handlers)

import NonceProvider from '@providers/nonce'

// ...

return new Promise((resolve, reject) => {
    let shellRendered = false

    const nonce = loadContext?.cspNonce

    const {
        pipe,
        abort
    } = renderToPipeableStream(
        <NonceProvider value={nonce}>
            <RemixServer
                context={remixContext}
                url={request.url}
                abortDelay={ABORT_DELAY} />
        </NonceProvider>,
        {
            nonce, // <-- Unsure about this one
            
            // ...

root.js

import { useNonce } from '@providers/nonce'

// ...

const App = () => {
    const nonce = useNonce()

    return (
        <html>
            <head>
                <Meta />
                <Links />
            </head>
            <body>
                <Outlet />
                <Scripts nonce={nonce} />
                <ScrollRestoration nonce={nonce} />
                <LiveReload nonce={nonce} />
            </body>
        </html>
    )
}

… and still CSP is whining in my console.

Did I forget anything obvious?

Aha! Thank you @ahuth that’s much easier to follow!

Edit: See ahuth’s solution below, which is much better than mine 🤦 .

For anyone as confused by this thread as I was… At the time of writing this isn’t currently working on Kent’s site .

And the NonceContext approach doesn’t add the nonce to the html, which I assume is why it is not working on Kent’s site.

Using the other comments in this thread, you can get it work using remix 1.19.3 with all V2 flags enabled using

In root.tsx

export const loader: LoaderFunction = async () => {
  return {cspScriptNonce: crypto.randomBytes(16).toString('hex')}
}

// don't include any inline scripts with whatever you use here as the nonce wont work
export const ErrorBoundary = () => <div /> 

const App = () => {
    const data = useLoaderData<LoaderData>() 
    const cspScriptNonce = typeof document === 'undefined' ? data.cspScriptNonce : ''
    ...etc...
}

Then in handleRequest or handleBrowserRequest etc…

const cspScriptNonce = remixContext.staticHandlerContext.loaderData.root?.cspScriptNonce
// this needs to handle cspScriptNonce=undefined for ErrorBoundaries
applySecurityHeaders(responseHeaders, cspScriptNonce). ```

Love to know if there is a better way of doing this without using the loader though.
Or ideally, all inline scripts can be removed so we don’t have to worry about this at all 🤞 .

Was also looking at this today. Not sure if this helps - but Kent Dodd’s site @kentcdodds - is also using nonce values, generated in his express server, and available via loader context… https://github.com/kentcdodds/kentcdodds.com/blob/main/app/root.tsx

@EvgeniyBudaev thank you! This works great!

For anyone unclear on why this works - the nonce attribute is stripped from the script elements as soon as the page loads. Then when client hydration happens, the nonce values are re-applied, and are then updated again every time the loader is re-fetched. This shouldn’t be happening anyway, and it would be great if there was added documentation in the Remix docs to ensure the nonces are only provided on the initial server-side load.

ref: https://html.spec.whatwg.org/multipage/urls-and-fetching.html#nonce-attributes : “Elements that have a nonce content attribute ensure that the cryptographic nonce is only exposed to script (and not to side-channels like CSS attribute selectors) by taking the value from the content attribute, moving it into an internal slot named [[CryptographicNonce]], exposing it to script via the HTMLOrSVGElement interface mixin, and setting the content attribute to the empty string. Unless otherwise specified, the slot’s value is the empty string.”

Hi @davidpfahler, I’m sure you’re discovering that CSP is a fairly complex feature. With as little information you’ve given it’s hard to know what is going on. Maybe open a discussion for more interactive follow up?

I’ll add that the Scroll Restoration code also adds some additional script inline that would need to mitigated as well: https://github.com/remix-run/remix/blob/main/packages/remix-react/scroll-restoration.tsx

Inspecting the source I see there’s the __remixContext which looks like a good candidate for an inline json block. I wrote about an approach for Inline Data With a Content Security Policy a while ago that might be of interest. Looking back, I think I was a bit verbose writing that post, but hopefully the content can help.