Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Impossible to avoid using a nonce without XSS vulnerability #5162

Closed
kentcdodds opened this issue Jan 20, 2023 · 27 comments
Closed

Impossible to avoid using a nonce without XSS vulnerability #5162

kentcdodds opened this issue Jan 20, 2023 · 27 comments

Comments

@kentcdodds
Copy link
Member

What version of Remix are you using?

1.11.0

Steps to Reproduce

I've created a demo: https://github.com/kentcdodds/nonce-remix-issue

Basically, create a project that uses nonce CSP and try to ensure the nonce is both available for the server render but not available for the client hydration.

Expected Behavior

I'd like a mechanism that allows me to mark specific data as not able to be passed to the client.

Actual Behavior

The only way to get data into the UI on the server is through the loader. Everything the loader sends to UI on the server also goes to the client. Therefore, the nonce we need for the server UI makes it into the client, which basically makes us lose all benefits of a nonce CSP.

@kentcdodds
Copy link
Member Author

I'm live streaming this now, so feel free to watch me demo the issue: https://www.youtube.com/watch?v=YboFmtwxIBk&t=2h42m40s

@trojanowski
Copy link

@kentcdodds I think it's currently possible to use a nonce without exposing it to the client. I created a sample repo showing that. Here is the actual commit where I added it to the plain remix project. The idea is to use React context with a value set only on the server.

To do that we will need:

  1. A context instance:
// app/components/nonce-context.tsx

import React from "react";

// The default value (`undefined`) will be used on the client
export const NonceContext = React.createContext<string | undefined>(undefined);
  1. Use the nonce in the root App component:
// app/root.tsx

// ...

import { useContext } from "react";
import { NonceContext } from "./components/nonce-context";

// ...
export default function App() {
  const nonce = useContext(NonceContext);

  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration nonce={nonce} />
        <Scripts nonce={nonce} />
        <LiveReload nonce={nonce} />
        {/* Uncomment the `<script>` below to ensure the CSP policy prevented
            an inline script without nonce
        */}
        {/* <script
          dangerouslySetInnerHTML={{ __html: "alert('Should be prevented')" }}
        /> */}
      </body>
    </html>
  );
}
  1. Update the handle...Request function/functions in app/entry.server.tsx:
// app/entry.server.tsx
import crypto from "node:crypto";
// ...
import { NonceContext } from "./components/nonce-context";

// ...

function handleBrowserRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  const cspNonce = crypto.randomBytes(16).toString("hex");
  responseHeaders.set(
    "Content-Security-Policy",
    `script-src 'nonce-${cspNonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none';`
  );

  return new Promise((resolve, reject) => {
    // ...

    const { pipe, abort } = renderToPipeableStream(
      // Set value for the provider to use it on the server
      <NonceContext.Provider value={cspNonce}>
        <RemixServer context={remixContext} url={request.url} />
      </NonceContext.Provider>,
      {
        // Also set nonce for Suspense
        nonce: cspNonce,
        // ...
      }
    );

    // ...
  });
}

@kentcdodds
Copy link
Member Author

kentcdodds commented Jan 23, 2023

Oh, that's brilliant and almost so obvious I'm embarrassed I didn't think of it 🤦‍♂️.

Thanks for the tip! I'm going to do that now.

Might be room to stick this in the docs somewhere or make an example.

@kentcdodds
Copy link
Member Author

