Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Embedded iframes with different storage access share (and overwrite) the same partitioned cookie #88

Open
jsnajdr opened this issue Jul 23, 2024 · 10 comments

Comments

@jsnajdr
Copy link

jsnajdr commented Jul 23, 2024

At wordpress.com we tried to enable the Partitioned attribute on some of our auth cookies and then had to quickly revert the changes because we discovered the following problem.

At jardasn.dev I prepared a minimal reproduction example that shows what's going wrong. After visiting jardasn.dev and clicking "Login" you'll see a page with two embedded iframe. This is how it looks like in Chrome (with blocked 3rd party cookies):

Screenshot 2024-07-23 at 14 50 59

The site has a top-level login cookie called logged_in. And there are two iframes that embed a document from a same-site same-origin URL. One is embedded directly, the other is nested in an intermediate cross-origin iframe from jardasn-alt.dev.

When loading the jardasn.dev/embed document, the response includes a Set-Cookie header with another cookie, logged_in_rest. When the request has the logged_in cookie, then logged_in_rest value will be the same as logged_in, i.e., admin. When the request doesn't have logged_in cookie, the value will be anon. Both cookies are supposed to be in sync. The logged_in_rest cookie is partitioned, it's supposed to be "private" to the embed iframe. Think of it as an extra authentication token, or nonce, specific for the embed.

The second iframe is nested inside cross-origin jardasn-alt.dev iframe. That makes Chrome treat it as third party. Therefore the logged_in cookie will be blocked when the jardasn.dev/embed is loaded, and the iframe will not have storage access. The logged_in_rest cookie returned by the server is going to be anon.

The first iframe is logged in, the second is logged out. However, both iframes share the same partitioned storage! The second iframe loaded a bit later, and when storing the logged_in_rest=anon cookie, it set it also for the first iframe! If you click the "Reread document.cookie" button in the first iframe, you'll see that the value changes from admin to anon:

Screenshot 2024-07-23 at 15 05 16

That's because the second iframe stored a new value. This is a big problem because instead of having one frame fully logged in and another fully logged out, the iframes pollute each other's cookies and the first frame becomes half logged out.

Let's send a REST request from both iframes. Clicking the "Send REST request" will trigger a same-origin fetch() and in the response the server will tell us which cookies did the request have:

Screenshot 2024-07-23 at 15 10 09

The first iframe's request cookies are out of sync. logged_in (first party cookie) is admin, but the logged_in_rest is anon, like if the iframe was logged out.

The second iframe's request cookie are OK. The logged_in one is missing because it's a first party cookie and it's blocked because there's no storage access. And the partitioned logged_in_rest cookie also says the request is anonymous.

If the first iframe loaded second, it would lead to a similar situation where the second frame is broken. It wouldn't have a valid logged_in cookie available, but it would have a valid logged_in_rest=admin. The login information would leak into the logged out iframe!

The root cause of all this is that both iframes share the same partition key, while having different first-party cookie access.

Firefox behaves differently. (I use the Nightly version with optInPartitioning enabled) Apparently both cookies are stored with different partition keys because they have two independent values:

Screenshot 2024-07-23 at 15 17 43

The first frame is fully logged in, the second is fully logged out, each has its own logged_in_rest cookie and clicking "Re-read document.cookie" doesn't change the value. Both REST requests have two cookies that are in sync.

Safari also behaves differently. It doesn't support partitioned cookies, but the key difference is that both iframes do have storage access, so they both see the same first party logged_in and logged_in_rest cookies:

Screenshot 2024-07-23 at 15 21 19

It seems that Safari doesn't care about the intermediate cross-origin jardasn-alt.dev iframe and that it gives the nested iframe full storage access anyway.

It's only Chrome where the problematic situation happens: shared partitioned storage, different storage access.

The second iframe can "fix itself" by requesting storage access and immediately reloading:

if (!await document.hasStorageAccess()) {
  await document.requestStorageAccess();
  document.location.reload();
}

The iframe is already "entitled" to have storage access, it just has to ask for it. And the request is exempt from the usual limitations -- doesn't need to be triggered by user gesture, doesn't show a popup.

But anyway, while the iframe is reloading, the bad logged_in_rest cookie is still in the shared storage and can cause damage. Only after it reloads it sets the right cookie and both iframes are logged in.

