Skip to content

Commit

Permalink
[Fizz] escape <script> textContent similar to bootstrapScript (#28871)
Browse files Browse the repository at this point in the history
stacked on #28870 

inline script children have been encoded as HTML for a while now but
this can easily break script parsing so practically if you were
rendering inline scripts you were using dangerouslySetInnerHTML. This is
not great because now there is no escaping at all so you have to be even
more careful. While care should always be taken when rendering untrusted
script content driving users to use dangerous APIs is not the right
approach and in this PR the escaping functionality used for
bootstrapScripts and importMaps is being extended to any inline script.

the approach is to escape 's' or 'S" with the appropriate unicode code
point if it is inside a <script or </script sequence. This has the nice
benefit of minimally escaping the text for readability while still
preserving full js parsing capabilities. As articulated when we
introduced this escaping for prior use cases this is only safe because
we are escaping the entire script content. It would be unsafe if we were
not escaping the entirety of the script because we would no longer be
able to ensure there are no earlier or later <script sequences that put
the parser in unexpected states.
  • Loading branch information
gnoff committed Apr 19, 2024
1 parent aead514 commit 561c023
Show file tree
Hide file tree
Showing 2 changed files with 88 additions and 71 deletions.
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

0 comments on commit 561c023

Please sign in to comment.