- Published on
Fetch Metadata and Isolation Policies
- Authors
- Name
- Teo Selenius
- Follow @TeoSelenius
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
.
Say that there's a website a.example
that loads an image from b.example
.
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.
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.
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:
- The request originated from an image element.
- The request's mode (that this was a resource load as opposed to, e.g., navigation).
- 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:
- XSS (Cross-Site Scripting) Attacks
- CSRF (Cross-Site Request Forgery) Attacks
- Various XS-leaks
- XSSI (Cross-Site Script Inclusion) Attacks
- Spectre
- Clickjacking
- Tabnabbing
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
andhttps://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
andwww.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 viafetch()
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 vianew 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.
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.
There is also a link. Clicking through the link, the page loads correctly.
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.