Skip to content

Commit

Permalink
Add nonce support to bootstrap scripts and external runtime (#26738)
Browse files Browse the repository at this point in the history
Adds support for nonce on `bootstrapScripts`, `bootstrapModules` and the external fizz runtime
  • Loading branch information
danieltott committed May 1, 2023
1 parent 86b0e91 commit 9545e48
Show file tree
Hide file tree
Showing 5 changed files with 112 additions and 3 deletions.
24 changes: 23 additions & 1 deletion packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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>');

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -297,6 +316,7 @@ export function createResponseState(
preloadChunks: [],
hoistableChunks: [],
stylesToHoist: false,
nonce,
};
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -5365,6 +5386,7 @@ function internalPreinitScript(
async: true,
src,
integrity,
nonce,
});
}
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
Expand Down
66 changes: 64 additions & 2 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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});
});
Expand All @@ -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(() => {
Expand Down
17 changes: 17 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>"`,
);
});
});
6 changes: 6 additions & 0 deletions packages/react-dom/src/test-utils/FizzTestUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down

0 comments on commit 9545e48

Please sign in to comment.