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
}