Advanced Uses of WebView

Android uses the WebKit browser engine as the foundation for both its Browser application and the WebView embeddable browsing widget. The Browser application, of course, is something Android users can interact with directly; the WebView widget is something you can integrate into your own applications for places where an HTML interface might be useful.

Earlier in this book, we saw a simple integration of a WebView into an Android activity, with the activity dictating what the browsing widget displayed and how it responded to links.

Here, we will expand on this theme, and show how to more tightly integrate the Java environment of an Android application with the JavaScript environment of WebKit.

Prerequisites

Understanding this chapter requires that you have read the core chapters, particularly the one covering WebView. Some of the samples use LocationManager for obtaining a GPS fix.

Friends with Benefits

When you integrate a WebView into your activity, you can control what Web pages are displayed, whether they are from a local provider or come from over the Internet, what should happen when a link is clicked, and so forth. And between WebView, WebViewClient, and WebSettings, you can control a fair bit about how the embedded browser behaves. Yet, by default, the browser itself is just a browser, capable of showing Web pages and interacting with Web sites, but otherwise gaining nothing from being hosted by an Android application.

However, WebView offers a few options for more tightly integrating the Java and JavaScript realms, so Web content can call into your app to get data to display, and your app can push data into the Web page for JavaScript to render.

Unfortunately, the techniques for doing this have changed over the years. Partially that is due to changes in WebView, particularly starting with Android 4.4. But, some of the changes are due to security issues, particularly when you are loading arbitrary content, such as Web-based ads from an ad network, into your WebView.

The following sections will go over four separate sample apps. All do the same thing: provide data about the ambient light level, using the sensor on the phone or tablet, to the Web page for rendering. The differences are whether we are pushing data from Java into JavaScript (e.g., as the light level changes), or whether we are pulling data from Java using JavaScript (e.g., in response to a user tapping on something in the Web page). Also, we will see two approaches to push and two approaches to pull.

JavaScript Calling Java: addJavascriptInterface()

The addJavascriptInterface() method on WebView allows you to inject a Java object into the WebView, exposing its methods, so they can be called by JavaScript loaded by the Web content in the WebView itself.

Now you have the power to provide access to a wide range of Android features and capabilities to your WebView-hosted content. If you can access it from your activity, and if you can wrap it in something convenient for use by JavaScript, your Web pages can access it as well.

The WebKit/SensorPull sample project demonstrates using addJavascriptInterface() to pull light sensor data into a Web page to display to the user.

For all four of these sample apps, the UI is just a WebView:

<?xml version="1.0" encoding="utf-8"?>
<WebView android:id="@+id/webkit"
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent">

</WebView>
(from WebKit/SensorPull/app/src/main/res/layout/main.xml)

In onCreate(), we set things up:

  @SuppressLint({"AddJavascriptInterface", "SetJavaScriptEnabled"})
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main);

    mgr=(SensorManager)getSystemService(Context.SENSOR_SERVICE);
    light=mgr.getDefaultSensor(Sensor.TYPE_LIGHT);

    wv=(WebView)findViewById(R.id.webkit);
    wv.getSettings().setJavaScriptEnabled(true);
    wv.addJavascriptInterface(jsInterface, "LIGHT_SENSOR");
    wv.loadUrl("file:///android_asset/index.html");
  }
(from WebKit/SensorPull/app/src/main/java/com/commonsware/android/webkit/bridge/MainActivity.java)

Specifically, we:

Because we are enabling JavaScript, Lint will complain that this poses security risks, so onCreate() has a @SuppressLint annotation for SetJavaScriptEnabled to indicate that we are aware of the risks. Similarly, because we are calling addJavascriptInterface(), Lint will complain that this poses even more security risks. So, @SuppressLint suppresses both the SetJavaScriptEnabled warning and the AddJavascriptInterface warning.

Also, you may notice that there is a significant debate within the Android SDK as to whether the “s” in “JavaScript” gets capitalized or not. In general, it does, but addJavascriptInterface() shipped in API Level 1 with a lowercase “s” in its name, and so that method, and variations of it (e.g., the AddJavascriptInterface annotation) will use a lowercase “s”. Eventually, you just get used to this.

addJavascriptInterface() takes two parameters: a Java object to inject into the JavaScript of the Web page, and a String that is the name by which JavaScript can reference that object. So, we have a jsInterface object that JavaScript can reference via LIGHT_SENSOR.

