Repository: facebook/react
Pull request: Add nonce support to bootstrap scripts and external runtime

## Summary

When using a Content Security Policy with [the `strict-dynamic` source list keyword](https://content-security-policy.com/strict-dynamic/), all other non-nonce keywords and sources are ignored, specifically `'self'`. This means that scripts that are inserted via `<script src=... />` into the HTML violate the content security policy.

This fix also supports CSPs that are _not_ using `strict-dynamic`, but _are_ using [`nonce-xxx` sources](https://content-security-policy.com/nonce/)

Right now the render functions (`renderToReadableStream`, `renderToPipeableStream` etc) via `createResponseState` add the nonce attribute to inline scripts set in `bootstrapScriptContent`, but not scripts via `bootstrapScripts` and `bootstrapModules`. Also missing the nonce value are external runtime scripts, as well as scripts rendered at runtime that could be rendered on the server.

Adding nonce support to those scripts will allow them to pass a `content-security-policy` that uses `strict-dynamic`.

### Related issues:
- closes #24883
- Discussion and related issue in: #26026
- https://github.com/vercel/next.js/issues/43743

### Changes:

- Add `nonce` to bootstrap scripts/modules in `ReactFizzConfigDOM -> createResponseState()`
- Add `nonce` property to the return object for `createResponseState()`
- Pass `nonce` prop to `internalPreinitScript()` (external runtime scripts)
- ~Inject `nonce` prop in `pushStartInstance()` where `type=='script'` if it exists in `responseState`~ (removed based on https://github.com/facebook/react/pull/26738#issuecomment-1527902527)
- Updated `FizzTestUtils.js` to re-add nonce attribute after executing inline scripts


## How did you test this change?

I added the following tests:
- `ReactDOMFizzServer-test - it should render scripts with nonce added`
  Test that `nonce` values are applied correctly in rendered scripts 
- `ReactDOMFizzServer-test - it should support nonce for bootstrap and runtime scripts`
  Update the existing nonce test to test all bootstrap scripts, as well as the runtime script


Changed files:
filename: packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
patch: @@ -130,6 +130,9 @@ export type ResponseState = {
   startInlineScript: PrecomputedChunk,
   instructions: InstructionState,
 
+  // state for outputting CSP nonce
+  nonce: string | void,
+
   // state for data streaming format
   externalRuntimeConfig: BootstrapScriptDescriptor | null,
 
@@ -161,6 +164,7 @@ const endInlineScript = stringToPrecomputedChunk('</script>');
 
 const startScriptSrc = stringToPrecomputedChunk('<script src="');
 const startModuleSrc = stringToPrecomputedChunk('<script type="module" src="');
+const scriptNonce = stringToPrecomputedChunk('" nonce="');
 const scriptIntegirty = stringToPrecomputedChunk('" integrity="');
 const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
 
@@ -245,10 +249,17 @@ export function createResponseState(
         typeof scriptConfig === 'string' ? scriptConfig : scriptConfig.src;
       const integrity =
         typeof scriptConfig === 'string' ? undefined : scriptConfig.integrity;
+
       bootstrapChunks.push(
         startScriptSrc,
         stringToChunk(escapeTextForBrowser(src)),
       );
+      if (nonce) {
+        bootstrapChunks.push(
+          scriptNonce,
+          stringToChunk(escapeTextForBrowser(nonce)),
+        );
+      }
       if (integrity) {
         bootstrapChunks.push(
           scriptIntegirty,
@@ -265,10 +276,18 @@ export function createResponseState(
         typeof scriptConfig === 'string' ? scriptConfig : scriptConfig.src;
       const integrity =
         typeof scriptConfig === 'string' ? undefined : scriptConfig.integrity;
+
       bootstrapChunks.push(
         startModuleSrc,
         stringToChunk(escapeTextForBrowser(src)),
       );
+
+      if (nonce) {
+        bootstrapChunks.push(
+          scriptNonce,
+          stringToChunk(escapeTextForBrowser(nonce)),
+        );
+      }
       if (integrity) {
         bootstrapChunks.push(
           scriptIntegirty,
@@ -297,6 +316,7 @@ export function createResponseState(
     preloadChunks: [],
     hoistableChunks: [],
     stylesToHoist: false,
+    nonce,
   };
 }
 
@@ -4066,7 +4086,7 @@ export function writePreamble(
     // (User code could choose to send this even earlier by calling
     //  preinit(...), if they know they will suspend).
     const {src, integrity} = responseState.externalRuntimeConfig;
-    internalPreinitScript(resources, src, integrity);
+    internalPreinitScript(resources, src, integrity, responseState.nonce);
   }
 
   const htmlChunks = responseState.htmlChunks;
@@ -5349,6 +5369,7 @@ function internalPreinitScript(
   resources: Resources,
   src: string,
   integrity: ?string,
+  nonce: ?string,
 ): void {
   const key = getResourceKey('script', src);
   let resource = resources.scriptsMap.get(key);
@@ -5365,6 +5386,7 @@ function internalPreinitScript(
       async: true,
       src,
       integrity,
+      nonce,
     });
   }
   return;

filename: packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
patch: @@ -57,6 +57,7 @@ export type ResponseState = {
   preloadChunks: Array<Chunk | PrecomputedChunk>,
   hoistableChunks: Array<Chunk | PrecomputedChunk>,
   stylesToHoist: boolean,
+  nonce: string | void,
   // This is an extra field for the legacy renderer
   generateStaticMarkup: boolean,
 };
@@ -94,6 +95,7 @@ export function createResponseState(
     preloadChunks: responseState.preloadChunks,
     hoistableChunks: responseState.hoistableChunks,
     stylesToHoist: responseState.stylesToHoist,
+    nonce: responseState.nonce,
 
     // This is an extra field for the legacy renderer
     generateStaticMarkup,

filename: packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
patch: @@ -574,7 +574,7 @@ describe('ReactDOMFizzServer', () => {
     );
   });
 
-  it('should support nonce scripts', async () => {
+  it('should support nonce for bootstrap and runtime scripts', async () => {
     CSPnonce = 'R4nd0m';
     try {
       let resolve;
@@ -591,11 +591,26 @@ describe('ReactDOMFizzServer', () => {
               <Lazy text="Hello" />
             </Suspense>
           </div>,
-          {nonce: 'R4nd0m'},
+          {
+            nonce: 'R4nd0m',
+            bootstrapScriptContent: 'function noop(){}',
+            bootstrapScripts: ['init.js'],
+            bootstrapModules: ['init.mjs'],
+          },
         );
         pipe(writable);
       });
+
       expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
+
+      // check that there are 4 scripts with a matching nonce:
+      // The runtime script, an inline bootstrap script, and two src scripts
+      expect(
+        Array.from(container.getElementsByTagName('script')).filter(
+          node => node.getAttribute('nonce') === CSPnonce,
+        ).length,
+      ).toEqual(4);
+
       await act(() => {
         resolve({default: Text});
       });
@@ -605,6 +620,53 @@ describe('ReactDOMFizzServer', () => {
     }
   });
 
+  it('should not automatically add nonce to rendered scripts', async () => {
+    CSPnonce = 'R4nd0m';
+    try {
+      await act(async () => {
+        const {pipe} = renderToPipeableStream(
+          <html>
+            <body>
+              <script nonce={CSPnonce}>{'try { foo() } catch (e) {} ;'}</script>
+              <script nonce={CSPnonce} src="foo" async={true} />
+              <script src="bar" />
+              <script src="baz" integrity="qux" async={true} />
+              <script type="module" src="quux" async={true} />
+              <script type="module" src="corge" async={true} />
+              <script
+                type="module"
+                src="grault"
+                integrity="garply"
+                async={true}
+              />
+            </body>
+          </html>,
+          {
+            nonce: CSPnonce,
+          },
+        );
+        pipe(writable);
+      });
+
+      expect(
+        stripExternalRuntimeInNodes(
+          document.getElementsByTagName('script'),
+          renderOptions.unstable_externalRuntimeSrc,
+        ).map(n => n.outerHTML),
+      ).toEqual([
+        `<script nonce="${CSPnonce}" src="foo" async=""></script>`,
+        `<script src="baz" integrity="qux" async=""></script>`,
+        `<script type="module" src="quux" async=""></script>`,
+        `<script type="module" src="corge" async=""></script>`,
+        `<script type="module" src="grault" integrity="garply" async=""></script>`,
+        `<script nonce="${CSPnonce}">try { foo() } catch (e) {} ;</script>`,
+        `<script src="bar"></script>`,
+      ]);
+    } finally {
+      CSPnonce = null;
+    }
+  });
+
   it('should client render a boundary if a lazy component rejects', async () => {
     let rejectComponent;
     const LazyComponent = React.lazy(() => {

filename: packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
patch: @@ -486,4 +486,21 @@ describe('ReactDOMFizzServerBrowser', () => {
       '<!DOCTYPE html><html><head><title>foo</title></head><body>bar</body></html>',
     );
   });
+
+  it('should support nonce attribute for bootstrap scripts', async () => {
+    const nonce = 'R4nd0m';
+    const stream = await ReactDOMFizzServer.renderToReadableStream(
+      <div>hello world</div>,
+      {
+        nonce,
+        bootstrapScriptContent: 'INIT();',
+        bootstrapScripts: ['init.js'],
+        bootstrapModules: ['init.mjs'],
+      },
+    );
+    const result = await readResult(stream);
+    expect(result).toMatchInlineSnapshot(
+      `"<div>hello world</div><script nonce="${nonce}">INIT();</script><script src="init.js" nonce="${nonce}" async=""></script><script type="module" src="init.mjs" nonce="${nonce}" async=""></script>"`,
+    );
+  });
 });

filename: packages/react-dom/src/test-utils/FizzTestUtils.js
patch: @@ -103,6 +103,12 @@ async function executeScript(script: Element) {
   } else {
     const newScript = ownerDocument.createElement('script');
     newScript.textContent = script.textContent;
+    // make sure to add nonce back to script if it exists
+    const scriptNonce = script.getAttribute('nonce');
+    if (scriptNonce) {
+      newScript.setAttribute('nonce', scriptNonce);
+    }
+
     parent.insertBefore(newScript, script);
     parent.removeChild(script);
   }