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;