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",