Browser Compatibility Testing with BrowserStack and JSBin

There are some great services out there that go a long way to outline browser compatibility of JavaScript, HTML, and CSS features. Can I Use? is one of the most thorough, and kangax’s ES5 and ES6 compatibility table are extremely valuable and regularly updated. The Mozilla Developer Network also has a browser support matrix for almost every feature that is documented, but it is a public wiki and a lot of the information comes from the previously mentioned sources. Outside of those resources, the reliability starts dropping quickly.

What if the feature you’re looking for isn’t popular enough to be well documented? Maybe you’re looking for confirmation of some combination of esoteric behaviors that isn’t documented anywhere?

BrowserStack & JSBin

BrowserStack has a service that allows you to take screenshots across a massive number of browsers. Services like these are often used to quickly verify that sites look the same or similar across a wide array of browsers and devices.

While that may not sound wildly exciting for our use case, we can easily leverage a service like jsbin to whip up a test case that exposes the pass or failure in a visually verifiable way.

Using a background color on the body (red for unsupported, green for supported) we can default an html body to the ‘unsupported’ class and, upon a successful test for our feature, simply change the class of the body to ‘supported’. For example, we recently wanted to test the browser support for document.currentScript, a feature that MDN says is not well supported but according to actual, real world chrome and IE/spartan developers, is widely supported and has been for years!

Well we had to know, so we put together a jsbin to quickly test this feature.


<html>
<head>
  <meta charset="utf-8">
  <title>JS Bintitle>
  <style>
    .unsupported{
      background-color:red;
    }
    .supported{
      background-color:green;
    }
  style>
head>
<body class=unsupported>
  <script id=foo>script>
  <script id=target>
    if (document.currentScript && document.currentScript.id === 'target')
      document.body.className = 'supported';
  script>
  <script id=bar>script>
body>
html>

After the bin is saved and public, we select a wide range of browser we wanted to test in browserstack and pass it the output view of the JSBin snippet. In this case we wanted to test way back to IE6, a range of ios and android browsers, opera, safari, and a random sampling of chrome and firefox.

browserstackss

After a couple minutes, we get our results back from BrowserStack and we can immediately see what the support matrix looks like.

browserstackresults

This binary pass/fail approach may not scale too well but you could get creative and produce large text, a number, or even an image that is easily parsable at varying resolutions, like a QR code, to convey test results. The technique is certainly valuable for anyone that doesn’t have an in-house cluster of devices, computers, and browsers at their disposal simply for browser testing. Credit goes to Michael Ficarra (@jspedant) for the original idea.

Oh, and by the way, document.currentScript is definitely not widely supported.

Detecting PhantomJS Based Visitors

These days, many web security incidents involve automation. Web-scraping, password reuse, and click-fraud attacks are perpetrated by adversaries trying to mimic real users, and thus will attempt to look like they are coming from a browser. As a website owner, you want to ensure you serve humans, and as a web service provider you want programmatic access to your content to go through your API instead of being scraped through your heavier and less stable web interface.

Assuming that you have basic checks for cURL-like visitors, the next reasonable step is to ensure that visitors are using real, UI-driven browsers — and not headless browsers like PhantomJS and SlimerJS.

In this article, we’re going to demonstrate some techniques for identifying visits by PhantomJS. We decided to focus on PhantomJS because it is the most popular headless browser environment, but many of the concepts that we’ll cover are applicable to SlimerJS and other tools.

NOTE: The techniques presented in this article are applicable to both PhantomJS 1.x and 2.x, unless explicitly mentioned. First up: is it possible to detect PhantomJS without even responding to it?

HTTP stack

As you may be aware, PhantomJS is built on the Qt framework. The way Qt implements the HTTP stack makes it stick out from other modern browsers.

First, let’s take a look at Chrome, which sends out the following headers:

GET / HTTP/1.1
Host: localhost:1337
Connection: keep-alive
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36
Accept-Encoding: gzip, deflate, sdch
Accept-Language: en-US,en;q=0.8,ru;q=0.6

In PhantomJS, however, the same HTTP request looks like this:

GET / HTTP/1.1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/534.34 (KHTML, like Gecko) PhantomJS/1.9.8 Safari/534.34
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Connection: Keep-Alive
Accept-Encoding: gzip
Accept-Language: en-US,*
Host: localhost:1337

You’ll notice the PhantomJS headers are distinct from Chrome (and, as it turns out, all other modern browsers) in a few subtle ways:

  • The Host header appears last
  • The Connection header value is mixed case
  • The only Accept-Encoding value is gzip
  • The User-Agent contains “PhantomJS”

Checking for these HTTP header aberrations on the server, it should be possible to identify a PhantomJS browser.

But, is it safe to believe these values? If an adversary uses a proxy to rewrite headers in front of the headless browser, they could modify those headers to appear like a normal modern browser instead.

Looks like tackling this problem purely on the server is not a silver bullet. So let’s take a look at what can be done on the client, using PhantomJS’s JavaScript environment.

Client-side User-Agent Check