Dang it. Unfortunately this only really works well if you're generating the nonce within the entry.server file. If it's generated by express (via https://npm.im/helmet for example) then the entry.server doesn't have access to that (even if you put it on context because the entry.server doesn't have access to context. :(

kentcdodds added a commit to kentcdodds/kentcdodds.com that referenced this issue Jan 24, 2023
@kentcdodds
Copy link
Member Author

We need this:

#4603

Until then, I've shipped a patch-package of Remix to side-step this security issue.

kentcdodds/kentcdodds.com@f698a2d

@ngbrown
Copy link
Contributor

ngbrown commented Feb 15, 2023

@kentcdodds Can you expand on the risk of having the nonce in the SSR injected block for window.__remixContext above and beyond the existing situation of blessed JS also being able to access the nonce directly from script tags in the DOM?

In your video you mentioned that the nonce value is removed from script tags, but that is only the case for elem.getAttribute('nonce'), not for reading the DOM element property (elem.nonce). JavaScript running in the browser can still fetch the nonce from a script tag. This works in Chrome 109 and Firefox 110:

console.log([...document.getElementsByTagName("script")].find(x => x.nonce).nonce);

Evidently this is part of the w3c spec and an issue is documented at w3c/webappsec-csp#458

@kentcdodds
Copy link
Member Author

It's because non-blessed js (like an extension) can access the DOM and therefore can grab the nonce from the script that sets the Remix context variable.

@kentcdodds
Copy link
Member Author

If the script is "blessed" then the nonce doesn't matter anyway.

@ngbrown
Copy link
Contributor

ngbrown commented Feb 15, 2023

I'm not seeing how the patching to remove the data changes the threat risk. If it does, then how? I don't think extensions are included in the threat model of the Content-Security-Policy spec.

When a script tag has a matching nonce, its origin domain is on the list, or has its hash is included in the Content-Security-Policy header, then that is what I meant as "blessed" by the developer. If any script nonce is allowed and used on a <script> element, then any loaded script can then load more scripts elements via the DOM without regard to the availability of the nonce in window.__remixContext. Running scripts (by nonce, hash, or origin) of module type can also load other scripts directly by module import (with no further regard to CSP).

The extensions' Manifest V3 allows nonce to be read from script elements, but prevents injecting script blocks into the document independent of the presence of CSP headers.

Manifest V2 allowed injecting script blocks and and combined with extensions' ability to modify the page's CSP headers, I think any script can be injected no matter what a site owner does. Setting site CSP headers isn't going to stop that. See the uBlock extension code for an example that adds an additional header, but it could have rewrote an existing header.

Unfortunately, the network tab in the DevTools does not show the resulting modified headers. I added some logging to a sample extension to view the resulting headers. https://gist.github.com/ngbrown/e67894d87e5298556afae180c591afef

@kentcdodds
Copy link
Member Author

It sounds like you're suggesting that nonce doesn't actually protect you in any way 🤔 can you demonstrate this by making an extension that exploits this on a website that utilizes a nonce?

@ngbrown
Copy link
Contributor

ngbrown commented Feb 15, 2023

Maybe it just doesn't protect a website in the way you thought it did? The issue I pointed to in the w3c CSP spec repo included links to this talk and slides. Maybe that would help us understand the actual benefits and reason behind CSP?

I had a Manifest v3 extension that demonstrated what I was saying in the gist in my last post. Save the files, turn on developer mode in your extensions and load it as an unpacked extension. It extracts the nonce from a script element and prints out the Content-Security-Policy header in its logs (see at chrome://serviceworker-internals/?devtools). You can edit the host_permissions list in the manifest and the urls filter to point at any website you desire. I tested on both localhost and a publicly accessible site.

Or did you want a demonstration of a script element injection in a Manifest V2 extension?

@meza
Copy link

meza commented Feb 28, 2023

Great stuff in this thread! I'm almost happy with the solution however the potentially Arc? code I can't seem to find and configure.

Does this ring a bell to anyone? This is the last remaining thing that needs a nonce (and is only an inconvenience on a dev server)

<script>
  (() => {
    const url = 'ws://localhost:2222'
    let socket = new WebSocket(url)
    socket.addEventListener('message', message => {
      if (message.data === 'reload') location.reload()
    })
    socket.addEventListener('close', ({ wasClean }) => {
      const retryMs = 1000
      const cancelMs = 5000
      const maxAttempts = Math.round(cancelMs / retryMs)
      let attempts = 0
      const reloadIfCanConnect = () => {
        attempts++
        if (attempts > maxAttempts){
          console.error('Could not reconnect to dev server.')
          return
        }
        socket = new WebSocket(url)
        socket.addEventListener('error', () => {
          setTimeout(reloadIfCanConnect, retryMs)
        })
        socket.addEventListener('open', () => {
          if (!wasClean) {
            location.reload()
          }
        })
      }
      reloadIfCanConnect()
    })
  })();
</script>

@kentcdodds
Copy link
Member Author

This code comes from the LiveReload component. You need to supply a nonce prop to that. It's marked as deprecated, but it's not (#5161).

@meza
Copy link

meza commented Feb 28, 2023

I've got this but the error's still there... maybe I need a clean build?

image

@kentcdodds
Copy link
Member Author

Hmmm... Maybe open a bug report with a reproduction?

@meza
Copy link

meza commented Feb 28, 2023

Ok, the plot chickens. There is an actual Live Reload code at the bottom of the BODY and it does indeed have the nonce.
This code is at the bottom of the HEAD and I've no idea where it comes from.

@meza
Copy link

meza commented Feb 28, 2023

Found it, it's Arc's livereload. Do I need both? I don't think so, right?

image

@kentcdodds
Copy link
Member Author

I don't know Arc 🤷‍♂️ Probably not?

@meza
Copy link

meza commented Mar 9, 2023

Have you had any luck suppressing the hydration warnings due to the browser removing the nonce value from the html?

@kentcdodds
Copy link
Member Author

You shouldn't hydrate with a nonce. The client shouldn't have access to the nonce value.

@meza
Copy link

meza commented Mar 10, 2023

I'm struggling to figure out how to do that. My scripts are only loaded through the root.tsx and nowhere else. Where would I need to put them? (They're also scripts with innerhtml, not remote ones)

@kentcdodds
Copy link
Member Author

Do it like this: #5162 (comment)

@meza
Copy link

meza commented Mar 10, 2023

Yeah, this is exactly how I am doing it. Was following this from the beginning. Still getting the error hence my confusion. Thanks for helping though, I really appreciate it.

https://github.com/meza/trance-stack/blob/14aaffb726183ba8c784a7e4d6e2ae0a603f0264/src/root.tsx#L88

For now we opted to do the same thing you do with LiveReload and the ScrollRestoration and suppress the warnings but it does leave a bitter taste in my mouth.

@TimoWestland
Copy link

I'm getting hydration errors with this approach since v1.16.0 of remix. Created a new issue for it: #6394

@ikibalnyi
Copy link

Have put together an alternative solution based on Node's AsyncLocalStorage available from node@>=14 https://nodejs.org/api/async_hooks.html. Check out this gist:
https://gist.github.com/ikibalnyi/d23a3710f810ff0f434bf58c9039d7b3

@lilouartz
Copy link

@kentcdodds What's the latest solution here?

@kentcdodds
Copy link
Member Author

Checkout what the Epic Stack does: https://github.com/epicweb-dev/epic-stack

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

7 participants