jsInterface is an instance of JSInterface, a static nested class inside MainActivity:

  private static class JSInterface {
    float lux=0.0f;

    private void updateLux(float lux) {
      this.lux=lux;
    }

    @JavascriptInterface
    public String getLux() {
      return(String.format(Locale.US, "{\"lux\": %f}", lux));
    }
  }
(from WebKit/SensorPull/app/src/main/java/com/commonsware/android/webkit/bridge/MainActivity.java)

It just has a getter and setter around the current light level, which is a float named lux (referring to the unit of brightness used for the values coming from the ambient light sensor). The getter, however, has two interesting traits:

This annotation is required of apps with android:targetSdkVersion set to 17 or higher, though it is a good idea to start using it anyway. With such an android:targetSdkVersion, in an app running on an Android 4.2 or higher device, only public methods with the @JavascriptInterface annotation will be accessible by JavaScript code. On earlier devices, or with an earlier android:targetSdkVersion, all public methods on the JsInterface object would be accessible by JavaScript, including those inherited from superclasses like Object. Note that your build target (i.e., compileSdkVersion in Android Studio) will need to be Android 4.2 or higher in order to reference the @JavascriptInterface annotation.

The reason for returning a JSON object (in string form), rather than just the float, is for two reasons:

  1. For more complex APIs, you cannot pass into JavaScript an arbitrary Java object. All return types from @JavascriptInterface objects need to be something that JavaScript can use, and a simple way to do that is to create data structures in JSON.
  2. float did not seem to work well as a return type, as it always seemed to turn into 0.0 on the JavaScript side, for unknown reasons

We register with the SensorManager to find out when the light level changes, via registerListener() (in onStart()) and unregisterListener() (in onStop()):

  @Override
  protected void onStart() {
    super.onStart();

    mgr.registerListener(this, light, SensorManager.SENSOR_DELAY_UI);
  }

  @Override
  protected void onStop() {
    mgr.unregisterListener(this);

    super.onStop();
  }
(from WebKit/SensorPull/app/src/main/java/com/commonsware/android/webkit/bridge/MainActivity.java)

That, in turn, will trigger a call to onSensorChanged() when the light level changes. There, we pass the light level (the first float out of the values array from the SensorEvent) to the JsInterface instance, ready to be retrieved by the JavaScript code in our Web page:

  @Override
  public void onSensorChanged(SensorEvent sensorEvent) {
    jsInterface.updateLux(sensorEvent.values[0]);
  }

  @Override
  public void onAccuracyChanged(Sensor sensor, int i) {
    // unused
  }
(from WebKit/SensorPull/app/src/main/java/com/commonsware/android/webkit/bridge/MainActivity.java)

In the Web page, we set it up to show the current light level, starting with a value of 0.0. When the user taps on the Light Level caption, we call a pull() JavaScript function, which:

<html>
<head>
<title>Android Light Sensor Demo</title>
<script language="javascript">
  function update_lux(lux) {
    document.getElementById("lux").innerHTML=lux;
  }
  
  function pull() {
    var result=JSON.parse(LIGHT_SENSOR.getLux());

    update_lux(result.lux);
  }
</script>
</head>
<body>
<p><b><a onClick="pull()">Light Level</a>: </b><span id="lux">0.0</span> lux</p>
</body>
</html>

(from WebKit/SensorPull/app/src/main/assets/index.html)

If you run the app, you get our trivial Web page in the WebView:

SensorPull Demo, As Initially Launched
Figure 517: SensorPull Demo, As Initially Launched

Tapping on the words “Light Level” will cause JavaScript to request the light level, updating the page to match:

SensorPull Demo, Showing Light Level
Figure 518: SensorPull Demo, Showing Light Level

Note that this sample app will only work on devices with an ambient light sensor. It is rather likely that the app will crash spectacularly on devices lacking such a sensor.

Unfortunately, addJavascriptInterface() opens up a number of security issues, outlined later in this chapter. Where possible, avoid the use of this API.

Java Calling JavaScript: loadUrl() and evaluateJavascript()

Now that we have seen how JavaScript can call into Java, it would be nice if Java could somehow call out to JavaScript. Well, as luck would have it, we can do that too. This is a good thing, otherwise, this would be a really weak section of the book.

