Open jsnajdr opened 4 months ago
[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.
jardasn.dev
iframe is embedded in jardasn.dev
, there is no foreign ancestor and the partition key is (https,jardasn.dev)
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.
@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.
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:
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:
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.
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.
Your use case may be better addressed through the use of unpartitioned cookies and using StorageAccessApi (SAA).
Our use case involves:
requestStorageAccess
to use first-party authentication cookies. That rules out fenced frames with don't support SAA.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:
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.Let me see if I understand your use case/requirements. From what I gather:
SameSite=None
cookies. SameSite=None cookies still support this use case.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.
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:
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.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:
example.com
, you get a logged_in_admin
cookie with path=/admin
. This cookie gives you access to "privileged" URLs under the /admin
path.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
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.
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:
What do you think? Am I missing something about your requirements that makes using two different cookies a non-viable approach?
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 requestpreferred_location_fpno
otherwiseNow, 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 https://github.com/privacycg/storage-partitioning/issues/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 https://github.com/privacycg/storage-partitioning/issues/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 🙂
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 https://github.com/privacycg/storage-partitioning/issues/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.
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 visitingjardasn.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):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 fromjardasn-alt.dev
.When loading the
jardasn.dev/embed
document, the response includes aSet-Cookie
header with another cookie,logged_in_rest
. When the request has thelogged_in
cookie, thenlogged_in_rest
value will be the same aslogged_in
, i.e.,admin
. When the request doesn't havelogged_in
cookie, the value will beanon
. Both cookies are supposed to be in sync. Thelogged_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 thelogged_in
cookie will be blocked when thejardasn.dev/embed
is loaded, and the iframe will not have storage access. Thelogged_in_rest
cookie returned by the server is going to beanon
.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 fromadmin
toanon
: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:The first iframe's request cookies are out of sync.
logged_in
(first party cookie) isadmin
, but thelogged_in_rest
isanon
, 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 partitionedlogged_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 validlogged_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: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
andlogged_in_rest
cookies: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:
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.