We may not be able to trust the User-Agent value as delivered via HTTP, but what about on the client?

if (/PhantomJS/.test(window.navigator.userAgent)) {
    console.log("PhantomJS environment detected.");
}

Unfortunately, it is similarly trivial to change user-agent header and navigator.userAgent values in PhantomJS, so this might not be enough.

Plugins

navigator.plugins contains an array of plugins that are present within the browser. Typical plugin values include Flash, ActiveX, support for Java applets, and the “Default Browser Helper”, which is a plugin that indicates whether this browser is the default browser in OS X. In our research, most fresh installs of common browsers include at least one default plugin — even on mobile.

This is unlike PhantomJS, which doesn’t implement any plugins, nor does it provide a way to add one (using the PhantomJS API).

The following check might then be useful:

if (!(navigator.plugins instanceof PluginArray) || navigator.plugins.length == 0) {
    console.log("PhantomJS environment detected.");
} else {
    console.log("PhantomJS environment not detected.");
}

On the other hand, it’s fairly trivial to spoof this plugin array by modifying the PhantomJS JavaScript environment before the page is loaded.

It’s also not difficult to imagine a custom build of PhantomJS with real, implemented plugins. This is easier than it sounds because the Qt framework on which PhantomJS is built provides a native API for implementing plugins.

Timing

Another point of interest is how PhantomJS suppresses JavaScript dialogs:

var start = Date.now();
alert('Press OK');
var elapse = Date.now() - start;
if (elapse < 15) {
    console.log("PhantomJS environment detected. #1");
} else {
    console.log("PhantomJS environment not detected.");
}

After measuring several times, it appears that if the alert dialog is suppressed within 15 milliseconds, the browser is probably not being controlled by a human. But using this approach means bothering real users with an alert they’ll manually have to close.

Global Properties

PhantomJS 1.x exposes two properties on the global object:

if (window.callPhantom || window._phantom) {
  console.log("PhantomJS environment detected.");
} else {
  console.log("PhantomJS environment not detected.");
}

However, these properties are part of an experimental feature and may change in the future.

Lack of JavaScript Engine Features

PhantomJS 1.x and 2.x currently use out-of-date WebKit engines, which means there are browser features that exist in newer browsers that do not exist in PhantomJS. This extends to the JavaScript engine — whereby some native properties and methods are different or absent in PhantomJS.

One such method is Function.prototype.bind, which is missing in PhantomJS 1.x and older. The following example checks whether bind is present, and that it has not been spoofed in the executing environment.

(function () {
  if (!Function.prototype.bind) {
    console.log("PhantomJS environment detected. #1");
    return;
  }
  if (Function.prototype.bind.toString().replace(/bind/g, 'Error') != Error.toString()) {
    console.log("PhantomJS environment detected. #2");
    return;
  }
  if (Function.prototype.toString.toString().replace(/toString/g, 'Error') != Error.toString()) {
    console.log("PhantomJS environment detected. #3");
    return;
  }
  console.log("PhantomJS environment not detected.");
})();

This code is a little too tricky to explain in detail here, but you can find out more from our presentation.

Stack Traces

Errors thrown by JavaScript code evaluated by PhantomJS via the evaluate command contain a uniquely identifiable stack trace, from which we can identify the headless browser.

For example, suppose that PhantomJS calls evaluate on the following code:

var err;
try {
  null[0]();
} catch (e) {
  err = e;
}
if (indexOfString(err.stack, 'phantomjs') > -1) {
  console.log("PhantomJS environment detected.");
} else {
  console.log("PhantomJS environment is not detected.");
}

Note that this example uses a custom indexOfString() function, left as an exercise for the reader, since the native String.prototype.indexOf can be spoofed by PhantomJS to always return a negative result.

Now, how do you get a PhantomJS script to evaluate this code? One technique is to override some frequently used DOM API functions that are likely to be called. For example, the code below overrides document.querySelectorAll to inspect the browser’s stack trace:

var html = document.querySelectorAll('html');
var oldQSA = document.querySelectorAll;
Document.prototype.querySelectorAll = Element.prototype.querySelectorAll = function () {
  var err;
  try {
    null[0]();
  } catch (e) {
    err = e;
  }
  if (indexOfString(err.stack, 'phantomjs') > -1) {
    return html;
  } else {
    return oldQSA.apply(this, arguments);
  }
};

Summary

In this article we’ve looked at 7 different techniques for identifying PhantomJS, both on the server and by executing code in PhantomJS’s client JavaScript environment. By combining the detection results with a strong feedback mechanism — for example, rendering a dynamic page inert, or invalidating the current session cookie — you can introduce a solid hurdle for PhantomJS visitors. Always keep in mind however, that these techniques are not infallible, and a sophisticated adversary will get through eventually.

To learn more, we recommend watching this recording of our presentation from AppSec USA 2014 (slides). We’ve also put together a GitHub repository of example implementations — and possible circumventions — of the techniques presented here.

Thanks for reading, and happy hunting.

Contributors