What is unusual is how you call out to JavaScript. One might imagine there would be an evaluateJavaScript() counterpart to addJavascriptInterface(), where you could supply some JavaScript source and have it executed within the context of the currently-loaded Web page.

Actually, there is such a method on Android 4.4. However, earlier versions of Android lacked that method. Instead, on older versions of Android, given your snippet of JavaScript source to execute, you call loadUrl() on your WebView, as if you were going to load a Web page, but you put javascript: in front of your code and use that as the “address” to load.

If you have ever created a “bookmarklet” for a desktop Web browser, you will recognize this technique as being the Android analogue – the javascript: prefix tells the browser to treat the rest of the address as JavaScript source, injected into the currently-viewed Web page.

The WebKit/SensorPush sample project expands upon the SensorPull app. This time, though, in addition to pulling via addJavascriptInterface(), we support pushing light levels as sensor readings come in.

That comes courtesy of a revised onSensorChanged() method:

  @Override
  public void onSensorChanged(SensorEvent sensorEvent) {
    float lux=sensorEvent.values[0];

    jsInterface.updateLux(lux);

    String js=String.format(Locale.US, "update_lux(%f)", lux);

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.KITKAT) {
      wv.evaluateJavascript(js, null);
    }
    else {
      wv.loadUrl("javascript:"+js);
    }
  }
(from WebKit/SensorPush/app/src/main/java/com/commonsware/android/webkit/bridge/MainActivity.java)

Before, we just updated the JsInterface object with the new light level. Now, we also format a JavaScript call to update_lux(), supplying our light level. Then, based on Android OS version (Build.VERSION.SDK_INT), we either call evaluateJavascript() or loadUrl(), the latter also employing the javascript: scheme.

Because we had pulled out update_lux() as a separate function before, our HTML and JavaScript does not need to change at all:

<html>
<head>
<title>Android Light Sensor Demo</title>
<script language="javascript">
  function update_lux(lux) {
    document.getElementById("lux").innerHTML=lux;
  }
  
  function pull() {
    var result=JSON.parse(LIGHT_SENSOR.getLux());

    update_lux(result.lux);
  }
</script>
</head>
<body>
<p><b><a onClick="pull()">Light Level</a>: </b><span id="lux">0.0</span> lux</p>
</body>
</html>

(from WebKit/SensorPush/app/src/main/assets/index.html)

If you run this sample app, you will find that the Web page updates in real time as you wave your hand in front of the light sensor, shine a light on that sensor, etc.

Java Calling JavaScript: WebMessage

Both of those techniques have worked since API Level 1. But, as mentioned, addJavascriptInterface() has security issues. Also, evaluateJavascript() (or its loadUrl() equivalent) requires the Java code to know what functions are available in the Web page. That may tie the Java and JavaScript more tightly than you might like.

Android 6.0 introduced another pair of options for communicating between Java and JavaScript — WebMessage and WebMessagePort — that try to eliminate these issues.

The simpler of the two approaches is WebMessage. Instead of calling evaluateJavascript() or loadUrl(), you create a WebMessage object and call postWebMessage() to deliver it to the JavaScript in your Web page. So, in the WebKit/SensorMessage sample project, we have an updated onSensorChanged() method that does this:

  @Override
  public void onSensorChanged(SensorEvent sensorEvent) {
    float lux=sensorEvent.values[0];

    jsInterface.updateLux(lux);

    String js=String.format(Locale.US, "update_lux(%f)", lux);

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.M) {
      wv.postWebMessage(new WebMessage(jsInterface.getLux()),
        Uri.EMPTY);
    }
    else if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.KITKAT) {
      wv.evaluateJavascript(js, null);
    }
    else {
      wv.loadUrl("javascript:"+js);
    }
  }
(from WebKit/SensorMessage/app/src/main/java/com/commonsware/android/webkit/bridge/MainActivity.java)

The WebMessage constructor that we are using here takes a simple string that is the content of the message. In this case, it is the JSON object wrapping our light level in lux, using the same getLux() method that JavaScript can call on our JsInterface instance that we registered via addJavascriptInterface().

postWebMessage() takes two parameters. The first is the WebMessage to deliver to the page. The other is supposed to be the Uri of the Web page. This is supposed to be used to confirm that you are sending the message to the page that you think you are sending it to.

Unfortunately, this is not behaving especially well.

