Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Cross-Origin-Opener-Policy: preventing attacks from popups: Understanding cross-origin security headers - Part 1

$
0
0

In this post I describe the same-origin policy and describe how, despite its protections, your site could leak information via side-channel vectors in the web platform APIs. I then describe how you can use the Cross-Origin-Opener-Policy (COOP) header to mitigate one such vulnerability that is available using the window.open() and window.opener APIs. I describe which values provide isolation in which scenarios, how they differ between opener and popup, and which values you should use depending on your requirements.

What is an origin?

Before we can go any further, it's first important to clarify what an "origin" is from a browser's point of view. The origin of two URLs are considered to be the same if they

  • Have the same scheme i.e. http or https
  • Have the same domain i.e. example.com, andrewlock.net or microsoft.com
  • Have the same subdomain i.e. www.
  • Have the same port (which may be implicit) i.e. port 80 for http and 443 for https

Note that the path of a URL, i.e. /some/path in the URL http://www.example.org/some/path is not part of the origin, neither is the querystring (?key=value) or a fragment (#someid).

But essentially, everything before the path in the URL must match for two URLs to be considered "same-origin". The only subtlety to that statement is that the port may be implicit (port 80 for http and 443 for https) or explicit, and the sites are still considered same-origin.

There is a similar, less-strict, concept called "same-site" that is used by same-site cookies, among other things.

The table below compares URLs to the origin http://www.example.org so you can clearly see the difference between same-origin and cross-origin:

URLDescriptionsame-origin
http://www.example.orgIdentical URL
http://www.example.org:80Identical URL (implicit port)
http://www.example.org:5000Different port
http://example.orgDifferent subdomain
http://sub.example.orgDifferent subdomain
https://www.example.orgDifferent scheme
http://www.example.evilDifferent TLD

When two sites have the same origin, they are termed "same-origin"; when they have different origins, they are "cross-origin".

Same-origin policy restrictions

The same-origin policy is a key security mechanism used by browsers to restrict how resources loaded from one origin can interact with resources loaded from another origin.

One of the most common interactions people first run into is when you're sending cross-origin requests, as many of these are blocked unless you enable Cross Origin Resource Sharing (CORS). These restrictions come into play when you're making fetch() requests in JavaScript for example.

Most of the time, developers interacting with cross-origin documents are trying to do completely legitimate things, and the same-origin policy can seem very restrictive. But the same-origin policy is vital for isolating your site from malicious sites. Without it, your website would be extremely vulnerable!

However, there are also cross-origin restrictions at the JavaScript API-level that don't involve fetch(). On the face of it, that might sound a bit confusing—how can you interact with a different document using JavaScript without making requests? There are several APIs that allow you to do this, which generally provide a reference to the Window object for a different document. For example:

  • iframe.contentWindow returns the Window object of an iFrame.
  • window.parent returns the Window object of the parent window (when called from an iFrame for example).
  • window.open() allows you to open a new window, whether in a popup or a new tab, and returns a reference to the Window object of the opened document.
  • window.opener provides a reference to the Window object of the document that opened this one. This is available when the current document was opened via a window.open() call, or by clicking a link that has a target attribute.

When the documents are same-origin, then you have direct access to the other Window but in cross-origin scenarios, you can only access a limited number of properties and methods, for example:

  • window.close()
  • window.focus()
  • window.postMessage()
  • window.length
  • window.closed (read-only)
  • window.opener (read-only)
  • window.frames (read-only)

The exact available properties and methods vary by browser, but you can find a more complete list here. Nevertheless, even though cross-origin interactions are limited to this small subset, there are attacks that can use these APIs to leak information across contexts.

Leaking information across origins

One class of vulnerabilities, often called cross-site leaks (aka XS-Leaks), attack the side-channels built into the web platform to infer information about other sites. There have been some high-profile attacks, such as Meltdown and Spectre which leverage hardware bugs, but there are also leaks that come directly from the browser APIs.

One source of information comes via the window.length property which returns the number of frames (iframes) that are in the document. This seems like a very benign property, but if this number changes based on properties of the user, then that's a source of information that could be abused by an attacker. As a concrete example, Facebook patched such a bug that could be used to leak all sorts of information about the user (as described in this post).

