From be5bc4d38a76cbaf4ee9d68d73a98a4a049409bb Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 15 Apr 2022 11:25:04 -0700 Subject: [PATCH] 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. --- .../__tests__/escapeScriptForBrowser-test.js | 107 ++++++++++++++++++ .../src/server/ReactDOMServerFormatConfig.js | 3 +- .../src/server/escapeScriptForBrowser.js | 37 ++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 packages/react-dom/src/__tests__/escapeScriptForBrowser-test.js create mode 100644 packages/react-dom/src/server/escapeScriptForBrowser.js diff --git a/packages/react-dom/src/__tests__/escapeScriptForBrowser-test.js b/packages/react-dom/src/__tests__/escapeScriptForBrowser-test.js new file mode 100644 index 0000000000000..681daf78bea37 --- /dev/null +++ b/packages/react-dom/src/__tests__/escapeScriptForBrowser-test.js @@ -0,0 +1,107 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactDOMFizzServer; +let Stream; + +function getTestWritable() { + const writable = new Stream.PassThrough(); + writable.setEncoding('utf8'); + const output = {result: '', error: undefined}; + writable.on('data', chunk => { + output.result += chunk; + }); + writable.on('error', error => { + output.error = error; + }); + const completed = new Promise(resolve => { + writable.on('finish', () => { + resolve(); + }); + writable.on('error', () => { + resolve(); + }); + }); + return {writable, completed, output}; +} + +describe('escapeScriptForBrowser', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOMFizzServer = require('react-dom/server'); + Stream = require('stream'); + }); + + it('"<[Ss]cript" strings are replaced with unicode escaped lowercase s or S depending on case', () => { + const {writable, output} = getTestWritable(); + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
, { + bootstrapScriptContent: + '"prescription pre
', + ); + }); + + it('" { + const {writable, output} = getTestWritable(); + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
, { + bootstrapScriptContent: + '"prescription pre
', + ); + }); + + it('"[Ss]cript", "/[Ss]cript", "<[Ss]crip", " { + const {writable, output} = getTestWritable(); + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
, { + bootstrapScriptContent: + '"Script script /Script /script
', + ); + }); + + it('matches case insensitively', () => { + const {writable, output} = getTestWritable(); + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
, { + bootstrapScriptContent: '"', + ); + }); + + it('does not escape <, >, &, \\u2028, or \\u2029 characters', () => { + const {writable, output} = getTestWritable(); + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
, { + bootstrapScriptContent: '"<, >, &, \u2028, or \u2029"', + }); + pipe(writable); + jest.runAllTimers(); + expect(output.result).toMatch( + '
', + ); + }); +}); diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js index a42cb3188722b..7b618fd601573 100644 --- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js @@ -52,6 +52,7 @@ import {validateProperties as validateUnknownProperties} from '../shared/ReactDO import warnValidStyle from '../shared/warnValidStyle'; import escapeTextForBrowser from './escapeTextForBrowser'; +import escapeScriptForBrowser from './escapeScriptForBrowser'; import hyphenateStyleName from '../shared/hyphenateStyleName'; import hasOwnProperty from 'shared/hasOwnProperty'; import sanitizeURL from '../shared/sanitizeURL'; @@ -102,7 +103,7 @@ export function createResponseState( if (bootstrapScriptContent !== undefined) { bootstrapChunks.push( inlineScriptWithNonce, - stringToChunk(escapeTextForBrowser(bootstrapScriptContent)), + stringToChunk(escapeScriptForBrowser(bootstrapScriptContent)), endInlineScript, ); } diff --git a/packages/react-dom/src/server/escapeScriptForBrowser.js b/packages/react-dom/src/server/escapeScriptForBrowser.js new file mode 100644 index 0000000000000..5827072c73dd7 --- /dev/null +++ b/packages/react-dom/src/server/escapeScriptForBrowser.js @@ -0,0 +1,37 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {checkHtmlStringCoercion} from 'shared/CheckStringCoercion'; + +const scriptRegex = /(<\/|<)(s)(cript)/gi; +const scriptReplacer = (match, prefix, s, suffix) => + `${prefix}${substitutions[s]}${suffix}`; +const substitutions = { + s: '\\u0073', + S: '\\u0053', +}; + +/** + * Escapes javascript for embedding into HTML. + * + * @param {*} scriptText Text value to escape. + * @return {string} An escaped string. + */ +function escapeScriptForBrowser(scriptText) { + if (typeof scriptText === 'boolean' || typeof scriptText === 'number') { + // this shortcircuit helps perf for types that we know will never have + // special characters, especially given that this function is used often + // for numeric dom ids. + return '' + scriptText; + } + if (__DEV__) { + checkHtmlStringCoercion(scriptText); + } + return ('' + scriptText).replace(scriptRegex, scriptReplacer); +} + +export default escapeScriptForBrowser;