Published on

Fetch Metadata and Isolation Policies

Authors
null

Learn everything about the fetch metadata headers and how you can implement isolation policies to defend against various client-side attacks.

What are fetch metadata headers?

Fetch metadata headers are a relatively new browser security feature that you can use in your web application to protect against client-side attacks like never before.

The purpose of the headers is simple. They tell the web server about the context in which an HTTP request happened.

An example

Here's Jim. He is logged in at b.example with the cookie SessionId=123.

null

Say that there's a website a.example that loads an image from b.example.

null

Without fetch metadata headers

Before the fetch metadata headers existed, all the webserver saw when Jim opened a.example was that someone with Jim's cookie loaded the image.

null

On HTTP level it might look like this:

GET /cat.jpg HTTP/1.1
Host: b.example
Cookie: SessionId=123

Note that the server has no way to know whether it's a.example or b.example loading the image with Jim's session ID.

With fetch metadata headers

With fetch metadata headers supported, the browser will send more information in the request to the webserver.

null

On HTTP level, it might look like this:

GET /cat.jpg HTTP/1.1
Host: b.example
Cookie: SessionId=123
Sec-Fetch-Dest: image
Sec-Fetch-Mode: no-cors
Sec-Fetch-Site: cross-site

Notice how the browser told the webserver three new things this time:

  1. The request originated from an image element.
  2. The request's mode (that this was a resource load as opposed to, e.g., navigation).
  3. The request originated from another website.

Actually there is a fourth thing implied in the request as well. The lack of Sec-Fetch-User header implies that the request didn't originate as a result of user interaction.

What attacks fetch metadata headers prevent?

Fetch metadata headers can help prevent pretty much any cross-site attack. These include:

Fetch metadata headers

The system consists of four HTTP request headers.

Sec-Fetch-Site

The Sec-Fetch-Site header tells the webserver if the request originated from the same origin, the same site, or a different website entirely. It can have one of the following values:

  • same-origin: The request came from the same origin, that is, the host, port, and scheme were identical. You can read more about what origin means here.
  • same-site: The request came from a different origin but from the same site, which is browser lingo for subdomain of the same registrable domain. For example: https://a.example.com and https://b.example.com are considered same-site (but different origin).
  • cross-site: The request came from a different website completely. For example, www.google.com and www.facebook.com are considered cross-site.
  • none: The request didn't originate from any website. This happens when the user opens a bookmark, types manually in the URL bar, opens a link from another program, etc.

Sec-Fetch-Mode

The Sec-Fetch-Mode tells the webserver the request's mode. It can have one of the following values:

  • same-origin: The browser is making a request to the same origin. Requests using this mode will fail if the target is of a different origin.
  • no-cors: The browser is making a request to another origin but doesn't expect to read the response or use any non-safelisted HTTP verbs or headers.
  • cors: The browser attempts to make a CORS (Cross-Origin Resource Sharing) request. You can read more about CORS here.
  • navigate: The browser is navigating from one page to another, such as when clicking a link, receiving a redirect, or opening a bookmark.

Sec-Fetch-Dest

The Sec-Fetch-Dest tells the webserver the request's destination, that is, what kind of place is waiting for the resource. In the example above, it was an <img> element loading the resource, so the destination was image.

Some of the possible values are:

  • empty: When the resource is loaded via fetch() or XHR.
  • document: When the resource is loaded in top-level navigation.
  • image: When the resource is loaded in an <img> tag.
  • worker: When the resource is loaded via new Worker(...).
  • iframe: When the resource is loaded into an <iframe>.

The value can be any element capable of loading an external resource, so also audio, audioworklet, embed, font, frame, manifest, object, paintworklet, report, script, serviceworker, sharedworker, style, track, video, xslt, etc. are possible.

Sec-Fetch-User

The Sec-Fetch-User header tells the webserver a navigation request originated (in theory) due to user interaction, such as by clicking a link. The value is always ?1. When navigation occurs as a result of something that browsers don't consider "user activation", this header is not sent at all.

Browser support

At the time of this writing, fetch metadata headers are supported on Chrome, Edge, and Opera. But don't worry, you can implement a policy in a fully backward-compatible way. You can check the up-to-date status here.

Implementing an isolation policy

The reason fetch metadata headers are so fantastic is that they allow us to do this.

null

Blocking a request on the server-side based on the client-side context is a power that we've never had before. But now we do, so let's use it and implement an isolation policy.

To create an isolation policy, you need a filter, middleware, etc., that enables you to block requests based on the HTTP request headers.