It only works as advertised for http/https URLs, or for data that you load using loadDataWithBaseURL() and supply some http or https URL. If you load from a file URL, as we are doing here, you cannot use the actual URL. Instead, you have to use Uri.EMPTY, which is a “wildcard” that skips over this test, which is what we use here. Apparently, this is all working as intended.

To receive these messages, our JavaScript needs to define an onmessage() global function:

<html>
<head>
<title>Android Light Sensor Demo</title>
<script language="javascript">
  function update_lux(lux) {
    document.getElementById("lux").innerHTML=lux;
  }

  function parse(json) {
    var result=JSON.parse(json);

    update_lux(result.lux);
  }
  
  function pull() {
    parse(LIGHT_SENSOR.getLux());
  }

  onmessage = function (e) {
    parse(e.data);
  }
</script>
</head>
<body>
<p><b><a onClick="pull()">Light Level</a>: </b><span id="lux">0.0</span> lux</p>
</body>
</html>

(from WebKit/SensorMessage/app/src/main/assets/index.html)

This receives the HTML Web message equivalent of the WebMessage that we posted. The data field on the supplied event object (e in the sample) contains our string. So, we turn around and parse() it, just as we would parse() the JSON we got from calling getLux() on our LIGHT_SENSOR.

If you run this sample app on an Android 6.0+ device, you should get the same results as with SensorPush, where the light level changes automatically. However, in this case, we will be using code that relies upon WebMessage and postWebMessage(), instead of evaluateJavascript() or loadUrl(). In particular, our Java code does not need to know anything about the internal workings of the JavaScript (e.g., function names) — it just passes over the message and relies on the JavaScript to have registered itself appropriately to receive the message.

JavaScript Calling Java: WebMessagePort

What would be nice is to use this WebMessage system to be able to replace addJavascriptInterface() and allow JavaScript to call back into Java. This is possible, but it is fairly complex.

For our Java code to receive messages sent to it from JavaScript, we need to do three things:

  1. Call createWebMessageChannel() on the WebView. This creates a private communications channel between us and our target Web page. It returns a two-element WebMessagePort array. Index 0 of that array is our end of the channel; index 1 is the JavaScript end of the channel.
  2. Call setWebMessageCallback() on our WebMessagePort, supplying a WebMessageCallback that will be called with onMessage() when a message arrives on the port from JavaScript.
  3. Send the other WebMessagePort to JavaScript using a WebMessage.

In the WebKit/SensorPort sample project, this is handled by an initPort() method:

  @TargetApi(Build.VERSION_CODES.M)
  private void initPort() {
    final WebMessagePort[] channel=wv.createWebMessageChannel();

    port=channel[0];
    port.setWebMessageCallback(new WebMessagePort.WebMessageCallback() {
      @Override
      public void onMessage(WebMessagePort port, WebMessage message) {
        postLux();
      }
    });

    wv.postWebMessage(new WebMessage("", new WebMessagePort[]{channel[1]}),
          Uri.EMPTY);
  }
(from WebKit/SensorPort/app/src/main/java/com/commonsware/android/webkit/bridge/MainActivity.java)

The WebMessage constructor that we use this time takes two parameters: an arbitrary string (here, just set to "", as we are not using it) and a one-element WebMessagePort array containing the JavaScript end of the communications channel.

However, we cannot do any of this work until the Web page is ready to be used. Otherwise, the JavaScript code will not receive our WebMessage, since it is not yet ready.

So, we have to postpone calling initPort() until a WebViewClient is called with onPageFinished(), as we do in onCreate():

  @SuppressLint({"AddJavascriptInterface", "SetJavaScriptEnabled"})
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main);

    mgr=(SensorManager)getSystemService(Context.SENSOR_SERVICE);
    light=mgr.getDefaultSensor(Sensor.TYPE_LIGHT);

    wv=(WebView)findViewById(R.id.webkit);
    wv.getSettings().setJavaScriptEnabled(true);
    wv.addJavascriptInterface(jsInterface, "LIGHT_SENSOR");

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.M) {
      wv.setWebViewClient(new WebViewClient() {
        @Override
        public void onPageFinished(WebView view, String url) {
          initPort();
        }
      });
    }

    wv.loadUrl(URL);
  }
(from WebKit/SensorPort/app/src/main/java/com/commonsware/android/webkit/bridge/MainActivity.java)

