Repository: facebook/react
Pull request: Escape bootstrapScriptContent for javascript embedding into HTML

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.

closes: https://github.com/facebook/react/issues/23063

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
patch: @@ -2811,4 +2811,65 @@ describe('ReactDOMFizzServer', () => {
       </ul>,
     );
   });
+
+  describe('bootstrapScriptContent escaping', () => {
+    // @gate experimental
+    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(async () => {
+        const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<div />, {
+          bootstrapScriptContent:
+            'window.__test_outlet = "This should have been replaced";var x = "' +
+            stringWithScriptsInIt +
+            '";\nwindow.__test_outlet = x;',
+        });
+        pipe(writable);
+      });
+      expect(window.__test_outlet).toMatch(stringWithScriptsInIt);
+    });
+
+    // @gate experimental
+    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(async () => {
+        const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<div />, {
+          bootstrapScriptContent:
+            'let x = ' +
+            stringWithLSAndPSCharacters +
+            '; window.__test_outlet = x;',
+        });
+        pipe(writable);
+      });
+      const outletString = JSON.stringify(window.__test_outlet);
+      expect(outletString).toBe(
+        stringWithLSAndPSCharacters.replace(/[\u2028\u2029]/g, ''),
+      );
+    });
+
+    // @gate experimental
+    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(async () => {
+        const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<div />, {
+          bootstrapScriptContent:
+            'let x = ' + booleanLogicString + '; window.__test_outlet = x;',
+        });
+        pipe(writable);
+      });
+      expect(window.__test_outlet).toBe(1);
+    });
+  });
 });

filename: packages/react-dom/src/server/ReactDOMServerFormatConfig.js
patch: @@ -83,6 +83,17 @@ const startScriptSrc = stringToPrecomputedChunk('<script src="');
 const startModuleSrc = stringToPrecomputedChunk('<script type="module" src="');
 const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
 
+const scriptRegex = /(<\/|<)(s)(cript)/gi;
+const scriptReplacer = (match, prefix, s, suffix) =>
+  `${prefix}${s === 's' ? '\\u0073' : '\\u0053'}${suffix}`;
+
+function escapeBootstrapScriptContent(scriptText) {
+  if (__DEV__) {
+    checkHtmlStringCoercion(scriptText);
+  }
+  return ('' + scriptText).replace(scriptRegex, scriptReplacer);
+}
+
 // Allows us to keep track of what we've already written so we can refer back to it.
 export function createResponseState(
   identifierPrefix: string | void,
@@ -102,7 +113,7 @@ export function createResponseState(
   if (bootstrapScriptContent !== undefined) {
     bootstrapChunks.push(
       inlineScriptWithNonce,
-      stringToChunk(escapeTextForBrowser(bootstrapScriptContent)),
+      stringToChunk(escapeBootstrapScriptContent(bootstrapScriptContent)),
       endInlineScript,
     );
   }