There are many other possible sources of information leaks (described in detail at https://xsleaks.dev/), but it's clear that even having mostly-readonly access to a cross-origin document is enough to expose yourself to vulnerabilities. To work around this, browsers introduced the Cross-Origin-Opener-Policy header, to give you additional restrictions.

Protecting against window.open attacks using Cross-Origin-Opener-Policy

Cross-Origin-Opener-Policy (COOP) is a security header that you can return in your HTTP responses, which enables additional protections for your site when you call window.open, or when a different document calls window.open to open your site.

The Cross-Origin-Opener-Policy (COOP) header currently has three possible values:

  • unsafe-none—the default, unsafe value.
  • same-origin—the most safe value
  • same-origin-allow-popups—a middle-ground, which provides some protections.

I'll describe how each of these headers impact the behaviour of the JavaScript APIs shortly, but a word of warning, the language can get confusing, as we're describing the available interactions between two different sites:

  • The opener—this is the site that called window.open(). This API returns a Window object which references the opened site
  • The opened—this is the site that was opened. It can reference the Window object of the opener using the window.opener property.

Both the opener and opened sites could set different values for COOP, and the combination of the two controls whether the two documents are in the same browsing context or not. When documents are in different browsing contexts, then

  • window.opener in the opened document returns null, instead of the Window reference to the opener.
  • In the opener, the value returned by window.open() returns "default" values for some of the properties, such as window.closed

Not that these restrictions are in addition to the same-origin restrictions that also apply.

There's a lot of moving pieces here, so the follow sections describe various scenarios.

Testing Cross-Origin-Opener-Policy with multiple scenarios

To test out the impact of the COOP header, I created two simple ASP.NET Core web apps which serve HTML pages.

  • Web app 1, hosted at http://localhost:5011, serves an Index.html document which contains a single button, which runs window.open() to open a popup in web app 2.
  • Web app 2, hosted at http://localhost:5005, serves a Popup.html document, which attempts to read window.opener.

The apps are nothing fancy, just basic HTML that shows the value of the Cross-Origin-Opener-Policy used to serve the page and a button to click:

The initial opener page

Each of the apps runs a small amount of JavaScript to test the isolation of the site. In the opener, web app 1 reads the value of window.closed; in the isolated scenario, this will always return true, whereas it will return the "correct" value when the documents are in a shared context:

// Add a button click handler
document.getElementById('open_btn')
    .addEventListener('click', () => {
        // Open the popup in a new window
        const opened = window.open('http://localhost:5005/popup', '_blank', 'popup=true');

        // Check whether we have the correct value for opened.closed
        document.getElementById('opener_ele')
            .innerHTML = 'Opened window - reference object <code>opened.closed</code> is ' + (opened.closed
                ? '<span style="color:red">TRUE</span>'
                : '<span style="color:green">FALSE</span>');
});

On the popup (opened) side, we check the value of window.opener. In the isolated context, this will be null, while in the shared context scenario it will be the standard Window object:

document.getElementById('opener_ele')
    .innerHTML = '<code>window.opener</code> object is ' +
        (window.opener
            ? '<span style="color:green">AVAILABLE</span>'
            : '<span style="color:red">UNAVAILABLE</span>')

For each app I used my NetEscapades.AspNetCore.SecurityHeaders NuGet package to configure the Cross-Origin-Opener-Policy for each web app with one of the three possible values. In the following sections we look at how the header impacts the behaviour of the apps.

The impact of Cross-Origin-Opener-Policy on same-origin communication

We'll start with the easy case, when both the opener and opened documents have the same origin, both running on http://localhost:5011. In this scenario, there are no same-origin restrictions, regardless of what Cross-Origin-Opener-Policy you apply to either the opener or the popup.

In this scenario, window.closed returns the correct value in the opener (false) and the window.opener value returns the "real" Window object to the popup. This happens even with the most-restrictive header value of same-origin:

The initial opener page

Each page can interact completely unrestricted with the other, because they're both on the same origin. Where things get interesting is when the pages are cross origin.

The impact of Cross-Origin-Opener-Policy on cross-origin communication

In this scenario, a website opens a page on a different origin.

We'll again start with the easiest scenario, where both sites are using Cross-Origin-Opener-Policy: unsafe-none (or, more likely, not returning the header at all). In this scenario, there are no additional restrictions over the normal same-site restrictions. That means:

  • window.opener is available in the popup
  • opened.closed reflects the correct value in the opener document

In cross-origin scenarios where both have unsafe-none, there are no additional restrictions

This scenario is the least secure one. Remember, if there's a malicious site, it could be either the site doing the opening, or it could be the site being opened in the popup (if your site is tricked into opening it). So although there are still same-origin restrictions for accessing the other site's Window object, the side channel vulnerabilities are unmitigated.

At the other end of the scale, if either the opener or the opened/popup document return Cross-Origin-Opener-Policy: same-origin, the result is the same:

  • window.opener is null in the popup
  • opened.closed always returns true in the opener document

The image below shows this in action: the opened.closed property returns true even though the popup is clearly open, while in the popup window.opener is null:

In cross-origin scenarios where both have unsafe-none, there are no additional restrictions

Not that you get the same behaviour when either of the sites uses same-origin, regardless of what the other site uses. Whether or not the sites share a browsing context is a binary decision, so if either of the sites opts out of sharing a context, the result is the same.

The final option to consider is same-origin-allow-popups, which is more subtle:

  • When returned by the opened/popup site, it behaves exactly like same-origin, i.e. isolated browsing contexts.
  • When returned by the opener, then if the popup returns unsafe-none (or doesn't return a COOP header), they will share browsing context. If the popup returns anything else, then they're isolated.

So in the following, the opener has same-origin-allow-popups while the popup has unsafe-none, and you can see they are sharing a context, as the values are correct and available:

If the opener has same-origin-allow-popups and the popup has unsafe-none, they share a browsing context

Whereas in the following, the popup does not have unsafe-none (instead it has same-origin-allow-popups), so they don't share a browsing context, and the objects are blocked:

If the opener has same-origin-allow-popups but the popup does not have unsafe-none, they don't share a browsing context

As a reminder, if the popup has same-origin-allow-popups, this is treated the same as same-origin, so even if the opener has unsafe-none, they do not share a browsing context

If the opener has unsafe-none and the popup has same-origin-allow-popups, they don't share a browsing context

I'm not going to lie, I found it all quite confusing trying to understand the matrix of possibilities. However, if you consider just the cross-origin scnario, you can narrow the options down to a 3×3 table of options. In the table below, "Shared ✔" means that the window.opener and opened values are available, because the sites share a browsing context. In the "Isolated ❌" case, the sites are isolated.

Opened Cross-Origin-Opener-Policy
unsafe-none / missingsame-origin-allow-popupssame-origin
Opener
COOP
unsafe-none / missingShared ✔Isolated ❌Isolated ❌
same-origin-allow-popupsShared ✔Isolated ❌Isolated ❌
same-originIsolated ❌Isolated ❌Isolated ❌
The possible states of the browsing context when using the Cross-Origin-Opener-Policy header in a cross-origin scenario

What Cross-Origin-Opener-Policy value should I use?

So if you've come this far, maybe you're looking for some advice. Which Cross-Origin-Opener-Policy header value should you use?

Luckily, this is relatively easy: if you want to be the most secure, your should use same-origin. This protects you from malicious popups in case you're tricked into calling window.open, but it also protects you if a malicious site opens your site in a popup.

Unfortunately, things might not be that simple. Maybe you interact with a payment or authentication site that requires opening in a popup and retrieving a token. If that's the case, you likely won't be able to use same-origin. For this scenario you can use same-origin-allow-popups (and the target authentication/payment site must omit the COOP header or alternatively use unsafe-none).

If you do choose to use same-origin-allow-popups for your site then you'll still be protected if a malicious site opens your site in a popup, but you won't be protected from sites that you open with window.open.

Rolling-out Cross-Origin-Opener-Policy safely with reporting

Given that returning the Cross-Origin-Opener-Policy header has the potential to break both the opened and opener sites, you obviously need to be careful when rolling it out. It's strongly recommended that you use the Reporting API built into browsers to identify any issues in production.

Before taking the plunge, you should also consider running the Cross-Origin-Opener-Policy header in "report only" mode, by using the Cross-Origin-Opener-Policy-Report-Only header instead. This header "simulates" the policy, and sends any violations via the reporting API. This gives you a chance to adjust your site and re-evaluate, before enforcing the policy for real.

A half-way house: restrict-properties

Throughout this post I've said there's only three values but Chrome actually briefly implemented a fourth value, restrict-properties. This was only available for companies signing up for a trial, and was explicitly meant to handle the payment/authentication scenario I described previously, but relaxing restrictions from the popup's point of view. I don't go into more detail here, as it never made it out of the experimental phase as far as I can tell.

Enabling additional features with crossOriginIsolated

After the Meltdown and Spectre vulnerabilities were revealed, browser makers removed some of the features they relied on. These were later re-introduced, but were guarded so that they could only be introduced in a "cross-origin isolated" state (exposed as window.crossOriginIsolated).

To be in a cross-origin isolated state, your site must:

  • Return the Cross-Origin-Opener-Policy header with the value same-origin.
  • Return the Cross-Origin-Embedder-Policy header with the value require-corp or credentialless. I'll talk more about this header in a separate post.

If you meet these requirements then you unlock some additional APIs:

So that's yet another reason to use Cross-Origin-Opener-Policy: same origin for your sites if you can!

Summary

In this post I discussed the same-origin policy and how, despite its restrictions, your site could leak information via side-channel vectors in the web platform APIs. I then described how you can use Cross-Origin-Opener-Policy (COOP) to mitigate vulnerabilities that are associated with the window.open() and window.opener APIs.

I described which header values provide isolation in which scenarios, how the behaviour differs between opener and popup, and which values you should use based on your requirements. Finally, I described the cross-origin isolated state which enables additional APIs and requires that you use the same-origin value.


Viewing all articles
Browse latest Browse all 743

Trending Articles