@jsnajdr
Copy link
Author

jsnajdr commented Jul 23, 2024

[In Firefox] apparently both cookies are stored with different partition keys because they have two independent values:

I found that Firefox has a function called UpdatePartitionKeyWithForeignAncestorBit which changes the partition key when the iframe has a foreign ancestor.

  • when jardasn.dev iframe is embedded in jardasn.dev, there is no foreign ancestor and the partition key is (https,jardasn.dev)
  • when the same iframe is embedded in jardasn-alt.dev which it turn is embedded in jardasn.dev, then the partition key becomes (https,jardasn.dev,f).

That's how Firefox manages to keep both iframes partitioned storage separate. Chrome doesn't have this foreign ancestor flag and the partition key for both iframes is exactly the same, leading to bad behavior.

FYI @bvandersloot-mozilla who might know more context why the foreign flag is there. It's clearly an are where the same platform feature behaves very differently in different browsers.

@aselya
Copy link

aselya commented Jul 23, 2024

@jsnajdr, thank you for the detailed explanation of the issue. I believe that this is an issue that might be addressed by my work including the ancestor chain bit in the partition key of partition cookies. I will need to do some more investigation but while I do that would you mind trying your test site either using the most recent build of Chrome Canary and let me know if the current implementation addresses the issue.

@jsnajdr
Copy link
Author

jsnajdr commented Jul 24, 2024

Thank you @aselya for the quick response. I tried the latest Chrome Canary and I can confirm that the new foreign ancestor bit works as expected. The two embedded iframes in my test site (A->A and A->B->A) no longer share the same partition and don't overwrite each other's cookies. Chrome now behaves exactly the same as Firefox (Nightly).

However, it's still possible to create a situation where logged-in and logged-out iframes share the same partition and overwrite each other's cookies. Here's a test site running at jardasn-alt.dev which embeds an iframe from jardasn.dev (a site you logged into when testing the original scenario in this issue), and embed it twice:

Screenshot 2024-07-24 at 11 41 19

The iframes don't have storage access to jardasn.dev first-party cookies, and therefore don't include the logged_in cookie on their load requests, and they receive a logged_in_rest=anon cookie in the response.

Now let's click "Request access & reload" in the left iframe. It will do requestStorageAccess().then(location.reload) and after reload, it has logged_in and logged_in_rest cookies with authenticated admin values. However, the logged_in_rest=admin partitioned cookie was stored into a partition that is shared between both iframes! Both of them have the foreign ancestor bit. Again, an authenticated logged_in_rest partitioned cookie leaked into an iframe that doesn't have access to first party logged_in. Fetch requests from the right will have two cookies that are out of sync. It's summarized in this screenshot:

Screenshot 2024-07-24 at 11 42 00

That suggests that keying the partition with the foreign ancestor bit might be a design mistake. The correct bit to key with is the hasStorageAccess value. Having or not having a foreign ancestor is merely one of several ways how an iframe can end up having or not having storage access. And the partition should be keyed by the value that really matters, i.e., hasStorageAccess, not by the foreign ancestor bit that is merely correlated with it. It's the storage access that determines the "cookie environment" in which the iframe lives. And iframes with different cookie environments should be in different partitions.

@aselya
Copy link

aselya commented Jul 25, 2024

Thank you for taking the time to do the additional testing. I’m glad to hear that the ancestor bit is behaving as expected and that the behavior aligns with Firefox (Nightly).

The addition of the ancestor chain bit to the partition key of partitioned cookies adds a limited level of security by differentiating between cookies set in a cross-site and same-site context but the primary concern for partitioned cookies is mitigating cross-site tracking. As you observed, the partition keys do not differentiate between individual frames. This behavior is consistent with how the rest of storage is partitioned in the browser as there are no security or privacy barriers between iframes with the same frame ancestors.

Your use case may be better addressed through the use of unpartitioned cookies and using StorageAccessApi (SAA). Since the access granted by SAA is per-frame unlike partitioned cookies.

Another approach to consider if you require partitioned cookies, is to utilize a fenced-frame instead of an iframe. It places more restrictions on the cross-site data in embedded content than an iframe and will result in a CookiePartitionKey that does not match between iframes. However, fenced frames are not universally supported by all major browsers and fenced frames cannot use requestStorageAccess to gain access to unpartitioned storage.

