diff --git a/Libraries/Components/WebView/WebView.android.js b/Libraries/Components/WebView/WebView.android.js index 6f0ac9b57fb033..b6995458e59001 100644 --- a/Libraries/Components/WebView/WebView.android.js +++ b/Libraries/Components/WebView/WebView.android.js @@ -16,6 +16,7 @@ const StyleSheet = require('StyleSheet'); const UIManager = require('UIManager'); const View = require('View'); const ViewPropTypes = require('ViewPropTypes'); +const WebViewShared = require('WebViewShared'); const deprecatedPropType = require('deprecatedPropType'); const keyMirror = require('fbjs/lib/keyMirror'); @@ -179,6 +180,15 @@ class WebView extends React.Component { */ allowUniversalAccessFromFileURLs: 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, the URL will be oppened by the Android OS. + * 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. @@ -241,7 +251,8 @@ class WebView extends React.Component { javaScriptEnabled : true, thirdPartyCookiesEnabled: true, scalesPageToFit: true, - saveFormDataDisabled: false + saveFormDataDisabled: false, + originWhitelist: WebViewShared.defaultOriginWhitelist, }; state = { @@ -293,6 +304,8 @@ class WebView extends React.Component { const nativeConfig = this.props.nativeConfig || {}; + const originWhitelist = (this.props.originWhitelist || []).map(WebViewShared.originWhitelistToRegex); + let NativeWebView = nativeConfig.component || RCTWebView; const webView = @@ -319,6 +332,7 @@ class WebView extends React.Component { geolocationEnabled={this.props.geolocationEnabled} mediaPlaybackRequiresUserAction={this.props.mediaPlaybackRequiresUserAction} allowUniversalAccessFromFileURLs={this.props.allowUniversalAccessFromFileURLs} + originWhitelist={originWhitelist} mixedContentMode={this.props.mixedContentMode} saveFormDataDisabled={this.props.saveFormDataDisabled} urlPrefixesForDefaultIntent={this.props.urlPrefixesForDefaultIntent} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/webview/ReactWebViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/webview/ReactWebViewManager.java index a580a2bc4a18af..7f07b2742677ab 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/webview/ReactWebViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/webview/ReactWebViewManager.java @@ -7,6 +7,11 @@ package com.facebook.react.views.webview; +import android.annotation.TargetApi; +import android.content.Context; +import java.util.LinkedList; +import java.util.List; +import java.util.regex.Pattern; import javax.annotation.Nullable; import java.io.UnsupportedEncodingException; @@ -110,6 +115,7 @@ protected static class ReactWebViewClient extends WebViewClient { protected boolean mLastLoadFailed = false; protected @Nullable ReadableArray mUrlPrefixesForDefaultIntent; + protected @Nullable List mOriginWhitelist; @Override public void onPageFinished(WebView webView, String url) { @@ -137,32 +143,50 @@ public void onPageStarted(WebView webView, String url, Bitmap favicon) { @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { - boolean useDefaultIntent = false; - if (mUrlPrefixesForDefaultIntent != null && mUrlPrefixesForDefaultIntent.size() > 0) { - ArrayList urlPrefixesForDefaultIntent = - mUrlPrefixesForDefaultIntent.toArrayList(); - for (Object urlPrefix : urlPrefixesForDefaultIntent) { - if (url.startsWith((String) urlPrefix)) { - useDefaultIntent = true; - break; - } + if (url.equals(BLANK_URL)) return false; + + // url blacklisting + if (mUrlPrefixesForDefaultIntent != null && mUrlPrefixesForDefaultIntent.size() > 0) { + ArrayList urlPrefixesForDefaultIntent = + mUrlPrefixesForDefaultIntent.toArrayList(); + for (Object urlPrefix : urlPrefixesForDefaultIntent) { + if (url.startsWith((String) urlPrefix)) { + launchIntent(view.getContext(), url); + return true; } } + } - if (!useDefaultIntent && - (url.startsWith("http://") || url.startsWith("https://") || - url.startsWith("file://") || url.equals("about:blank"))) { - return false; - } else { - try { - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - view.getContext().startActivity(intent); - } catch (ActivityNotFoundException e) { - FLog.w(ReactConstants.TAG, "activity not found to handle uri scheme for: " + url, e); - } + if (mOriginWhitelist != null && shouldHandleURL(mOriginWhitelist, url)) { + return false; + } + + launchIntent(view.getContext(), url); + return true; + } + + private void launchIntent(Context context, String url) { + try { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addCategory(Intent.CATEGORY_BROWSABLE); + context.startActivity(intent); + } catch (ActivityNotFoundException e) { + FLog.w(ReactConstants.TAG, "activity not found to handle uri scheme for: " + url, e); + } + } + + private boolean shouldHandleURL(List originWhitelist, String url) { + Uri uri = Uri.parse(url); + String scheme = uri.getScheme() != null ? uri.getScheme() : ""; + String authority = uri.getAuthority() != null ? uri.getAuthority() : ""; + String urlToCheck = scheme + "://" + authority; + for (Pattern pattern : originWhitelist) { + if (pattern.matcher(urlToCheck).matches()) { return true; } + } + return false; } @Override @@ -211,6 +235,10 @@ protected WritableMap createWebViewEvent(WebView webView, String url) { public void setUrlPrefixesForDefaultIntent(ReadableArray specialUrls) { mUrlPrefixesForDefaultIntent = specialUrls; } + + public void setOriginWhitelist(List originWhitelist) { + mOriginWhitelist = originWhitelist; + } } /** @@ -356,6 +384,7 @@ protected ReactWebView createReactWebViewInstance(ThemedReactContext reactContex } @Override + @TargetApi(Build.VERSION_CODES.LOLLIPOP) protected WebView createViewInstance(ThemedReactContext reactContext) { ReactWebView webView = createReactWebViewInstance(reactContext); webView.setWebChromeClient(new WebChromeClient() { @@ -375,9 +404,18 @@ public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermiss }); reactContext.addLifecycleEventListener(webView); mWebViewConfig.configWebView(webView); - webView.getSettings().setBuiltInZoomControls(true); - webView.getSettings().setDisplayZoomControls(false); - webView.getSettings().setDomStorageEnabled(true); + WebSettings settings = webView.getSettings(); + settings.setBuiltInZoomControls(true); + settings.setDisplayZoomControls(false); + settings.setDomStorageEnabled(true); + + settings.setAllowFileAccess(false); + settings.setAllowContentAccess(false); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + settings.setAllowFileAccessFromFileURLs(false); + setAllowUniversalAccessFromFileURLs(webView, false); + } + setMixedContentMode(webView, "never"); // Fixes broken full-screen modals/galleries due to body height being 0. webView.setLayoutParams( @@ -546,6 +584,20 @@ public void setGeolocationEnabled( view.getSettings().setGeolocationEnabled(isGeolocationEnabled != null && isGeolocationEnabled); } + @ReactProp(name = "originWhitelist") + public void setOriginWhitelist( + WebView view, + @Nullable ReadableArray originWhitelist) { + ReactWebViewClient client = ((ReactWebView) view).getReactWebViewClient(); + if (client != null && originWhitelist != null) { + List whiteList = new LinkedList<>(); + for (int i = 0 ; i < originWhitelist.size() ; i++) { + whiteList.add(Pattern.compile(originWhitelist.getString(i))); + } + client.setOriginWhitelist(whiteList); + } + } + @Override protected void addEventEmitters(ThemedReactContext reactContext, WebView view) { // Do not register default touch emitter and let WebView implementation handle touches