From d56a22fc295da4083f2ab11e719f920b5b1a0c93 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 6 Mar 2019 16:19:00 -0800 Subject: [PATCH 01/11] Allow framesets --- packages/react-dom/src/client/validateDOMNesting.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-dom/src/client/validateDOMNesting.js b/packages/react-dom/src/client/validateDOMNesting.js index c4190394d560..3bd82241c082 100644 --- a/packages/react-dom/src/client/validateDOMNesting.js +++ b/packages/react-dom/src/client/validateDOMNesting.js @@ -282,7 +282,9 @@ if (__DEV__) { ); // https://html.spec.whatwg.org/multipage/semantics.html#the-html-element case 'html': - return tag === 'head' || tag === 'body'; + return tag === 'head' || tag === 'body' || tag === 'frameset'; + case 'frameset': + return tag === 'frame'; case '#document': return tag === 'html'; } @@ -314,6 +316,7 @@ if (__DEV__) { case 'caption': case 'col': case 'colgroup': + case 'frameset': case 'frame': case 'head': case 'html': From 38a27cb98541dd5c9fea8a985d03ace4cf9cfb57 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 6 Mar 2019 16:49:37 -0800 Subject: [PATCH 02/11] Allow to be used in integration tests Full document renders requires server rendering so the client path just uses the hydration path in this case to simplify writing these tests. --- .../ReactDOMServerIntegrationTestUtils.js | 77 ++++++++++++++----- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js b/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js index d45462e42c5b..57b00046dd78 100644 --- a/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js +++ b/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js @@ -19,6 +19,28 @@ module.exports = function(initModules) { ({ReactDOM, ReactDOMServer} = initModules()); } + function shouldUseDocument(reactElement) { + // Used for whole document tests. + return reactElement && reactElement.type === 'html'; + } + + function getContainerFromMarkup(reactElement, markup) { + if (shouldUseDocument(reactElement)) { + const doc = document.implementation.createHTMLDocument(''); + doc.open(); + doc.write( + markup || + 'test doc', + ); + doc.close(); + return doc; + } else { + const container = document.createElement('div'); + container.innerHTML = markup; + return container; + } + } + // Helper functions for rendering tests // ==================================== @@ -97,9 +119,7 @@ module.exports = function(initModules) { // Does not render on client or perform client-side revival. async function serverRender(reactElement, errorCount = 0) { const markup = await renderIntoString(reactElement, errorCount); - const domElement = document.createElement('div'); - domElement.innerHTML = markup; - return domElement.firstChild; + return getContainerFromMarkup(reactElement, markup).firstChild; } // this just drains a readable piped into it to a string, which can be accessed @@ -133,27 +153,28 @@ module.exports = function(initModules) { // Does not render on client or perform client-side revival. async function streamRender(reactElement, errorCount = 0) { const markup = await renderIntoStream(reactElement, errorCount); - const domElement = document.createElement('div'); - domElement.innerHTML = markup; - return domElement.firstChild; + return getContainerFromMarkup(reactElement, markup).firstChild; } const clientCleanRender = (element, errorCount = 0) => { - const div = document.createElement('div'); - return renderIntoDom(element, div, false, errorCount); + if (shouldUseDocument(element)) { + // Documents can't be rendered from scratch. + return clientRenderOnServerString(element, errorCount); + } + const container = document.createElement('div'); + return renderIntoDom(element, container, false, errorCount); }; const clientRenderOnServerString = async (element, errorCount = 0) => { const markup = await renderIntoString(element, errorCount); resetModules(); - const domElement = document.createElement('div'); - domElement.innerHTML = markup; - let serverNode = domElement.firstChild; + let container = getContainerFromMarkup(element, markup); + let serverNode = container.firstChild; const firstClientNode = await renderIntoDom( element, - domElement, + container, true, errorCount, ); @@ -178,19 +199,35 @@ module.exports = function(initModules) { const clientRenderOnBadMarkup = async (element, errorCount = 0) => { // First we render the top of bad mark up. - const domElement = document.createElement('div'); - domElement.innerHTML = - '
'; - await renderIntoDom(element, domElement, true, errorCount + 1); + + let container = getContainerFromMarkup( + element, + shouldUseDocument(element) + ? '
' + : '
', + ); + + await renderIntoDom(element, container, true, errorCount + 1); // This gives us the resulting text content. - const hydratedTextContent = domElement.textContent; + const hydratedTextContent = + container.lastChild && container.lastChild.textContent; // Next we render the element into a clean DOM node client side. - const cleanDomElement = document.createElement('div'); - await asyncReactDOMRender(element, cleanDomElement, true); + let cleanContainer; + if (shouldUseDocument(element)) { + // We can't render into a document during a clean render, + // so instead, we'll render the children into the document element. + cleanContainer = getContainerFromMarkup(element, '') + .documentElement; + element = element.props.children; + } else { + cleanContainer = document.createElement('div'); + } + await asyncReactDOMRender(element, cleanContainer, true); // This gives us the expected text content. - const cleanTextContent = cleanDomElement.textContent; + const cleanTextContent = + cleanContainer.lastChild && cleanContainer.lastChild.textContent; // The only guarantee is that text content has been patched up if needed. expect(hydratedTextContent).toBe(cleanTextContent); From 968e0a0eafc27a69d3aac671d2836ba6daa62b22 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 6 Mar 2019 00:18:44 -0800 Subject: [PATCH 03/11] Prevent javascript protocol URLs --- ...erIntegrationUntrustedURL-test.internal.js | 133 ++++++++++++++++++ .../src/client/DOMPropertyOperations.js | 4 + .../src/server/DOMMarkupOperations.js | 5 + packages/react-dom/src/shared/DOMProperty.js | 40 +++++- packages/react-dom/src/shared/sanitizeURL.js | 28 ++++ packages/shared/ReactFeatureFlags.js | 3 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.persistent.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 12 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.internal.js create mode 100644 packages/react-dom/src/shared/sanitizeURL.js diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.internal.js new file mode 100644 index 000000000000..7c33a7032b23 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.internal.js @@ -0,0 +1,133 @@ +/** + * 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 + */ + +/* eslint-disable no-script-url */ + +'use strict'; + +const ReactDOMServerIntegrationUtils = require('./utils/ReactDOMServerIntegrationTestUtils'); + +let React; +let ReactDOM; +let ReactDOMServer; + +function initModules() { + jest.resetModuleRegistry(); + const ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.disableJavaScriptURLs = true; + + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMServer = require('react-dom/server'); + + // Make them available to the helpers. + return { + ReactDOM, + ReactDOMServer, + }; +} + +const { + resetModules, + itRenders, + itThrowsWhenRendering, +} = ReactDOMServerIntegrationUtils(initModules); + +describe('ReactDOMServerIntegration', () => { + beforeEach(() => { + resetModules(); + }); + + itRenders('a http link with the word javascript in it', async render => { + const e = await render( + Click me, + ); + expect(e.tagName).toBe('A'); + expect(e.href).toBe('http://javascript:0/thisisfine'); + }); + + itThrowsWhenRendering( + 'a javascript protocol href', + render => render(p0wned, 1), + 'XSS', + ); + + itThrowsWhenRendering( + 'a javascript protocol area href', + render => + render( + + + , + 1, + ), + 'XSS', + ); + + itThrowsWhenRendering( + 'a javascript protocol form action', + render => render(
p0wned
, 1), + 'XSS', + ); + + itThrowsWhenRendering( + 'a javascript protocol button formAction', + render => render(, 1), + 'XSS', + ); + + itThrowsWhenRendering( + 'a javascript protocol input formAction', + render => + render(, 1), + 'XSS', + ); + + itThrowsWhenRendering( + 'a javascript protocol iframe src', + render => render(