Our WebMessageCallback, upon receipt of a WebMessage from the JavaScript, calls a postLux() method. We are just using the existence of the message as a “ping” from the JavaScript to Java, asking for us to send it the ambient light level. So, in postLux(), we create a WebMessage and send it to JavaScript… but not via the postWebMessage() method on WebView. Instead, we use our end of the WebMessagePort communications channel, calling postMessage() on it, in a postLux() method:

  @TargetApi(Build.VERSION_CODES.M)
  private void postLux() {
    port.postMessage(new WebMessage(jsInterface.getLux()));
  }
(from WebKit/SensorPort/app/src/main/java/com/commonsware/android/webkit/bridge/MainActivity.java)

So, when the JavaScript sends a WebMessage to Java, Java sends a WebMessage right back, supplying the light level JSON.

To prove that this is working, this sample comments out the automated push of the light level in onSensorChanged() — ordinarily, we would call postLux() to push over the light level when we get it:

  @Override
  public void onSensorChanged(SensorEvent sensorEvent) {
    float lux=sensorEvent.values[0];

    jsInterface.updateLux(lux);

    String js=String.format(Locale.US, "update_lux(%f)", lux);

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.M) {
      // postLux();
    }
    else if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.KITKAT) {
      wv.evaluateJavascript(js, null);
    }
    else {
      wv.loadUrl("javascript:"+js);
    }
  }
(from WebKit/SensorPort/app/src/main/java/com/commonsware/android/webkit/bridge/MainActivity.java)

In JavaScript, our onmessage global function is now a bit more complex as well. We get our end of the communications channel by retrieving our port from ports on the event delivered to onmessage(). Then, we register an onmessage function on that port, which is how we receive the light levels that Java delivers via postMessage() on its WebMessagePort. When the user taps the label, we call a pull() function in JavaScript, that calls postMessage() on the port, supplying some string as a message (here, hardcoded as "ping" and ignored by our Java code):

<html>
<head>
<title>Android Light Sensor Demo</title>
<script language="javascript">
  function update_lux(lux) {
    document.getElementById("lux").innerHTML=lux;
  }

  function parse(json) {
    var result=JSON.parse(json);

    update_lux(result.lux);
  }

  var port;

  function pull() {
    port.postMessage("ping");
  }

  onmessage = function (e) {
    port = e.ports[0];

    port.onmessage = function (f) {
      parse(f.data);
    }
  }
</script>
</head>
<body>
<p><b><a onClick="pull()">Light Level</a>: </b><span id="lux">0.0</span> lux</p>
</body>
</html>

(from WebKit/SensorPort/app/src/main/assets/index.html)

If you run this sample on Android 6.0+, and you tap the “Light Level” label, you will get the light level, delivered by means of our WebMessagePort-based communications channel.

(NOTE: the author would like to thank Diego Torres Milano for his assistance in finding out how this stuff works).

Navigating the Waters

There is no navigation toolbar with the WebView widget. This allows you to use it in places where such a toolbar would be pointless and a waste of screen real estate. That being said, if you want to offer navigational capabilities, you can, but you have to supply the UI. WebView offers ways to perform garden-variety browser navigation, including:

Settings, Preferences, and Options (Oh, My!)

With your favorite desktop Web browser, you have some sort of “settings” or “preferences” or “options” window. Between that and the toolbar controls, you can tweak and twiddle the behavior of your browser, from preferred fonts to the behavior of JavaScript.

Similarly, you can adjust the settings of your WebView widget as you see fit, via the WebSettings instance returned from calling the widget’s getSettings() method.

There are lots of options on WebSettings to play with. Most appear fairly esoteric (e.g., setFantasyFontFamily()). However, here are some that you may find more useful:

Security and Your WebView

More so than normal widgets, WebView opens up potential security issues, just as a Web browser could. If all you are doing is displaying your own content, the risks are minimal. If, on the other hand, you are displaying content from third parties, it is possible that their content is malicious in a way that can compromise your app’s security, to your users’ detriment.

Rogue JavaScript Risks

If you call setJavaScriptEnabled(true) on your WebSettings, you are allowing JavaScript code to be loaded and executed by WebView. In many cases, this is essential to get your content to render properly (e.g., the JavaScript is issuing AJAX calls). However, if you did not write the scripts, you do not know what they might be doing. If there are flaws in WebView — such as those discussed in the next sections — then your users may be at risk.

Even in the absence of such bugs, JavaScript can always:

