privacycg / storage-access

The Storage Access API
https://privacycg.github.io/storage-access/
199 stars 26 forks source link

Expand storage-access-preserving navigations to include same-origin-initiated navigations, not just self-initiated. #197

Open bvandersloot-mozilla opened 4 months ago

bvandersloot-mozilla commented 4 months ago

We got this bug filed on Firefox: Bug 1876504 - With storage access granted, nested iframe is loaded without cookies

The user has an iframe with storage access and loads a same-origin iframe nested inside the first iframe. The subdocument fetch behaves differently among browsers. In the spec, I believe it is ambiguous whether or not this request should get unpartitioned cookies, pending the result of the cookie-layering work. However it is clear that the resulting window should not have storage access initially.

Chrome now sends unpartitioned cookies on the subdocument fetch, then does not give access to the subdocument's unpartitioned cookies initially. This is weird, and I think we should make these two align. Firefox gives neither unpartitioned cookies. This is consistent, but increases developer friction.

To solve this, I propose we generalize our propogation of the has storage access state to not be self-initiated but instead be same-origin-initiated. This does not meaningfully change the security properties of which documents may get storage access in my view, maintaining origin-granularity over which subdocuments can ever access their unpartitioned cookies.

bvandersloot-mozilla commented 4 months ago

@arturjanc: you had security concerns on the origin boundary w.r.t. navigations initiated by documents with storage access. Does this maintain the security invariants you wanted? I don't recall why the navigation was required to be self-initiated.

arturjanc commented 4 months ago

This change makes sense to me and I don't think it will undermine the security properties we care about here.

The motivation behind the self-initiated restriction was to prevent a cross-origin embedder from navigating an iframe that received storage access to an arbitrary endpoint on the iframe's site chosen by the embedder (and e.g. clickjack it or leak data). But allowing same-origin-initiated navigations to maintain storage access is fine because it still protects the iframe from these kinds of navigations, so I think we can safely allow it.

annevk commented 4 months ago

I think @johannhof was also interested in allowing this for the same site, no?

arturjanc commented 4 months ago

Dumb question: can you even initiate a navigation in a same-site-but-cross-origin iframe if you're not the embedder? You generally shouldn't be able to navigate cross-origin frames except in a few cases, e.g. you have an embedder/embeddee relationship - I think we tightened this a few years ago, but don't remember exactly where we landed on that.

Basically, I'm not sure in which scenarios allowing same-site navigation would be useful here.

annevk commented 4 months ago

That's not something that's currently well-defined unfortunately. https://github.com/whatwg/html/issues/313 goes into some of it.

jsnajdr commented 3 months ago

Hi :wave: I'm the reporter of the Firefox bug that @bvandersloot-mozilla mentions. And the question whether storage access should be propagated to a nested same-site or same-origin iframe is alse very relevant to us.

Our setup looks like this:

  1. Third party sites embed an iframe from widgets.wordpress.com. This iframe requests storage access, and after it's granted, it expects to be able to issue credentialed fetch requests and load credentialed sub-iframes, with the unpartitioned login cookie the user got when logging into wordpress.com as a top-level site.
  2. The widgets.wordpress.com iframe loads a nested iframe, from public-api.wordpress.com. (Cross-origin, same-site). This nested iframe would like to be loaded with wordpress.com unpartitioned login cookie. This currently works on Chrome, but doesn't work on Firefox.
  3. The public-api.wordpress.com nested iframe would also like to send credentialed fetch requests to public-api.wordpress.com (same-origin). This currently doesn't work, neither in Chrome nor Firefox, because the storage access is not automatically propagated to the nested iframe. And it can't request storage access on its own, because there are no user interactions with this nested iframe. It's an invisible helper that communicates with its parent using messages.

Reading the discussion in this issue, it seems that propagating the storage access automatically to nested same-site iframes could be possible to standardize and implement? That would solve all our problems.

After all, if widgets.wordpress.com was a top-level site and it embedded public-api.wordpress.com, the iframe would get storage access, because:

If browsingContext is same authority with browsingContext’s top-level browsing context's active document, resolve p with true.

But when this tree of same-site iframes is embedded in a cross-origin document, and the top-level iframe of this tree is granted storage access, it's a similar situation, but the nested iframes don't get access.

