@samitouri / QOS-React / commits / 9545e4810c

Add nonce support to bootstrap scripts and external runtime (#26738)

Adds support for nonce on `bootstrapScripts`, `bootstrapModules` and the external fizz runtime

Dan Ott committed May 1, 2023 at 12:19 UTC 9545e4810c2dc8922f575b6d8f726503a7345d0c
5 files changed +112 -3
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+23 -1
@@ -130,6 +130,9 @@ export type ResponseState = {
130 startInlineScript: PrecomputedChunk,
131 instructions: InstructionState,
132
133 + // state for outputting CSP nonce
134 + nonce: string | void,
135 +
136 // state for data streaming format
137 externalRuntimeConfig: BootstrapScriptDescriptor | null,
138
@@ -161,6 +164,7 @@ const endInlineScript = stringToPrecomputedChunk('</script>');
164
165 const startScriptSrc = stringToPrecomputedChunk('<script src="');
166 const startModuleSrc = stringToPrecomputedChunk('<script type="module" src="');
167 +const scriptNonce = stringToPrecomputedChunk('" nonce="');
168 const scriptIntegirty = stringToPrecomputedChunk('" integrity="');
169 const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
170
@@ -245,10 +249,17 @@ export function createResponseState(
249 typeof scriptConfig === 'string' ? scriptConfig : scriptConfig.src;
250 const integrity =
251 typeof scriptConfig === 'string' ? undefined : scriptConfig.integrity;
252 +
253 bootstrapChunks.push(
254 startScriptSrc,
255 stringToChunk(escapeTextForBrowser(src)),
256 );
257 + if (nonce) {
258 + bootstrapChunks.push(
259 + scriptNonce,
260 + stringToChunk(escapeTextForBrowser(nonce)),
261 + );
262 + }
263 if (integrity) {
264 bootstrapChunks.push(
265 scriptIntegirty,
@@ -265,10 +276,18 @@ export function createResponseState(
276 typeof scriptConfig === 'string' ? scriptConfig : scriptConfig.src;
277 const integrity =
278 typeof scriptConfig === 'string' ? undefined : scriptConfig.integrity;
279 +
280 bootstrapChunks.push(
281 startModuleSrc,
282 stringToChunk(escapeTextForBrowser(src)),
283 );
284 +
285 + if (nonce) {
286 + bootstrapChunks.push(
287 + scriptNonce,
288 + stringToChunk(escapeTextForBrowser(nonce)),
289 + );
290 + }
291 if (integrity) {
292 bootstrapChunks.push(
293 scriptIntegirty,
@@ -297,6 +316,7 @@ export function createResponseState(
316 preloadChunks: [],
317 hoistableChunks: [],
318 stylesToHoist: false,
319 + nonce,
320 };
321 }
322
@@ -4066,7 +4086,7 @@ export function writePreamble(
4086 // (User code could choose to send this even earlier by calling
4087 // preinit(...), if they know they will suspend).
4088 const {src, integrity} = responseState.externalRuntimeConfig;
4069 - internalPreinitScript(resources, src, integrity);
4089 + internalPreinitScript(resources, src, integrity, responseState.nonce);
4090 }
4091
4092 const htmlChunks = responseState.htmlChunks;
@@ -5349,6 +5369,7 @@ function internalPreinitScript(
5369 resources: Resources,
5370 src: string,
5371 integrity: ?string,
5372 + nonce: ?string,
5373 ): void {
5374 const key = getResourceKey('script', src);
5375 let resource = resources.scriptsMap.get(key);
@@ -5365,6 +5386,7 @@ function internalPreinitScript(
5386 async: true,
5387 src,
5388 integrity,
5389 + nonce,
5390 });
5391 }
5392 return;
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+2
@@ -57,6 +57,7 @@ export type ResponseState = {
57 preloadChunks: Array<Chunk | PrecomputedChunk>,
58 hoistableChunks: Array<Chunk | PrecomputedChunk>,
59 stylesToHoist: boolean,
60 + nonce: string | void,
61 // This is an extra field for the legacy renderer
62 generateStaticMarkup: boolean,
63 };
@@ -94,6 +95,7 @@ export function createResponseState(
95 preloadChunks: responseState.preloadChunks,
96 hoistableChunks: responseState.hoistableChunks,
97 stylesToHoist: responseState.stylesToHoist,
98 + nonce: responseState.nonce,
99
100 // This is an extra field for the legacy renderer
101 generateStaticMarkup,
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+64 -2
@@ -574,7 +574,7 @@ describe('ReactDOMFizzServer', () => {
574 );
575 });
576
577 - it('should support nonce scripts', async () => {
577 + it('should support nonce for bootstrap and runtime scripts', async () => {
578 CSPnonce = 'R4nd0m';
579 try {
580 let resolve;
@@ -591,11 +591,26 @@ describe('ReactDOMFizzServer', () => {
591 <Lazy text="Hello" />
592 </Suspense>
593 </div>,
594 - {nonce: 'R4nd0m'},
594 + {
595 + nonce: 'R4nd0m',
596 + bootstrapScriptContent: 'function noop(){}',
597 + bootstrapScripts: ['init.js'],
598 + bootstrapModules: ['init.mjs'],
599 + },
600 );
601 pipe(writable);
602 });
603 +
604 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
605 +
606 + // check that there are 4 scripts with a matching nonce:
607 + // The runtime script, an inline bootstrap script, and two src scripts
608 + expect(
609 + Array.from(container.getElementsByTagName('script')).filter(
610 + node => node.getAttribute('nonce') === CSPnonce,
611 + ).length,
612 + ).toEqual(4);
613 +
614 await act(() => {
615 resolve({default: Text});
616 });
@@ -605,6 +620,53 @@ describe('ReactDOMFizzServer', () => {
620 }
621 });
622
623 + it('should not automatically add nonce to rendered scripts', async () => {
624 + CSPnonce = 'R4nd0m';
625 + try {
626 + await act(async () => {
627 + const {pipe} = renderToPipeableStream(
628 + <html>
629 + <body>
630 + <script nonce={CSPnonce}>{'try { foo() } catch (e) {} ;'}</script>
631 + <script nonce={CSPnonce} src="foo" async={true} />
632 + <script src="bar" />
633 + <script src="baz" integrity="qux" async={true} />
634 + <script type="module" src="quux" async={true} />
635 + <script type="module" src="corge" async={true} />
636 + <script
637 + type="module"
638 + src="grault"
639 + integrity="garply"
640 + async={true}
641 + />
642 + </body>
643 + </html>,
644 + {
645 + nonce: CSPnonce,
646 + },
647 + );
648 + pipe(writable);
649 + });
650 +
651 + expect(
652 + stripExternalRuntimeInNodes(
653 + document.getElementsByTagName('script'),
654 + renderOptions.unstable_externalRuntimeSrc,
655 + ).map(n => n.outerHTML),
656 + ).toEqual([
657 + `<script nonce="${CSPnonce}" src="foo" async=""></script>`,
658 + `<script src="baz" integrity="qux" async=""></script>`,
659 + `<script type="module" src="quux" async=""></script>`,
660 + `<script type="module" src="corge" async=""></script>`,
661 + `<script type="module" src="grault" integrity="garply" async=""></script>`,
662 + `<script nonce="${CSPnonce}">try { foo() } catch (e) {} ;</script>`,
663 + `<script src="bar"></script>`,
664 + ]);
665 + } finally {
666 + CSPnonce = null;
667 + }
668 + });
669 +
670 it('should client render a boundary if a lazy component rejects', async () => {
671 let rejectComponent;
672 const LazyComponent = React.lazy(() => {
packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
+17
@@ -486,4 +486,21 @@ describe('ReactDOMFizzServerBrowser', () => {
486 '<!DOCTYPE html><html><head><title>foo</title></head><body>bar</body></html>',
487 );
488 });
489 +
490 + it('should support nonce attribute for bootstrap scripts', async () => {
491 + const nonce = 'R4nd0m';
492 + const stream = await ReactDOMFizzServer.renderToReadableStream(
493 + <div>hello world</div>,
494 + {
495 + nonce,
496 + bootstrapScriptContent: 'INIT();',
497 + bootstrapScripts: ['init.js'],
498 + bootstrapModules: ['init.mjs'],
499 + },
500 + );
501 + const result = await readResult(stream);
502 + expect(result).toMatchInlineSnapshot(
503 + `"<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>"`,
504 + );
505 + });
506 });
packages/react-dom/src/test-utils/FizzTestUtils.js
+6
@@ -103,6 +103,12 @@ async function executeScript(script: Element) {
103 } else {
104 const newScript = ownerDocument.createElement('script');
105 newScript.textContent = script.textContent;
106 + // make sure to add nonce back to script if it exists
107 + const scriptNonce = script.getAttribute('nonce');
108 + if (scriptNonce) {
109 + newScript.setAttribute('nonce', scriptNonce);
110 + }
111 +
112 parent.insertBefore(newScript, script);
113 parent.removeChild(script);
114 }