The addJavascriptInterface() Bugs

Another way that rogue JavaScript can attack users is if you use addJavascriptInterface() to allow JavaScript code to call out to a Java object that you supply.

As was noted earlier in this chapter, when addJavascriptInterface() was introduced, there is this @JavascriptInterface annotation that we should apply to the methods we want JavaScript to be able to call on the object we supply via addJavascriptInterface(). This is because of a bug in the addJavascriptInterface() implementation, whereby on 4.1 and below any method on the Java object could be called by JavaScript. This includes methods like getClass()… which in turn would allow JavaScript to use Class.forName() to get at arbitrary stuff. This was used by various bits of malware.

Hence, using addJavascriptInterface() on Android 4.1 and below is rather risky, if you are loading arbitrary third-party JavaScript. If you have the means of examining that JavaScript (e.g., you are loading the scripts yourself), you might perform some simple scans of it to see if they appear to be doing anything unfortunate with your Java object that you injected into JavaScript via addJavascriptInterface().

Worse, Android sometimes also injects its own objects, without our requesting them.

In particular, this security bug points out that, through Android 4.3, if users have enabled an accessibility service, Android automatically injects objects into WebView, using addJavascriptInterface(), named accessibility and accessibilityTraversal. So, even if you do not inject any objects yourself via addJavascriptInterface(), your WebView may be at risk. The security researchers who uncovered this attack vector suggest using removeJavascriptInterface() to specifically get rid of those objects.

The Same-Origin Policy Bug

Due to a bizarre bug in the parsing of URLs, it is possible for JavaScript code to violate the “same-origin policy” of a WebView on Android 4.3 and earlier.

Quoting Wikipedia from September 2014:

the same-origin policy is an important concept in the web application security model. The policy permits scripts running on pages originating from the same site — a combination of scheme, hostname, and port number — to access each other’s DOM with no specific restrictions, but prevents access to DOM on different sites… This mechanism bears a particular significance for modern web applications that extensively depend on HTTP cookies to maintain authenticated user sessions, as servers act based on the HTTP cookie information to reveal sensitive information or take state-changing actions. A strict separation between content provided by unrelated sites must be maintained on the client side to prevent the loss of data confidentiality or integrity.

All modern Web browsers implement the same-origin policy (SOP)… but there can be bugs. A security researcher disclosed that the original AOSP Browser application failed to implement the SOP properly, when a javascript: URL has a null byte before the j in javascript. And while the report was focused on the AOSP Browser app, the problem really lies with WebView.

To see this in action, load https://commonsware.com/misc/sop-demo.html in the AOSP Browser app on Android 4.3 or lower. This Web page consists of:


<html>
<head>
<title>WebView SOP Test</title>
</head>
<body>
<h1>WebView SOP Test</h1>
<iframe name="test" src="http://developer.android.com"></iframe>
<input type=button value="test" onclick="window.open('\u0000javascript:alert(document.domain)','test')">
</body>
</html>

It is derived from a similar example found in the blog post outlining the security flaw.

In an SOP-compliant browser, clicking the button will have no effect. In the AOSP Browser app, clicking the button shows the domain name of the document in the iframe. And, loading this HTML into a WebView has the same effect.

Part of the WebView overhaul in Android 4.4 — replacing the original implementation with a new one backed by Chromium — had the effect of fixing this bug, whether intentionally or inadvertently.

There is no obvious mitigation approach for this bug, insofar as for the attack shown above, none of the callbacks on WebViewClient or WebChromeClient seem to allow us to intercept this URL before its JavaScript is executed. If you are loading HTML yourself from a third party, you might consider scanning that HTML for obvious signs of the attack (e.g., regular expression check for \u0000javascript), but that will be limited at best. Beyond that, try to limit the content in a WebView to be from only one origin, so that there is nothing for attackers to obtain via this bug.

Also note that the security researcher who found this bug has also found another SOP violation, suggesting that mitigation strategies may be impractical.

Android 8.0 WebView Changes

WebView, as Android developers use it, is really a facade API, as of Android 5.0. Previously, the WebView implementation was part of the Android framework. Nowadays, the implementation is delegated to the Android System WebView, which allows Google to update the WebView implementation without relying upon manufacturers to distribute firmware updates.

Hence, to some extent, WebView continuously changes, as the Android System WebView app gets updated a few times per year.