cfredric commented 3 months ago

My concern with a change like this is that it makes it hard to "drop" unpartitioned cookie access once you've gotten it, even if you want to. Today, an iframe can drop cookie access just by opening a new iframe and not requesting access there. With this change, that's no longer possible. It seems like a step backward to the unsafe default of having unpartitioned cookie access without having asked for it, IMO.

Instead, I think a better solution for this is to use the Storage Access Headers proposal, specifically the load response header. Then the subdocument fetch will still be credentialed (since the parent iframe has unpartitioned cookie access, after all), and the fetch's response headers will indicate that the subresource iframe is opting into having unpartitioned cookie access, upon loading. This ensures that any iframe that wants cookie access still has to ask for it explicitly, which is a better default policy for security IMO.

@jsnajdr, I think there might be a misunderstanding on your third point - although storage access is not automatically propagated to the nested iframe, the nested iframe can call requestStorageAccess() without obtaining a user gesture first (at least in Chrome and Firefox), and the request will be granted since the user has already granted permission to the parent iframe. Can you give that a try?

bvandersloot-mozilla commented 3 months ago

I like that approach, if we can get a 3-engine consensus on the storage access headers :)

jsnajdr commented 3 months ago

although storage access is not automatically propagated to the nested iframe, the nested iframe can call requestStorageAccess() [...] Can you give that a try?

Oh yes, this has been the missing piece and it works! And it works around the Firefox "bug" I reported. Initially, the frame load request is sent without unpartitioned cookies and the frame doesn't have storage access. But I can do this:

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

After the access was granted, I reload the frame. This time the request is sent with unpartitioned cookies, and after the load the frame has storage access automatically, without having to ask for it.

With Storage Access Headers, the above script can be replaced with a response header Activate-Storage-Access: retry. Then the browser will do the same workflow automatically. However, I believe I still need to load the iframe twice, I don't see a way how to save the extra roundtrip.

It seems like a step backward to the unsafe default of having unpartitioned cookie access without having asked for it

I see, the design principle is that no embedded frame should automatically get storage access until it asks for it explicitly. The default is "no access".

But that means that Firefox does it right (not sending unpartitioned cookies on the initial load) and it's Chrome that has a bug. If the embedded frame doesn't have storage access by default, then the load request shouldn't have unpartitioned cookies. If it has them, it means that the frame effectively does have unpartitioned access, because the server response can contain data derived from the unpartitioned cookies.

cfredric commented 3 months ago

However, I believe I still need to load the iframe twice, I don't see a way how to save the extra roundtrip.

Yeah, there's a tradeoff between security and performance here. We're exploring that tradeoff space in https://github.com/cfredric/storage-access-headers/issues/6; feel free to take a look and contribute if you have more ideas.

But that means that Firefox does it right (not sending unpartitioned cookies on the initial load) and it's Chrome that has a bug. If the embedded frame doesn't have storage access by default, then the load request shouldn't have unpartitioned cookies.

Maybe I'm misunderstanding, but from your initial description I understood that the embedded iframe does request storage access: This iframe requests storage access, and after it's granted, it expects to be able to issue credentialed fetch requests. One of the credentialed fetches that this iframe issues happens to be for the source of another iframe (which should not implicitly have storage access when it loads, IMO), but that doesn't change that the original iframe has storage access and can issue credentialed fetches. From that point of view, I think Chrome is behaving consistently here.

jsnajdr commented 3 months ago

One of the credentialed fetches that this iframe issues happens to be for the source of another iframe (which should not implicitly have storage access when it loads, IMO)

This is the core question that Firefox and Chrome are answering differently. Which iframe really does the fetch? Is it the parent iframe (and its document and window objects) or is it the child iframe?

Chrome thinks that the parent iframe does the fetch, doing it for the child iframe. Firefox thinks that the child frame does it, doing it for itself.

Is there a place in the HTML or Fetch standard that would state clearly which document owns the fetch? I would say it should be the child frame's contentDocument: that frame has its own "content navigable" and the fetch is part of its session history.

I noticed another difference between how Firefox and Chrome treat the iframe fetch differently. When there is a widgets.wordpress.com embedded frame that creates a nested public-api.wordpress.com iframe, the fetch has these headers in both browsers:

Sec-Fetch-Dest: iframe
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-site