I will use NodeJS as an example, but such middleware is usually trivial to implement in any development framework.

We'll start with this skeleton:

app.use(function (req, res, next) {})

1. Backward compatibility

Begin your policy by allowing requests that don't have the fetch metadata headers at all. Otherwise, people using browsers that don't yet support fetch metadata wouldn't be able to access your website.

if (!req.headers['sec-fetch-site']) {
  return next()
}
if (!req.headers['sec-fetch-mode']) {
  return next()
}
if (!req.headers['sec-fetch-dest']) {
  return next()
}

2. Allow all requests from the same origin

To make your application work properly, allow all interactions from the same origin.

if (req.headers['sec-fetch-site'] === 'same-origin') {
  return next()
}

3. Allow requests that don't originate from another website

Sometimes requests originate from the user opening a bookmark or typing in the URL bar. In these cases, the value of Sec-Fetch-Site is none, so let's allow that.

if (req.headers['sec-fetch-site'] === 'none') {
  return next()
}

4. Allow navigation

To enable other websites to link to your page, navigation has to be allowed. However, just allowing navigation would also allow cross-site POST requests, framing, and other things we don't necessarily want.

To be safe, verify that the HTTP method is GET and that the resource destination is document.

if (
  req.method === 'GET' &&
  req.headers['sec-fetch-mode'] === 'navigate' &&
  req.headers['sec-fetch-dest'] === 'document'
) {
  return next()
}

5. Block any other requests

If a request didn't match any rules so far, reject it.

return res.status(403).json({
  error: 'Request blocked by the isolation policy.',
})

6. Relax the policy if required

You may want to allow some cross-origin or cross-site interactions.

Allow subdomains

To allow requests from your own subdomains, let requests through that have the Sec-Fetch-Site value of same-site.

if (req.headers['sec-fetch-site'] === 'same-site') {
  return next()
}

Allow framing

To enable other websites to load your page in an iframe, allow navigating GET requests when the destination is iframe.

if (
  req.method === 'GET' &&
  req.headers['sec-fetch-mode'] === 'navigate' &&
  req.headers['sec-fetch-dest'] === 'iframe'
) {
  return next()
}

7. Test the policy

We have now implemented a rather strict isolation policy. Here is the entire thing (I didn't allow iframes or subdomains in my example). You can fork and play with the code here.

app.use(function (req, res, next) {
  // If fetch metadata is not supported, allow the request.
  if (!req.headers['sec-fetch-site']) {
    return next()
  }
  if (!req.headers['sec-fetch-mode']) {
    return next()
  }
  if (!req.headers['sec-fetch-dest']) {
    return next()
  }

  // If the request originates from your own web application, allow it.
  if (req.headers['sec-fetch-site'] === 'same-origin') {
    return next()
  }

  // If the request doesn't originate from a website at all (bookmark, etc.) then allow it.
  if (req.headers['sec-fetch-site'] === 'none') {
    return next()
  }

  // If the request is a navigation GET request, allow it.
  if (
    req.method === 'GET' &&
    req.headers['sec-fetch-mode'] === 'navigate' &&
    req.headers['sec-fetch-dest'] === 'document'
  ) {
    return next()
  }

  // If no rules matched, block the request.
  return res.status(403).json({
    error: 'Request blocked by the isolation policy.',
  })
})

I've created a little test page here. It tries to load an image, POST an HTML form and frame the protected website. If you open the site, you will find that all of the three will fail.

isolation policy blocked requests

There is also a link. Clicking through the link, the page loads correctly.

not blocked through link

Also, loading the page in Firefox or Safari works fine, so our backward compatibility seems to be in check.

8. Deploy the policy

When deploying the policy to production for the first time, it is recommended to use a logging-only approach.

The policy would remain the same, but instead of blocking requests, you log that a request would have been blocked because of X, and then you let the request through.

This way, you will quickly know if the policy would break something and if there's something you have to add before finally deploying the enforcing policy.

Conclusion

Fetch metadata headers are a remarkable browser feature that enables developers to secure web applications in a way that hasn't previously been possible.

Implementing an effective policy is simple, and it can easily be relaxed to accommodate any special needs, such as allowing framing or interactions from subdomains.

When deploying the policy to production, it is recommended to start with a logging-only approach. Then later deploy the enforcing policy when you're satisfied that it won't break anything.

Browser support is still lacking or experimental depending on the browser, but the headers can already be used in a backward compatible way.

References