Skip to content

Commit 9545e48

Browse files
authored
Add nonce support to bootstrap scripts and external runtime (#26738)
Adds support for nonce on `bootstrapScripts`, `bootstrapModules` and the external fizz runtime
1 parent 86b0e91 commit 9545e48

File tree

5 files changed

+112
-3
lines changed

5 files changed

+112
-3
lines changed

packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ export type ResponseState = {
130130
startInlineScript: PrecomputedChunk,
131131
instructions: InstructionState,
132132

133+
// state for outputting CSP nonce
134+
nonce: string | void,
135+
133136
// state for data streaming format
134137
externalRuntimeConfig: BootstrapScriptDescriptor | null,
135138

@@ -161,6 +164,7 @@ const endInlineScript = stringToPrecomputedChunk('</script>');
161164

162165
const startScriptSrc = stringToPrecomputedChunk('<script src="');
163166
const startModuleSrc = stringToPrecomputedChunk('<script type="module" src="');
167+
const scriptNonce = stringToPrecomputedChunk('" nonce="');
164168
const scriptIntegirty = stringToPrecomputedChunk('" integrity="');
165169
const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
166170

@@ -245,10 +249,17 @@ export function createResponseState(
245249
typeof scriptConfig === 'string' ? scriptConfig : scriptConfig.src;
246250
const integrity =
247251
typeof scriptConfig === 'string' ? undefined : scriptConfig.integrity;
252+
248253
bootstrapChunks.push(
249254
startScriptSrc,
250255
stringToChunk(escapeTextForBrowser(src)),
251256
);
257+
if (nonce) {
258+
bootstrapChunks.push(
259+
scriptNonce,
260+
stringToChunk(escapeTextForBrowser(nonce)),
261+
);
262+
}
252263
if (integrity) {
253264
bootstrapChunks.push(
254265
scriptIntegirty,
@@ -265,10 +276,18 @@ export function createResponseState(
265276
typeof scriptConfig === 'string' ? scriptConfig : scriptConfig.src;
266277
const integrity =
267278
typeof scriptConfig === 'string' ? undefined : scriptConfig.integrity;
279+
268280
bootstrapChunks.push(
269281
startModuleSrc,
270282
stringToChunk(escapeTextForBrowser(src)),
271283
);
284+
285+
if (nonce) {
286+
bootstrapChunks.push(
287+
scriptNonce,
288+
stringToChunk(escapeTextForBrowser(nonce)),
289+
);
290+
}
272291
if (integrity) {
273292
bootstrapChunks.push(
274293
scriptIntegirty,
@@ -297,6 +316,7 @@ export function createResponseState(
297316
preloadChunks: [],
298317
hoistableChunks: [],
299318
stylesToHoist: false,
319+
nonce,
300320
};
301321
}
302322

@@ -4066,7 +4086,7 @@ export function writePreamble(
40664086
// (User code could choose to send this even earlier by calling
40674087
// preinit(...), if they know they will suspend).
40684088
const {src, integrity} = responseState.externalRuntimeConfig;
4069-
internalPreinitScript(resources, src, integrity);
4089+
internalPreinitScript(resources, src, integrity, responseState.nonce);
40704090
}
40714091

40724092
const htmlChunks = responseState.htmlChunks;
@@ -5349,6 +5369,7 @@ function internalPreinitScript(
53495369
resources: Resources,
53505370
src: string,
53515371
integrity: ?string,
5372+
nonce: ?string,
53525373
): void {
53535374
const key = getResourceKey('script', src);
53545375
let resource = resources.scriptsMap.get(key);
@@ -5365,6 +5386,7 @@ function internalPreinitScript(
53655386
async: true,
53665387
src,
53675388
integrity,
5389+
nonce,
53685390
});
53695391
}
53705392
return;

packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export type ResponseState = {
5757
preloadChunks: Array<Chunk | PrecomputedChunk>,
5858
hoistableChunks: Array<Chunk | PrecomputedChunk>,
5959
stylesToHoist: boolean,
60+
nonce: string | void,
6061
// This is an extra field for the legacy renderer
6162
generateStaticMarkup: boolean,
6263
};
@@ -94,6 +95,7 @@ export function createResponseState(
9495
preloadChunks: responseState.preloadChunks,
9596
hoistableChunks: responseState.hoistableChunks,
9697
stylesToHoist: responseState.stylesToHoist,
98+
nonce: responseState.nonce,
9799

98100
// This is an extra field for the legacy renderer
99101
generateStaticMarkup,

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ describe('ReactDOMFizzServer', () => {
574574
);
575575
});
576576

577-
it('should support nonce scripts', async () => {
577+
it('should support nonce for bootstrap and runtime scripts', async () => {
578578
CSPnonce = 'R4nd0m';
579579
try {
580580
let resolve;
@@ -591,11 +591,26 @@ describe('ReactDOMFizzServer', () => {
591591
<Lazy text="Hello" />
592592
</Suspense>
593593
</div>,
594-
{nonce: 'R4nd0m'},
594+
{
595+
nonce: 'R4nd0m',
596+
bootstrapScriptContent: 'function noop(){}',
597+
bootstrapScripts: ['init.js'],
598+
bootstrapModules: ['init.mjs'],
599+
},
595600
);
596601
pipe(writable);
597602
});
603+
598604
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+
599614
await act(() => {
600615
resolve({default: Text});
601616
});
@@ -605,6 +620,53 @@ describe('ReactDOMFizzServer', () => {
605620
}
606621
});
607622

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+
608670
it('should client render a boundary if a lazy component rejects', async () => {
609671
let rejectComponent;
610672
const LazyComponent = React.lazy(() => {

packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,4 +486,21 @@ describe('ReactDOMFizzServerBrowser', () => {
486486
'<!DOCTYPE html><html><head><title>foo</title></head><body>bar</body></html>',
487487
);
488488
});
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+
});
489506
});

packages/react-dom/src/test-utils/FizzTestUtils.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ async function executeScript(script: Element) {
103103
} else {
104104
const newScript = ownerDocument.createElement('script');
105105
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+
106112
parent.insertBefore(newScript, script);
107113
parent.removeChild(script);
108114
}

0 commit comments

Comments
 (0)