Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Fizz] escape <script> textContent similar to bootstrapScript #28871

Merged
merged 1 commit into from
Apr 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 6 additions & 10 deletions packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,16 +294,16 @@ const scriptCrossOrigin = stringToPrecomputedChunk('" crossorigin="');
const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');

/**
* This escaping function is designed to work with bootstrapScriptContent and importMap only.
* because we know we are escaping the entire script. We can avoid for instance
* This escaping function is designed to work with with inline scripts where the entire
* contents are escaped. Because we know we are escaping the entire script we can avoid for instance
* escaping html comment string sequences that are valid javascript as well because
* if there are no sebsequent <script sequences the html parser will never enter
* script data double escaped state (see: https://www.w3.org/TR/html53/syntax.html#script-data-double-escaped-state)
*
* While untrusted script content should be made safe before using this api it will
* ensure that the script cannot be early terminated or never terminated state
*/
function escapeBootstrapAndImportMapScriptContent(scriptText: string) {
function escapeEntireInlineScriptContent(scriptText: string) {
if (__DEV__) {
checkHtmlStringCoercion(scriptText);
}
Expand Down Expand Up @@ -372,9 +372,7 @@ export function createRenderState(
if (bootstrapScriptContent !== undefined) {
bootstrapChunks.push(
inlineScriptWithNonce,
stringToChunk(
escapeBootstrapAndImportMapScriptContent(bootstrapScriptContent),
),
stringToChunk(escapeEntireInlineScriptContent(bootstrapScriptContent)),
endInlineScript,
);
}
Expand Down Expand Up @@ -411,9 +409,7 @@ export function createRenderState(
const map = importMap;
importMapChunks.push(importMapScriptStart);
importMapChunks.push(
stringToChunk(
escapeBootstrapAndImportMapScriptContent(JSON.stringify(map)),
),
stringToChunk(escapeEntireInlineScriptContent(JSON.stringify(map))),
);
importMapChunks.push(importMapScriptEnd);
}
Expand Down Expand Up @@ -3266,7 +3262,7 @@ function pushScriptImpl(

pushInnerHTML(target, innerHTML, children);
if (typeof children === 'string') {
target.push(stringToChunk(encodeHTMLTextNode(children)));
target.push(stringToChunk(escapeEntireInlineScriptContent(children)));
}
target.push(endChunkForTag('script'));
return null;
Expand Down
143 changes: 82 additions & 61 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4180,79 +4180,100 @@ describe('ReactDOMFizzServer', () => {
]);
});

describe('bootstrapScriptContent and importMap escaping', () => {
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 () => {
window.__test_outlet = '';
const stringWithScriptsInIt =
'prescription pre<scription pre<Scription pre</scRipTion pre</ScripTion </script><script><!-- <script> -->';
await act(() => {
const {pipe} = renderToPipeableStream(<div />, {
bootstrapScriptContent:
'window.__test_outlet = "This should have been replaced";var x = "' +
stringWithScriptsInIt +
'";\nwindow.__test_outlet = x;',
describe('inline script escaping', () => {
describe('bootstrapScriptContent', () => {
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 () => {
window.__test_outlet = '';
const stringWithScriptsInIt =
'prescription pre<scription pre<Scription pre</scRipTion pre</ScripTion </script><script><!-- <script> -->';
await act(() => {
const {pipe} = renderToPipeableStream(<div />, {
bootstrapScriptContent:
'window.__test_outlet = "This should have been replaced";var x = "' +
stringWithScriptsInIt +
'";\nwindow.__test_outlet = x;',
});
pipe(writable);
});
pipe(writable);
expect(window.__test_outlet).toMatch(stringWithScriptsInIt);
});
expect(window.__test_outlet).toMatch(stringWithScriptsInIt);
});

it('does not escape \\u2028, or \\u2029 characters', async () => {
// these characters are ignored in engines support https://github.com/tc39/proposal-json-superset
// in this test with JSDOM the characters are silently dropped and thus don't need to be encoded.
// if you send these characters to an older browser they could fail so it is a good idea to
// sanitize JSON input of these characters
window.__test_outlet = '';
const el = document.createElement('p');
el.textContent = '{"one":1,\u2028\u2029"two":2}';
const stringWithLSAndPSCharacters = el.textContent;
await act(() => {
const {pipe} = renderToPipeableStream(<div />, {
bootstrapScriptContent:
'let x = ' +
stringWithLSAndPSCharacters +
'; window.__test_outlet = x;',

it('does not escape \\u2028, or \\u2029 characters', async () => {
// these characters are ignored in engines support https://github.com/tc39/proposal-json-superset
// in this test with JSDOM the characters are silently dropped and thus don't need to be encoded.
// if you send these characters to an older browser they could fail so it is a good idea to
// sanitize JSON input of these characters
window.__test_outlet = '';
const el = document.createElement('p');
el.textContent = '{"one":1,\u2028\u2029"two":2}';
const stringWithLSAndPSCharacters = el.textContent;
await act(() => {
const {pipe} = renderToPipeableStream(<div />, {
bootstrapScriptContent:
'let x = ' +
stringWithLSAndPSCharacters +
'; window.__test_outlet = x;',
});
pipe(writable);
});
pipe(writable);
const outletString = JSON.stringify(window.__test_outlet);
expect(outletString).toBe(
stringWithLSAndPSCharacters.replace(/[\u2028\u2029]/g, ''),
);
});

it('does not escape <, >, or & characters', async () => {
// these characters valid javascript and may be necessary in scripts and won't be interpretted properly
// escaped outside of a string context within javascript
window.__test_outlet = null;
// this boolean expression will be cast to a number due to the bitwise &. we will look for a truthy value (1) below
const booleanLogicString = '1 < 2 & 3 > 1';
await act(() => {
const {pipe} = renderToPipeableStream(<div />, {
bootstrapScriptContent:
'let x = ' + booleanLogicString + '; window.__test_outlet = x;',
});
pipe(writable);
});
expect(window.__test_outlet).toBe(1);
});
const outletString = JSON.stringify(window.__test_outlet);
expect(outletString).toBe(
stringWithLSAndPSCharacters.replace(/[\u2028\u2029]/g, ''),
);
});

it('does not escape <, >, or & characters', async () => {
// these characters valid javascript and may be necessary in scripts and won't be interpretted properly
// escaped outside of a string context within javascript
window.__test_outlet = null;
// this boolean expression will be cast to a number due to the bitwise &. we will look for a truthy value (1) below
const booleanLogicString = '1 < 2 & 3 > 1';
await act(() => {
const {pipe} = renderToPipeableStream(<div />, {
bootstrapScriptContent:
'let x = ' + booleanLogicString + '; window.__test_outlet = x;',
describe('importMaps', () => {
it('escapes </[sS]cirpt> in importMaps', async () => {
window.__test_outlet_key = '';
window.__test_outlet_value = '';
const jsonWithScriptsInIt = {
"keypos</script><script>window.__test_outlet_key = 'pwned'</script><script>":
'value',
key: "valuepos</script><script>window.__test_outlet_value = 'pwned'</script><script>",
};
await act(() => {
const {pipe} = renderToPipeableStream(<div />, {
importMap: jsonWithScriptsInIt,
});
pipe(writable);
});
pipe(writable);
expect(window.__test_outlet_key).toBe('');
expect(window.__test_outlet_value).toBe('');
});
expect(window.__test_outlet).toBe(1);
});

it('escapes </[sS]cirpt> in importMaps', async () => {
window.__test_outlet_key = '';
window.__test_outlet_value = '';
const jsonWithScriptsInIt = {
"keypos</script><script>window.__test_outlet_key = 'pwned'</script><script>":
'value',
key: "valuepos</script><script>window.__test_outlet_value = 'pwned'</script><script>",
};
await act(() => {
const {pipe} = renderToPipeableStream(<div />, {
importMap: jsonWithScriptsInIt,
describe('inline script', () => {
it('escapes </[sS]cirpt> in inline scripts', async () => {
window.__test_outlet = '';
await act(() => {
const {pipe} = renderToPipeableStream(
<script>{`
<!-- some html comment </script><script>window.__test_outlet = 'pwned'</script>
window.__test_outlet = 'safe';
--> </script><script>window.__test_outlet = 'pwned after'</script>
`}</script>,
);
pipe(writable);
});
pipe(writable);
expect(window.__test_outlet).toBe('safe');
});
expect(window.__test_outlet_key).toBe('');
expect(window.__test_outlet_value).toBe('');
});
});

Expand Down