@jsnajdr
Copy link
Author

jsnajdr commented Jul 29, 2024

Your use case may be better addressed through the use of unpartitioned cookies and using StorageAccessApi (SAA).

Our use case involves:

  1. authenticated cross-site embed. If the embed is to be authenticated, it must call requestStorageAccess to use first-party authentication cookies. That rules out fenced frames with don't support SAA.
  2. the embed iframe setting its own additional cookie for the frame. If a cross-site embed wants to set cookies, they must be partitioned, otherwise the Set-Cookie headers will be ignored by the browser.

In short, we want to use SAA together with partitioned cookies and I claim that they don't work together well.

This behavior is consistent with how the rest of storage is partitioned in the browser as there are no security or privacy barriers between iframes with the same frame ancestors.

Yes, and there also no barriers between same-origin iframes that are embedded in a cross-origin nested structure. Any iframe can traverse window.parent and window.frames`, even cross-origin ones, and find all its same-origin friends. And then freely read and write their DOM tree etc. That means that there are no security or privacy barriers even between iframes with different frame ancestors.

And this is a paradoxical thing about Storage Access API: although all same-origin frames are on par security-wise, some of them will have storage access and some won't. A frame that doesn't have storage access can easily circumvent that by finding a same-origin iframe that has storage access and communicating with it.

@cfredric writes:

IMO it would be very confusing if an iframe's partition key could change over time;

I'd say it's much less confusing than it seems at first. Because when an iframe discovers that it has been loaded without storage access, a typical action is to request access and reload. The storage access headers proposal mentions this technique all the time. When the change of partition key is associated with a reload, it feels much more intuitive.

The storage access headers proposal also mentions a calendar widget example that loads a "placeholder" without storage access, and this placeholder requests access and reloads. I see two problems with this example:

  1. If this placeholder is loaded anonymously, without storage access, and the response has a Set-Cookie header with a partitioned cookie (with a value derived from request's auth cookies), this cookie will be stored into a partition that is shared with authenticated iframes. Both will overwrite and pollute each other's info.
  2. The placeholder is not necessarily just an empty rectangle without any content or function. It can be that the placeholder displays read-only public information, and that it needs storage access only for writing. A "blog post comments" widget is a typical example. Therefore, an unauthenticated version of the widget is a legitimate part of the UI, and it can have its own partitioned cookies specific for anonymous sessions.

@cfredric
Copy link
Contributor

Let me see if I understand your use case/requirements. From what I gather:

  • You need access to "global" authentication state. ("If the embed is to be authenticated, it must call requestStorageAccess to use first-party authentication cookies.")
    • This has traditionally been supported by SameSite=None cookies. SameSite=None cookies still support this use case.
  • You want each iframe's authentication state to be independent from any other iframe's. ("[Our use case involves] the embed iframe setting its own additional cookie for the frame.")
    • This has never been supported by any kind of cookie.
      • Unpartitioned cookies are too broad since they're accessible, in principle, by any document served from your site.
      • Partitioned cookies are too broad since different iframes can share the same partition key, no matter how we define the partition key (provided it isn't nonced).

From that perspective, it feels to me that your problem isn't solvable with cookies, since cookies really aren't "per-window" state. Even partitioned cookies (or DOM storage) cannot help, because partitions are meant to be shared by different iframes (as long as there are no privacy concerns with doing so) - which seems like it violates your second requirement.

I don't quite follow the reasoning behind your second requirement, but IMO, it seems similar to some concerns around CSRF, and I wonder if using an iframe-local token (like an anti-CSRF token) would satisfy your requirements. That kind of solution uses per-iframe state, rather than per-partition state, so I think it has the data-isolation property you are looking for.

@jsnajdr
Copy link
Author

jsnajdr commented Jul 30, 2024

You want each iframe's authentication state to be independent from any other iframe's.

No, the requirement is much simpler than this. The authentication state is stored in a first-party cookie. So any embedded iframe can be only in two possible states: 1) it has storage access to the first-party cookie, and therefore it's authenticated. 2) it doesn't have storage access, and therefore it's logged out.

Now imagine the embedded frame wants to set a partitioned cookie. And the cookie's value is derived from authentication state. This is a perfectly reasonable scenario, there's nothing weird about it. The CHIPS standard describes two motivational examples: store finder service sets your preferred location as a partitioned cookie, or a support chat service sets your conversation ID as a partitioned cookie. If the service knows who you are (via its first party auth cookie), it's reasonable that it sets a different location or conversation ID compared to when it doesn't know who you are.

But when you embed two iframes from the same service, and one has storage access and the other doesn't, the partitioned cookies will have different values, and will overwrite each other, because they share the same partition.

Without SAA, this cannot happen. If two frames have different "storage shelves" to read from, they also have different storage shelves to write to:

  • if 3rd party cookies are not blocked (and we are in the pre-SameSite era), an embedded iframe can read any first-party cookie, and can write any first-party cookie. They both share the same storage shelf for both reading and writing.
  • if 3rd party cookies are blocked, an embedded iframe cannot read any first-party cookie, and at the same time it cannot write any first-party cookie as well. It can write a partitioned cookie, but that's isolated from the frames that can read first-party cookies.
  • if 3rd party cookies are not blocked, and a first-party cookie doesn't have SameSite=None, then some frames (A->A) can read it and some (B->A or A->B->A) can't. But then the storage of these two kinds of iframes will be isolated from each other with the cross-site ancestor chain bit. Seems to me this was one of reason why @wanderview originally proposed the cross-origin ancestor bit.

Now SAA adds another situation where the storage shelf for reading is different for two iframes, but the writes are not sufficiently isolated yet. That's the bug I'm reporting.

I don't quite follow the reasoning behind your second requirement, but IMO, it seems similar to some concerns around CSRF, and I wonder if using an iframe-local token (like an anti-CSRF token) would satisfy your requirements.

Our use case uses partitioned cookies to "forward" a cookie to another domain:

  1. When logging in to example.com, you get a logged_in_admin cookie with path=/admin. This cookie gives you access to "privileged" URLs under the /admin path.
  2. Now you load an iframe from api.example.com/admin. The load includes the logged_in_admin cookie and the response sets the same cookie as Set-Cookie: logged_in_api=value; domain=api.example.com path=/; partitioned
  3. The logged_in_api cookie now can be used to make "privileged" authenticated requests to api.example.com.
    3a. The logged_in_admin cookie could not be set during login, because example.com is not part of api.example.com and such cookie would be ignored.

Yes, this is very similar to setting a CSRF token. And yes, in the end we'll be able to solve this without cookies, because we don't need the token to persist between navigations: each iframe load is able to generate a new one and store it in the response body. But if we wanted the token to persist, like in the "store finder" and "chat widget" scenarios, then we'd have to store it in storage, be it cookies or local storage, the current partitoning behavior with SAA would be an obstacle.

@cfredric
Copy link
Contributor

Thanks for the clarifications!

The authentication state is stored in a first-party cookie. So any embedded iframe can be only in two possible states: 1) it has storage access to the first-party cookie, and therefore it's authenticated. 2) it doesn't have storage access, and therefore it's logged out.

This makes sense to me; this is a good fit for an unpartitioned cookie (or several).

But when you embed two iframes from the same service, and one has storage access and the other doesn't, the partitioned cookies will have different values, and will overwrite each other, because they share the same partition.

Since the two iframes know a priori that they have different authentication states (and will therefore write different values into the partitioned storage medium), couldn't they use different names for the cookie(s) too, to avoid overwriting each other? That's a simple change that avoids coupling unpartitioned and partitioned storage via the key, and it sounds like it would solve your issue.

I would prefer to avoid incorporating the "has storage access" bit into the partition key, because:

  • there are legitimate use cases that rely on that bit not being part of the key;
  • this use case can be supported by using two partitioned cookies (with different names), without changing the partition key;
  • including that bit in the key doesn't solve any privacy or security concerns, from a web platform perspective;
  • and as I mentioned before, incorporating that bit into the partition key makes a single iframe's partition key mutable over time, which is a footgun for web developers and a big challenge for browser vendors.

What do you think? Am I missing something about your requirements that makes using two different cookies a non-viable approach?

@jsnajdr
Copy link
Author

jsnajdr commented Aug 1, 2024

Since the two iframes know a priori that they have different authentication states (and will therefore write different values into the partitioned storage medium), couldn't they use different names for the cookie(s) too, to avoid overwriting each other?

Yes, at least the server knows whether the iframe is loading with storage access or without. Because the load request includes first party cookies. So, if a store finder wants to set a preferred_location cookie, it will set it under two different names:

  • preferred_location_fpyes if the first-party session cookie is in the request
  • preferred_location_fpno otherwise

Now, in the browser, both iframes will see both cookies (if they are not httponly) in document.cookie. How do they determine which one to use? If the first party session cookie is httponly, the script in the iframe, unlike the server, doesn't see it at all. The most reliable way is to check hasStorageAccess:

let location = getDocCookie('preferred_location_fpno');
if (!location && await document.hasStorageAccess()) {
  location = getDocCookie('preferred_location_fpyes');
}

(even with storage access we have to look at the fpno cookie because that's what we get in logged-out state)

For fetch requests, they will include both cookies, no matter which frame they are sent from, because they are both in the partitioned cookie jar. The server now has to have similar logic which determines which one is the right one to use, depending on the presence of the first-party session cookie.

So, it can all work, and it's very similar to the advice in this document about transitioning to partitioned cookies.

including that bit in the key doesn't solve any privacy or security concerns, from a web platform perspective;

However, I'm not very sure about this. The iframe that doesn't have storage access will still see the preferred_location_fpyes cookie, which is an information derived from the first-party cookie. So, it effectively does have storage access. Information is leaking.

It's the same situation as with the foreign ancestor bit discussed in privacycg/storage-partitioning#25. Information obtained using a SameSite=Strict cookie was stored to a partition shared with frames that cannot use the samesite cookie. That's also a leak and it was fixed by adding a new bit to the partition key.

So, if leaking samesite-derived information is a privacy and security concern, then leaking first-party-derived information causes exactly the same concerns, whatever they are, doesn't it?

One difference might be that privacycg/storage-partitioning#25 is very much about service workers. Does SAA and CHIPS have any relevant interaction with service workers? That's a point where I start to be very confused 🙂

@cfredric
Copy link
Contributor

cfredric commented Aug 1, 2024

How do they determine which one to use? If the first party session cookie is httponly, the script in the iframe, unlike the server, doesn't see it at all. The most reliable way is to check hasStorageAccess:

Agreed; the script ultimately wants to know if it is logged in (i.e. if it has access to the unpartitioned auth cookies, and if the auth cookie exists), so document.hasStorageAccess() is at least part of the answer there.

The iframe that doesn't have storage access will still see the preferred_location_fpyes cookie, which is an information derived from the first-party cookie. So, it effectively does have storage access. Information is leaking.

Recall that our assumption is that one of the iframes already obtained storage-access permission and is using unpartitioned cookies/storage. That means any iframe can easily call document.requestStorageAccess() (without a user gesture, and without a prompt) and also access unpartitioned cookies/storage directly. So there's no privacy boundary that is being broken here.

From a security perspective, we need to consider the threat model. The reason that the ancestor chain bit was added is that the inner A iframe in a A(A) context (i.e. same-site embedded iframe) is fundamentally different from the inner iframe in a A(B(A)) context (i.e. cross-site embedded iframe), with regard to security. In the A(B(A)) case, the B iframe may be malicious; but there's no possibly-malicious third-party in the former case. That's why the ancestor chain bit is important: it distinguishes "contexts which may be under attack by an embedder" from "contexts which won't be attacked by an embedder".

But that isn't the case in the situation you've described; both of the iframes are cross-site, so they are equally vulnerable to attack by a malicious embedder.

if leaking samesite-derived information

From the browser's point of view, this isn't a leak: your site willingly copied the same-site-derived information from unpartitioned cookies and put it in partitioned storage. If you decide that you consider that a leak, then you should store the data in the unpartitioned storage/cookies, and stop copying it into cross-site partitioned storage. Then that info would be available if and only if the iframe has storage access, which sounds like what you actually want.

One difference might be that privacycg/storage-partitioning#25 is very much about service workers. Does SAA and CHIPS have any relevant interaction with service workers? That's a point where I start to be very confused 🙂

I believe service workers use the same partition key that CHIPS uses (including the ancestor chain bit).

Service workers don't have any interaction with SAA, due to security concerns there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants