From 634e7e11e3ad39e0b13bf20cc7722c0cfd3c3e28 Mon Sep 17 00:00:00 2001 From: Mehdi Mulani Date: Fri, 4 May 2018 13:47:42 -0700 Subject: [PATCH] iOS only: Breaking Change: Restrict WebView to only http(s) URLs Summary: To prevent people from linking file:// or other URLs inside RN WebViews, default to not allowing those types of URLs. This adds the originWhitelist to specify other schemes or domains to be allowed. If the url is not allowed, it will be opened in Safari/by the OS instead. Reviewed By: yungsters Differential Revision: D7833203 fbshipit-source-id: 6881acd3b434d17910240e4edd585c0a10b5df8c --- IntegrationTests/WebViewTest.js | 1 + Libraries/Components/WebView/WebView.ios.js | 32 ++++++++++++--- Libraries/Components/WebView/WebViewShared.js | 24 +++++++++++ .../WebView/__tests__/WebViewShared-test.js | 41 +++++++++++++++++++ package.json | 1 + 5 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 Libraries/Components/WebView/WebViewShared.js create mode 100644 Libraries/Components/WebView/__tests__/WebViewShared-test.js diff --git a/IntegrationTests/WebViewTest.js b/IntegrationTests/WebViewTest.js index 499e1c095..818b4e980 100644 --- a/IntegrationTests/WebViewTest.js +++ b/IntegrationTests/WebViewTest.js @@ -48,6 +48,7 @@ class WebViewTest extends React.Component { ); } diff --git a/Libraries/Components/WebView/WebView.ios.js b/Libraries/Components/WebView/WebView.ios.js index 56a4465fe..1d54f663f 100644 --- a/Libraries/Components/WebView/WebView.ios.js +++ b/Libraries/Components/WebView/WebView.ios.js @@ -10,15 +10,17 @@ const ActivityIndicator = require('ActivityIndicator'); const EdgeInsetsPropType = require('EdgeInsetsPropType'); -const React = require('React'); +const Linking = require('Linking'); const PropTypes = require('prop-types'); +const React = require('React'); const ReactNative = require('ReactNative'); +const ScrollView = require('ScrollView'); const StyleSheet = require('StyleSheet'); const Text = require('Text'); const UIManager = require('UIManager'); const View = require('View'); const ViewPropTypes = require('ViewPropTypes'); -const ScrollView = require('ScrollView'); +const WebViewShared = require('WebViewShared'); const deprecatedPropType = require('deprecatedPropType'); const invariant = require('fbjs/lib/invariant'); @@ -353,6 +355,15 @@ class WebView extends React.Component { */ mediaPlaybackRequiresUserAction: PropTypes.bool, + /** + * List of origin strings to allow being navigated to. The strings allow + * wildcards and get matched against *just* the origin (not the full URL). + * If the user taps to navigate to a new page but the new page is not in + * this whitelist, we will open the URL in Safari. + * The default whitelisted origins are "http://*" and "https://*". + */ + originWhitelist: PropTypes.arrayOf(PropTypes.string), + /** * Function that accepts a string that will be passed to the WebView and * executed immediately as JavaScript. @@ -398,6 +409,7 @@ class WebView extends React.Component { }; static defaultProps = { + originWhitelist: WebViewShared.defaultOriginWhitelist, scalesPageToFit: true, }; @@ -446,9 +458,19 @@ class WebView extends React.Component { const viewManager = nativeConfig.viewManager || RCTWebViewManager; - const onShouldStartLoadWithRequest = this.props.onShouldStartLoadWithRequest && ((event: Event) => { - const shouldStart = this.props.onShouldStartLoadWithRequest && - this.props.onShouldStartLoadWithRequest(event.nativeEvent); + const compiledWhitelist = (this.props.originWhitelist || []).map(WebViewShared.originWhitelistToRegex); + const onShouldStartLoadWithRequest = ((event: Event) => { + let shouldStart = true; + const {url} = event.nativeEvent; + const origin = WebViewShared.extractOrigin(url); + const passesWhitelist = compiledWhitelist.some(x => new RegExp(x).test(origin)); + shouldStart = shouldStart && passesWhitelist; + if (!passesWhitelist) { + Linking.openURL(url); + } + if (this.props.onShouldStartLoadWithRequest) { + shouldStart = shouldStart && this.props.onShouldStartLoadWithRequest(event.nativeEvent); + } viewManager.startLoadWithResult(!!shouldStart, event.nativeEvent.lockIdentifier); }); diff --git a/Libraries/Components/WebView/WebViewShared.js b/Libraries/Components/WebView/WebViewShared.js new file mode 100644 index 000000000..744ea201f --- /dev/null +++ b/Libraries/Components/WebView/WebViewShared.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ +'use strict'; + +const escapeStringRegexp = require('escape-string-regexp'); + +const WebViewShared = { + defaultOriginWhitelist: ['http://*', 'https://*'], + extractOrigin: (url: string): ?string => { + const result = /^[A-Za-z0-9]+:(\/\/)?[^/]*/.exec(url); + return result === null ? null : result[0]; + }, + originWhitelistToRegex: (originWhitelist: string): string => { + return escapeStringRegexp(originWhitelist).replace(/\\\*/g, '.*'); + }, +}; + +module.exports = WebViewShared; diff --git a/Libraries/Components/WebView/__tests__/WebViewShared-test.js b/Libraries/Components/WebView/__tests__/WebViewShared-test.js new file mode 100644 index 000000000..37f52064c --- /dev/null +++ b/Libraries/Components/WebView/__tests__/WebViewShared-test.js @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+react_native + */ + + 'use strict'; + +const WebViewShared = require('WebViewShared'); + +describe('WebViewShared', () => { + it('extracts the origin correctly', () => { + expect(WebViewShared.extractOrigin('http://facebook.com')).toBe('http://facebook.com'); + expect(WebViewShared.extractOrigin('https://facebook.com')).toBe('https://facebook.com'); + expect(WebViewShared.extractOrigin('http://facebook.com:8081')).toBe('http://facebook.com:8081'); + expect(WebViewShared.extractOrigin('ftp://facebook.com')).toBe('ftp://facebook.com'); + expect(WebViewShared.extractOrigin('myweirdscheme://')).toBe('myweirdscheme://'); + expect(WebViewShared.extractOrigin('http://facebook.com/')).toBe('http://facebook.com'); + expect(WebViewShared.extractOrigin('http://facebook.com/longerurl')).toBe('http://facebook.com'); + expect(WebViewShared.extractOrigin('http://facebook.com/http://facebook.com')).toBe('http://facebook.com'); + expect(WebViewShared.extractOrigin('http://facebook.com//http://facebook.com')).toBe('http://facebook.com'); + expect(WebViewShared.extractOrigin('http://facebook.com//http://facebook.com//')).toBe('http://facebook.com'); + expect(WebViewShared.extractOrigin('about:blank')).toBe('about:blank'); + }); + + it('rejects bad urls', () => { + expect(WebViewShared.extractOrigin('a/b')).toBeNull(); + expect(WebViewShared.extractOrigin('a//b')).toBeNull(); + }); + + it('creates a whitelist regex correctly', () => { + expect(WebViewShared.originWhitelistToRegex('http://*')).toBe('http://.*'); + expect(WebViewShared.originWhitelistToRegex('*')).toBe('.*'); + expect(WebViewShared.originWhitelistToRegex('*//test')).toBe('.*//test'); + expect(WebViewShared.originWhitelistToRegex('*/*')).toBe('.*/.*'); + expect(WebViewShared.originWhitelistToRegex('*.com')).toBe('.*\\.com'); + }); +}); diff --git a/package.json b/package.json index 3be9d818e..02d8deb8c 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "denodeify": "^1.2.1", "envinfo": "^3.0.0", "errorhandler": "^1.5.0", + "escape-string-regexp": "^1.0.5", "eslint-plugin-react-native": "^3.2.1", "event-target-shim": "^1.0.5", "fbjs": "^0.8.14",