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 <WebView> 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
This commit is contained in:
Mehdi Mulani 2018-05-04 13:47:42 -07:00 committed by Facebook Github Bot
parent cd48a6130b
commit 634e7e11e3
5 changed files with 94 additions and 5 deletions

View File

@ -48,6 +48,7 @@ class WebViewTest extends React.Component {
<WebView <WebView
source={source} source={source}
onMessage = {processMessage} onMessage = {processMessage}
originWhitelist={['about:blank']}
/> />
); );
} }

View File

@ -10,15 +10,17 @@
const ActivityIndicator = require('ActivityIndicator'); const ActivityIndicator = require('ActivityIndicator');
const EdgeInsetsPropType = require('EdgeInsetsPropType'); const EdgeInsetsPropType = require('EdgeInsetsPropType');
const React = require('React'); const Linking = require('Linking');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const React = require('React');
const ReactNative = require('ReactNative'); const ReactNative = require('ReactNative');
const ScrollView = require('ScrollView');
const StyleSheet = require('StyleSheet'); const StyleSheet = require('StyleSheet');
const Text = require('Text'); const Text = require('Text');
const UIManager = require('UIManager'); const UIManager = require('UIManager');
const View = require('View'); const View = require('View');
const ViewPropTypes = require('ViewPropTypes'); const ViewPropTypes = require('ViewPropTypes');
const ScrollView = require('ScrollView'); const WebViewShared = require('WebViewShared');
const deprecatedPropType = require('deprecatedPropType'); const deprecatedPropType = require('deprecatedPropType');
const invariant = require('fbjs/lib/invariant'); const invariant = require('fbjs/lib/invariant');
@ -353,6 +355,15 @@ class WebView extends React.Component {
*/ */
mediaPlaybackRequiresUserAction: PropTypes.bool, 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 * Function that accepts a string that will be passed to the WebView and
* executed immediately as JavaScript. * executed immediately as JavaScript.
@ -398,6 +409,7 @@ class WebView extends React.Component {
}; };
static defaultProps = { static defaultProps = {
originWhitelist: WebViewShared.defaultOriginWhitelist,
scalesPageToFit: true, scalesPageToFit: true,
}; };
@ -446,9 +458,19 @@ class WebView extends React.Component {
const viewManager = nativeConfig.viewManager || RCTWebViewManager; const viewManager = nativeConfig.viewManager || RCTWebViewManager;
const onShouldStartLoadWithRequest = this.props.onShouldStartLoadWithRequest && ((event: Event) => { const compiledWhitelist = (this.props.originWhitelist || []).map(WebViewShared.originWhitelistToRegex);
const shouldStart = this.props.onShouldStartLoadWithRequest && const onShouldStartLoadWithRequest = ((event: Event) => {
this.props.onShouldStartLoadWithRequest(event.nativeEvent); 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); viewManager.startLoadWithResult(!!shouldStart, event.nativeEvent.lockIdentifier);
}); });

View File

@ -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;

View File

@ -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');
});
});

View File

@ -158,6 +158,7 @@
"denodeify": "^1.2.1", "denodeify": "^1.2.1",
"envinfo": "^3.0.0", "envinfo": "^3.0.0",
"errorhandler": "^1.5.0", "errorhandler": "^1.5.0",
"escape-string-regexp": "^1.0.5",
"eslint-plugin-react-native": "^3.2.1", "eslint-plugin-react-native": "^3.2.1",
"event-target-shim": "^1.0.5", "event-target-shim": "^1.0.5",
"fbjs": "^0.8.14", "fbjs": "^0.8.14",