Here, both browsers agree that the fetch is not same-origin, but same-site. It's like the parent frame owns the fetch. But when the frame reloads itself (in the "if doesn't have storage access, request access and reload" flow), then Chrome treats the second fetch as same-origin:

Sec-Fetch-Site: same-origin

But in Firefox it's still same-site, just like the initial request.

johannhof commented 3 months ago

As to "who does the fetch", I would defer to @annevk for authoritative knowledge of the current specification. In any case, I don't think we should be dogmatic about what the current spec says but consider what the best developer experience would be instead.

I agree that it's very confusing to have a credentialed document load and then no subsequent storage access in the document, since developers are likely to already make a decision about what content to render based on the server-side presence of cookies or not. It could also lead to subtle issues where most of the pre-rendered page works well but then subsequent credentialed subresource requests fail.

So, either we completely exclude nested same-site iframes from receiving storage access without rSA (or headers) or we always propagate the storage access bit to same-site children.

I'm leaning towards the latter. I haven't really seen clear evidence of a practical attack that would be possible against those nested iframes, especially one that isn't also applicable to any other same-site resource loaded by the original iframe. Without this kind of reasoning for restricting it, I think we should prefer developer utility and simplicity here.

jsnajdr commented 3 months ago

So, either we completely exclude nested same-site iframes from receiving storage access without rSA (or headers) or we always propagate the storage access bit to same-site children.

I was testing this with Safari (17.2.1) today and found that it already propagates the access to a nested same-site iframe:

  1. Top-level site example.com embeds iframe from widgets.wordpress.com
  2. widgets.wordpress.com requests access and it's granted. (On a second load it doesn't even need to request the access, it's auto-granted, which is nice). Then this iframe loads nested iframe from public-api.wordpress.com.
  3. public-api.wordpress.com has access (await document.hasStorageAccess() === true) right from the beginning, and doesn't need to request it.

However, there is a weird bug when wordpress.com is the top-level site. Then a nested public-api.wordpress.com iframe doesn't have storage access! It needs to ask for it with requestStorageAccess(), and additionally, that request needs to be triggered by a user interaction. Otherwise it rejects with undefined. I.e., what @cfredric recommends above:

although storage access is not automatically propagated to the nested iframe, the nested iframe can call requestStorageAccess() without obtaining a user gesture first (at least in Chrome and Firefox), and the request will be granted

doesn't work in Safari when a top-level frame (which never asked for access, because why would it when it's the top-level document) embeds a same-site iframe.

This is already reported in WebKit since 4 years ago, and @johnwilander responded "I made a review comment about this in the ongoing Storage Access API spec work." But there are no details about the location and the content of the review comment, and the bug is still there today.

arturjanc commented 2 months ago

So, either we completely exclude nested same-site iframes from receiving storage access without rSA (or headers) or we always propagate the storage access bit to same-site children.

I'm leaning towards the latter. I haven't really seen clear evidence of a practical attack that would be possible against those nested iframes, especially one that isn't also applicable to any other same-site resource loaded by the original iframe. Without this kind of reasoning for restricting it, I think we should prefer developer utility and simplicity here.

Conceptually, I see this similarly to what @cfredric outlined above, i.e. it seems a bit cleaner for each document to require explicit calls to document.requestStorageAccess() to receive credentials when embedded in top-level-3P context. This way, having one document within a site that calls rSA doesn't suddenly make any content from its hosting site eligible to be embedded with credentials (if e.g. the document that received storage access directly iframes it).

OTOH, looking at this purely from a security perspective I think @johannhof is right that we don't have a compelling reason to disallow automatic propagation of storage access:

One concern that @ddworken came up with is whether a cross-site ancestor, e.g. the top-level page, is permitted to navigate the nested frame (which, as @annevk points out above, is not well-defined) -- my hope is that it shouldn't. But if it was, then it could navigate that nested frame to a different endpoint same-site with the frame that requested storage access; so we would need to prevent this from happening, similarly to the self-initiated restriction we discussed above.

Overall, to me this boils down to the ergonomic benefit of automatically propagating storage access. Ideally, if this affects only a small number of widgets, we could forego this and require nested frames to call rSA. But if this introduces a burden on many developers building widget that request storage access, I think automatically propagating access to same-site frames would be okay.