However, it is only in a new version of Android that Google changes the API or the general approach taken by a WebView implementation. In Android 8.0, two significant changes arrived: multi-process mode and support for banning of cleartext traffic.

Multi-Process Mode

Historically, WebView code ran in your app’s regular process. Because we often load JavaScript into a WebView, having the WebView in our process would raise the risks of WebView bugs. If JavaScript could cause arbitrary code to execute in our own process, it would have whatever rights our own application code does, including everything for which we have runtime permissions.

Android 7.0 added a developer option for moving the WebView execution to a separate process, and that is now standard on Android 8.0. As the documentation describes it, “Web content is handled in a separate, isolated process from the containing app’s process for enhanced security.”

In principle, this should not require code changes. However, this is a significant architectural change, and so it is worthwhile to fully test your WebView usage on Android 8.0+, to make sure nothing breaks compared with that same code running on Android 7.1 or older devices.

Honors Cleartext Traffic Setting

If your app has a targetSdkVersion over 25, and in your network security configuration you banned cleartext traffic, WebView will honor that setting on Android 8.0+. On older devices, WebView ignores the network security configuration, and it appears that it still ignores the rest of the configuration (e.g., certificate pinning).

However, this allows you to block the accidental transmission of data over unencrypted channels. Most apps doing network I/O should ban cleartext traffic, at least for debug builds, to identify where they are accidentally using a plain http URL. Apps for which security is a priority might also ban cleartext traffic for release builds.

The WebKit/BrowserSecure sample project demonstrates this. It has a network security configuration XML file that bands cleartext traffic:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
  <base-config cleartextTrafficPermitted="false">
    <trust-anchors>
      <certificates src="system" />
    </trust-anchors>
  </base-config>
</network-security-config>
(from WebKit/BrowserSecure/app/src/main/res/xml/net_security_config.xml)

It applies that configuration in the manifest via the android:networkSecurityConfig attribute on the <application> element:

  <application
    android:allowBackup="false"
    android:icon="@drawable/ic_launcher"
    android:label="@string/app_name"
    android:networkSecurityConfig="@xml/net_security_config">
    <activity android:name=".BrowserDemo1">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
  </application>
(from WebKit/BrowserSecure/app/src/main/AndroidManifest.xml)

The activity just loads up a plain HTTP-served Web page:

package com.commonsware.android.browser1;

import android.app.Activity;
import android.os.Bundle;
import android.webkit.WebView;

public class BrowserDemo1 extends Activity {
  WebView browser;
  
  @Override
  public void onCreate(Bundle state) {
    super.onCreate(state);
    setContentView(R.layout.main);

    browser=(WebView)findViewById(R.id.webkit);
    browser.loadUrl("http://www.andglobe.com");
  }
}
(from WebKit/BrowserSecure/app/src/main/java/com/commonsware/android/browser1/BrowserDemo1.java)

The project is set up to target 26:

apply plugin: 'com.android.application'

android {
    compileSdkVersion 25
    buildToolsVersion '26.0.2'

    defaultConfig {
        minSdkVersion 25
        targetSdkVersion 26
        applicationId 'com.commonsware.android.browser.secure'
    }
}
(from WebKit/BrowserSecure/app/build.gradle)

If you change that to have it target 25, the app will run “normally” on Android 8.0, showing the designated Web page. But, with a targetSdkVersion set at 26, the WebView will not show that page, as it would be loaded over a cleartext (HTTP) connection:

BrowserSecure Sample, Showing Banned Cleartext Traffic
Figure 519: BrowserSecure Sample, Showing Banned Cleartext Traffic

Chrome Custom Tabs

Chrome custom tabs serve as middle ground between using a WebView in your own app and launching a URL into a separate Web browser.

With a WebView, you have complete control over the overall user experience within your app. However, your WebView is decoupled from any other browser the user may be using on the device. Conversely, launching URLs into the user’s chosen browser gives the user their normal browsing experience, but you have no control over the user experience, as the user is now in the browser app, not your app.

With Chrome custom tabs, while Chrome is handling the URL, it will allow you limited control over the action bar (color and custom actions). It also simplifies some things that you might otherwise have had to handle yourself, such as pre-fetching a Web page to be able to quickly switch to it. Basic integration is also fairly easy, coming in the form of extras on the same sort of ACTION_VIEW Intent that you might have used for launching the URL in a standalone browser.

At the same time, there are some concerns: