Skip to content

Commit d40dc73

Browse files
authored
Escape bootstrapScriptContent for javascript embedding into HTML (#24385)
The previous escape was for Text into HTML and breaks script contents. The new escaping ensures that the script contents cannot prematurely close the host script tag by escaping script open and close string sequences using a unicode escape substitution.
1 parent 726ba80 commit d40dc73

File tree

2 files changed

+73
-1
lines changed

2 files changed

+73
-1
lines changed

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

+61
Original file line numberDiff line numberDiff line change
@@ -2811,4 +2811,65 @@ describe('ReactDOMFizzServer', () => {
28112811
</ul>,
28122812
);
28132813
});
2814+
2815+
describe('bootstrapScriptContent escaping', () => {
2816+
// @gate experimental
2817+
it('the "S" in "</?[Ss]cript" strings are replaced with unicode escaped lowercase s or S depending on case, preserving case sensitivity of nearby characters', async () => {
2818+
window.__test_outlet = '';
2819+
const stringWithScriptsInIt =
2820+
'prescription pre<scription pre<Scription pre</scRipTion pre</ScripTion </script><script><!-- <script> -->';
2821+
await act(async () => {
2822+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<div />, {
2823+
bootstrapScriptContent:
2824+
'window.__test_outlet = "This should have been replaced";var x = "' +
2825+
stringWithScriptsInIt +
2826+
'";\nwindow.__test_outlet = x;',
2827+
});
2828+
pipe(writable);
2829+
});
2830+
expect(window.__test_outlet).toMatch(stringWithScriptsInIt);
2831+
});
2832+
2833+
// @gate experimental
2834+
it('does not escape \\u2028, or \\u2029 characters', async () => {
2835+
// these characters are ignored in engines support https://github.com/tc39/proposal-json-superset
2836+
// in this test with JSDOM the characters are silently dropped and thus don't need to be encoded.
2837+
// if you send these characters to an older browser they could fail so it is a good idea to
2838+
// sanitize JSON input of these characters
2839+
window.__test_outlet = '';
2840+
const el = document.createElement('p');
2841+
el.textContent = '{"one":1,\u2028\u2029"two":2}';
2842+
const stringWithLSAndPSCharacters = el.textContent;
2843+
await act(async () => {
2844+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<div />, {
2845+
bootstrapScriptContent:
2846+
'let x = ' +
2847+
stringWithLSAndPSCharacters +
2848+
'; window.__test_outlet = x;',
2849+
});
2850+
pipe(writable);
2851+
});
2852+
const outletString = JSON.stringify(window.__test_outlet);
2853+
expect(outletString).toBe(
2854+
stringWithLSAndPSCharacters.replace(/[\u2028\u2029]/g, ''),
2855+
);
2856+
});
2857+
2858+
// @gate experimental
2859+
it('does not escape <, >, or & characters', async () => {
2860+
// these characters valid javascript and may be necessary in scripts and won't be interpretted properly
2861+
// escaped outside of a string context within javascript
2862+
window.__test_outlet = null;
2863+
// this boolean expression will be cast to a number due to the bitwise &. we will look for a truthy value (1) below
2864+
const booleanLogicString = '1 < 2 & 3 > 1';
2865+
await act(async () => {
2866+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<div />, {
2867+
bootstrapScriptContent:
2868+
'let x = ' + booleanLogicString + '; window.__test_outlet = x;',
2869+
});
2870+
pipe(writable);
2871+
});
2872+
expect(window.__test_outlet).toBe(1);
2873+
});
2874+
});
28142875
});

packages/react-dom/src/server/ReactDOMServerFormatConfig.js

+12-1
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,17 @@ const startScriptSrc = stringToPrecomputedChunk('<script src="');
8383
const startModuleSrc = stringToPrecomputedChunk('<script type="module" src="');
8484
const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
8585

86+
const scriptRegex = /(<\/|<)(s)(cript)/gi;
87+
const scriptReplacer = (match, prefix, s, suffix) =>
88+
`${prefix}${s === 's' ? '\\u0073' : '\\u0053'}${suffix}`;
89+
90+
function escapeBootstrapScriptContent(scriptText) {
91+
if (__DEV__) {
92+
checkHtmlStringCoercion(scriptText);
93+
}
94+
return ('' + scriptText).replace(scriptRegex, scriptReplacer);
95+
}
96+
8697
// Allows us to keep track of what we've already written so we can refer back to it.
8798
export function createResponseState(
8899
identifierPrefix: string | void,
@@ -102,7 +113,7 @@ export function createResponseState(
102113
if (bootstrapScriptContent !== undefined) {
103114
bootstrapChunks.push(
104115
inlineScriptWithNonce,
105-
stringToChunk(escapeTextForBrowser(bootstrapScriptContent)),
116+
stringToChunk(escapeBootstrapScriptContent(bootstrapScriptContent)),
106117
endInlineScript,
107118
);
108119
}

0 commit comments

Comments
 (0)