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 0000000000000..21a4cc1b6dc87
--- /dev/null
+++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.internal.js
@@ -0,0 +1,271 @@
+/**
+ * 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 runTests(itRenders, itRejectsRendering, expectToReject) {
+ 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');
+ });
+
+ itRejectsRendering('a javascript protocol href', async render => {
+ // Only the first one warns. The second warning is deduped.
+ const e = await render(
+
,
+ 1,
+ );
+ expect(e.firstChild.href).toBe('javascript:notfine');
+ expect(e.lastChild.href).toBe('javascript:notfineagain');
+ });
+
+ itRejectsRendering(
+ 'a javascript protocol with leading spaces',
+ async render => {
+ const e = await render(
+ p0wned ,
+ 1,
+ );
+ // We use an approximate comparison here because JSDOM might not parse
+ // \u0000 in HTML properly.
+ expect(e.href).toContain('notfine');
+ },
+ );
+
+ itRejectsRendering(
+ 'a javascript protocol with intermediate new lines and mixed casing',
+ async render => {
+ const e = await render(
+ p0wned ,
+ 1,
+ );
+ expect(e.href).toBe('javascript:notfine');
+ },
+ );
+
+ itRejectsRendering('a javascript protocol area href', async render => {
+ const e = await render(
+
+
+ ,
+ 1,
+ );
+ expect(e.firstChild.href).toBe('javascript:notfine');
+ });
+
+ itRejectsRendering('a javascript protocol form action', async render => {
+ const e = await render(, 1);
+ expect(e.action).toBe('javascript:notfine');
+ });
+
+ itRejectsRendering(
+ 'a javascript protocol button formAction',
+ async render => {
+ const e = await render( , 1);
+ expect(e.getAttribute('formAction')).toBe('javascript:notfine');
+ },
+ );
+
+ itRejectsRendering('a javascript protocol input formAction', async render => {
+ const e = await render(
+ p0wned ,
+ 1,
+ );
+ expect(e.getAttribute('formAction')).toBe('javascript:notfine');
+ });
+
+ itRejectsRendering('a javascript protocol iframe src', async render => {
+ const e = await render(, 1);
+ expect(e.src).toBe('javascript:notfine');
+ });
+
+ itRejectsRendering('a javascript protocol frame src', async render => {
+ const e = await render(
+
+
'
+ : '
',
+ );
+
+ 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);
@@ -320,6 +357,7 @@ module.exports = function(initModules) {
asyncReactDOMRender,
serverRender,
clientCleanRender,
+ clientRenderOnBadMarkup,
clientRenderOnServerString,
renderIntoDom,
streamRender,
diff --git a/packages/react-dom/src/client/DOMPropertyOperations.js b/packages/react-dom/src/client/DOMPropertyOperations.js
index 9a186827fa700..6a2d0f7d244a6 100644
--- a/packages/react-dom/src/client/DOMPropertyOperations.js
+++ b/packages/react-dom/src/client/DOMPropertyOperations.js
@@ -15,6 +15,8 @@ import {
BOOLEAN,
OVERLOADED_BOOLEAN,
} from '../shared/DOMProperty';
+import sanitizeURL from '../shared/sanitizeURL';
+import {disableJavaScriptURLs} from 'shared/ReactFeatureFlags';
import type {PropertyInfo} from '../shared/DOMProperty';
@@ -34,6 +36,13 @@ export function getValueForProperty(
const {propertyName} = propertyInfo;
return (node: any)[propertyName];
} else {
+ if (!disableJavaScriptURLs && propertyInfo.sanitizeURL) {
+ // If we haven't fully disabled javascript: URLs, and if
+ // the hydration is successful of a javascript: URL, we
+ // still want to warn on the client.
+ sanitizeURL('' + (expected: any));
+ }
+
const attributeName = propertyInfo.attributeName;
let stringValue = null;
@@ -164,6 +173,9 @@ export function setValueForProperty(
// `setAttribute` with objects becomes only `[object]` in IE8/9,
// ('' + value) makes it output the correct toString()-value.
attributeValue = '' + (value: any);
+ if (propertyInfo.sanitizeURL) {
+ sanitizeURL(attributeValue);
+ }
}
if (attributeNamespace) {
node.setAttributeNS(attributeNamespace, attributeName, attributeValue);
diff --git a/packages/react-dom/src/client/validateDOMNesting.js b/packages/react-dom/src/client/validateDOMNesting.js
index c4190394d560f..3bd82241c082d 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':
diff --git a/packages/react-dom/src/server/DOMMarkupOperations.js b/packages/react-dom/src/server/DOMMarkupOperations.js
index 39dd3aee7f29a..7792bbcb0d5ac 100644
--- a/packages/react-dom/src/server/DOMMarkupOperations.js
+++ b/packages/react-dom/src/server/DOMMarkupOperations.js
@@ -17,6 +17,7 @@ import {
shouldIgnoreAttribute,
shouldRemoveAttribute,
} from '../shared/DOMProperty';
+import sanitizeURL from '../shared/sanitizeURL';
import quoteAttributeValueForBrowser from './quoteAttributeValueForBrowser';
/**
@@ -58,6 +59,10 @@ export function createMarkupForProperty(name: string, value: mixed): string {
if (type === BOOLEAN || (type === OVERLOADED_BOOLEAN && value === true)) {
return attributeName + '=""';
} else {
+ if (propertyInfo.sanitizeURL) {
+ value = '' + (value: any);
+ sanitizeURL(value);
+ }
return attributeName + '=' + quoteAttributeValueForBrowser(value);
}
} else if (isAttributeNameSafe(name)) {
diff --git a/packages/react-dom/src/shared/DOMProperty.js b/packages/react-dom/src/shared/DOMProperty.js
index 3dd08203d4951..e770e0f722cc8 100644
--- a/packages/react-dom/src/shared/DOMProperty.js
+++ b/packages/react-dom/src/shared/DOMProperty.js
@@ -51,6 +51,7 @@ export type PropertyInfo = {|
+mustUseProperty: boolean,
+propertyName: string,
+type: PropertyType,
+ +sanitizeURL: boolean,
|};
/* eslint-disable max-len */
@@ -186,6 +187,7 @@ function PropertyInfoRecord(
mustUseProperty: boolean,
attributeName: string,
attributeNamespace: string | null,
+ sanitizeURL: boolean,
) {
this.acceptsBooleans =
type === BOOLEANISH_STRING ||
@@ -196,6 +198,7 @@ function PropertyInfoRecord(
this.mustUseProperty = mustUseProperty;
this.propertyName = name;
this.type = type;
+ this.sanitizeURL = sanitizeURL;
}
// When adding attributes to this list, be sure to also add them to
@@ -223,6 +226,7 @@ const properties = {};
false, // mustUseProperty
name, // attributeName
null, // attributeNamespace
+ false, // sanitizeURL
);
});
@@ -240,6 +244,7 @@ const properties = {};
false, // mustUseProperty
attributeName, // attributeName
null, // attributeNamespace
+ false, // sanitizeURL
);
});
@@ -253,6 +258,7 @@ const properties = {};
false, // mustUseProperty
name.toLowerCase(), // attributeName
null, // attributeNamespace
+ false, // sanitizeURL
);
});
@@ -272,6 +278,7 @@ const properties = {};
false, // mustUseProperty
name, // attributeName
null, // attributeNamespace
+ false, // sanitizeURL
);
});
@@ -308,6 +315,7 @@ const properties = {};
false, // mustUseProperty
name.toLowerCase(), // attributeName
null, // attributeNamespace
+ false, // sanitizeURL
);
});
@@ -331,6 +339,7 @@ const properties = {};
true, // mustUseProperty
name, // attributeName
null, // attributeNamespace
+ false, // sanitizeURL
);
});
@@ -350,6 +359,7 @@ const properties = {};
false, // mustUseProperty
name, // attributeName
null, // attributeNamespace
+ false, // sanitizeURL
);
});
@@ -370,6 +380,7 @@ const properties = {};
false, // mustUseProperty
name, // attributeName
null, // attributeNamespace
+ false, // sanitizeURL
);
});
@@ -381,6 +392,7 @@ const properties = {};
false, // mustUseProperty
name.toLowerCase(), // attributeName
null, // attributeNamespace
+ false, // sanitizeURL
);
});
@@ -478,6 +490,7 @@ const capitalize = token => token[1].toUpperCase();
false, // mustUseProperty
attributeName,
null, // attributeNamespace
+ false, // sanitizeURL
);
});
@@ -485,7 +498,6 @@ const capitalize = token => token[1].toUpperCase();
[
'xlink:actuate',
'xlink:arcrole',
- 'xlink:href',
'xlink:role',
'xlink:show',
'xlink:title',
@@ -502,6 +514,7 @@ const capitalize = token => token[1].toUpperCase();
false, // mustUseProperty
attributeName,
'http://www.w3.org/1999/xlink',
+ false, // sanitizeURL
);
});
@@ -522,6 +535,7 @@ const capitalize = token => token[1].toUpperCase();
false, // mustUseProperty
attributeName,
'http://www.w3.org/XML/1998/namespace',
+ false, // sanitizeURL
);
});
@@ -535,5 +549,29 @@ const capitalize = token => token[1].toUpperCase();
false, // mustUseProperty
attributeName.toLowerCase(), // attributeName
null, // attributeNamespace
+ false, // sanitizeURL
+ );
+});
+
+// These attributes accept URLs. These must not allow javascript: URLS.
+// These will also need to accept Trusted Types object in the future.
+const xlinkHref = 'xlinkHref';
+properties[xlinkHref] = new PropertyInfoRecord(
+ 'xlinkHref',
+ STRING,
+ false, // mustUseProperty
+ 'xlink:href',
+ 'http://www.w3.org/1999/xlink',
+ true, // sanitizeURL
+);
+
+['src', 'href', 'action', 'formAction'].forEach(attributeName => {
+ properties[attributeName] = new PropertyInfoRecord(
+ attributeName,
+ STRING,
+ false, // mustUseProperty
+ attributeName.toLowerCase(), // attributeName
+ null, // attributeNamespace
+ true, // sanitizeURL
);
});
diff --git a/packages/react-dom/src/shared/sanitizeURL.js b/packages/react-dom/src/shared/sanitizeURL.js
new file mode 100644
index 0000000000000..9e1034c1fe459
--- /dev/null
+++ b/packages/react-dom/src/shared/sanitizeURL.js
@@ -0,0 +1,53 @@
+/**
+ * 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.
+ *
+ * @flow
+ */
+
+import invariant from 'shared/invariant';
+import warning from 'shared/warning';
+import ReactSharedInternals from 'shared/ReactSharedInternals';
+import {disableJavaScriptURLs} from 'shared/ReactFeatureFlags';
+
+let ReactDebugCurrentFrame = ((null: any): {getStackAddendum(): string});
+if (__DEV__) {
+ ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;
+}
+
+// A javascript: URL can contain leading C0 control or \u0020 SPACE,
+// and any newline or tab are filtered out as if they're not part of the URL.
+// https://url.spec.whatwg.org/#url-parsing
+// Tab or newline are defined as \r\n\t:
+// https://infra.spec.whatwg.org/#ascii-tab-or-newline
+// A C0 control is a code point in the range \u0000 NULL to \u001F
+// INFORMATION SEPARATOR ONE, inclusive:
+// https://infra.spec.whatwg.org/#c0-control-or-space
+
+/* eslint-disable max-len */
+const isJavaScriptProtocol = /^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*\:/i;
+
+let didWarn = false;
+
+function sanitizeURL(url: string) {
+ if (disableJavaScriptURLs) {
+ invariant(
+ !isJavaScriptProtocol.test(url),
+ 'React has blocked a javascript: URL as a security precaution.%s',
+ __DEV__ ? ReactDebugCurrentFrame.getStackAddendum() : '',
+ );
+ } else if (__DEV__ && !didWarn && isJavaScriptProtocol.test(url)) {
+ didWarn = true;
+ warning(
+ false,
+ 'A future version of React will block javascript: URLs as a security precaution. ' +
+ 'Use event handlers instead if you can. If you need to generate unsafe HTML try ' +
+ 'using dangerouslySetInnerHTML instead. React was passed %s.',
+ JSON.stringify(url),
+ );
+ }
+}
+
+export default sanitizeURL;
diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js
index ac67f3d8ead1e..39c81704cfd3a 100644
--- a/packages/shared/ReactFeatureFlags.js
+++ b/packages/shared/ReactFeatureFlags.js
@@ -42,6 +42,9 @@ export function addUserTimingListener() {
throw new Error('Not implemented.');
}
+// Disable javascript: URL strings in href for XSS protection.
+export const disableJavaScriptURLs = false;
+
// React Fire: prevent the value and checked attributes from syncing
// with their related DOM properties
export const disableInputAttributeSyncing = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js
index b5cef5b36353d..6a7833e06aabc 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-fb.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js
@@ -24,6 +24,7 @@ export const enableStableConcurrentModeAPIs = false;
export const warnAboutShorthandPropertyCollision = false;
export const enableSchedulerDebugging = false;
export const debugRenderPhaseSideEffectsForStrictMode = true;
+export const disableJavaScriptURLs = false;
export const disableInputAttributeSyncing = false;
export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__;
export const warnAboutDeprecatedLifecycles = true;
diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js
index 25000d24f2fc6..aa48eb40e2889 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-oss.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js
@@ -20,6 +20,7 @@ export const warnAboutDeprecatedLifecycles = false;
export const enableProfilerTimer = __PROFILE__;
export const enableSchedulerTracing = __PROFILE__;
export const enableSuspenseServerRenderer = false;
+export const disableJavaScriptURLs = false;
export const disableInputAttributeSyncing = false;
export const enableStableConcurrentModeAPIs = false;
export const warnAboutShorthandPropertyCollision = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.persistent.js b/packages/shared/forks/ReactFeatureFlags.persistent.js
index 6179f0da5634c..345fee26407a9 100644
--- a/packages/shared/forks/ReactFeatureFlags.persistent.js
+++ b/packages/shared/forks/ReactFeatureFlags.persistent.js
@@ -20,6 +20,7 @@ export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__;
export const enableProfilerTimer = __PROFILE__;
export const enableSchedulerTracing = __PROFILE__;
export const enableSuspenseServerRenderer = false;
+export const disableJavaScriptURLs = false;
export const disableInputAttributeSyncing = false;
export const enableStableConcurrentModeAPIs = false;
export const warnAboutShorthandPropertyCollision = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js
index 0798a7f7dff99..7153ac8c29903 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js
@@ -20,6 +20,7 @@ export const replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
export const enableProfilerTimer = false;
export const enableSchedulerTracing = false;
export const enableSuspenseServerRenderer = false;
+export const disableJavaScriptURLs = false;
export const disableInputAttributeSyncing = false;
export const enableStableConcurrentModeAPIs = false;
export const warnAboutShorthandPropertyCollision = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
index e51f5810e2e49..b97c5d8559ed2 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
@@ -23,6 +23,7 @@ export const enableSuspenseServerRenderer = false;
export const enableStableConcurrentModeAPIs = false;
export const enableSchedulerDebugging = false;
export const warnAboutDeprecatedSetNativeProps = false;
+export const disableJavaScriptURLs = false;
// Only used in www builds.
export function addUserTimingListener() {
diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js
index 3048979a9d32e..d63c184065bfa 100644
--- a/packages/shared/forks/ReactFeatureFlags.www.js
+++ b/packages/shared/forks/ReactFeatureFlags.www.js
@@ -16,6 +16,7 @@ export const {
debugRenderPhaseSideEffectsForStrictMode,
replayFailedUnitOfWorkWithInvokeGuardedCallback,
warnAboutDeprecatedLifecycles,
+ disableJavaScriptURLs,
disableInputAttributeSyncing,
warnAboutShorthandPropertyCollision,
warnAboutDeprecatedSetNativeProps,