← All reports

[4] Srcdoc iframes inherit the top-level site for cookies

MediumWebCore loaderCrossOrigin

A srcdoc frame inherited its origin from one parent and its cookies from another.

c52bbb5

Rated Medium — the diff removes a conflation that handed a nested attacker frame the top-level document's cookie site. No memory-safety component, and the attacker must already control a subframe of the victim's page; given that, SameSite=Strict stops defending the endpoints that rely on it.

Cookies carry a SameSite attribute that decides whether they ride along on requests made from other sites, and browsers evaluate it against a per-document "site for cookies" value that each outgoing request inherits. Some documents have no URL-derived origin of their own — about:blank and about:srcdoc take their origin from the element that created them, i.e. their immediate parent — so the browser has to decide what cookie site such a document belongs to. The expectation is straightforward: a document that inherits its origin from its parent should also track its parent's cookie site, because that parent is who it actually speaks for.

The angle: any attacker controlling a subframe of a victim page — an ad slot, an embed widget, a user-supplied frame — could create a srcdoc child and issue credentialed cross-site requests to the top-level origin carrying its SameSite=Strict and Lax cookies.

When we set firstPartyForCookies on a subframe, we check if either:

  1. shouldInheritSecurityOriginFromOwner is true for the current document's URL, or
  2. the current document's URL is same-registrable-domain as the top-level document URL

In the case of an iframe with srcdoc, shouldInheritSecurityOriginFromOwner returns true (as documented), and this causes us to set the page's mainFrameURL as the firstPartyForCookies. We need a conditional exception for shouldInheritSecurityOriginFromOwner, but it should take nested iframes into account. This patch adjusts the logic so we inherit the ancestor frame's siteForCookies instead of the page's URL. The same-registrable-domain check remains unchanged.

Source/WebCore/loader/FrameLoader.cpp

RefPtr localFrame = dynamicDowncast<LocalFrame>(*descendantFrame);
if (!localFrame)
continue;
- if (SecurityPolicy::shouldInheritSecurityOriginFromOwner(protect(localFrame->document())->url()) || registrableDomain.matches(protect(localFrame->document())->url()))
+ if (SecurityPolicy::shouldInheritSecurityOriginFromOwner(protect(localFrame->document())->url())) {
+ if (RefPtr parent = dynamicDowncast<LocalFrame>(localFrame->tree().parent()))
+ protect(localFrame->document())->setSiteForCookies(parent->document()->siteForCookies());
+ } else if (registrableDomain.matches(protect(localFrame->document())->url()))
protect(localFrame->document())->setSiteForCookies(url);
}
}

LayoutTests/http/tests/cookies/same-site/resources/srcdoc-creator-inside-cross-origin-iframe.html

+const fetchURL = "http://127.0.0.1:8000/cookies/resources/echo-json.py";
+const srcdocContent = `<!DOCTYPE html><body><script>
+fetch(${JSON.stringify(fetchURL)}, {credentials: "include", mode: "cors"})
+ .then((response) => response.json())
+ .then((cookies) => {
+ window.top.postMessage({type: "cookies-from-srcdoc", cookies}, "*");
+ })
+ .catch((error) => {
+ window.top.postMessage({type: "cookies-from-srcdoc", cookies: {error: String(error)}}, "*");
+ });
+<\/script></body>`;
+
+let srcdocIframe = document.createElement("iframe");
+srcdocIframe.style.display = "none";
+srcdocIframe.srcdoc = srcdocContent;
+document.body.appendChild(srcdocIframe);

The single production change is in FrameLoader::setFirstPartyForCookies(const URL& url), which walks the descendant frames of m_frame and decides, per LocalFrame descendant, whether that document's site-for-cookies should be updated to url — the frame tree's new first-party URL. Before the patch the two conditions were OR-ed into one branch: if (shouldInheritSecurityOriginFromOwner(document->url()) || registrableDomain.matches(document->url())) document->setSiteForCookies(url);.

The patch splits them. The shouldInheritSecurityOriginFromOwner case no longer assigns url; it looks up the descendant's parent via localFrame->tree().parent(), downcasts with dynamicDowncast<LocalFrame>, and on success assigns protect(localFrame->document())->setSiteForCookies(parent->document()->siteForCookies()). The registrableDomain.matches(...) branch is unchanged and still assigns url, but is now reached only via else if, so it is no longer evaluated for origin-inheriting documents. The new branch is a no-op when the parent is not a LocalFrame — e.g. a RemoteFrame under site isolation. The rest of the commit is test collateral: fetch() and <img> variants each as .html plus -expected.txt, and three supporting resources including the server-side recorder record-image-cookies.py, which together build the exact frame nesting required to trigger the bug.

Conflating origin inheritance from the immediate parent with membership in the top-level document's site.

Site-for-cookies / first-party-for-cookies. A URL stored on each Document (settable via Document::setSiteForCookies) representing the site a request belongs to. It is copied into each outgoing resource request and is what the network layer compares against a cookie's site to decide same-site versus cross-site.

SameSite cookie attribute. SameSite=Strict cookies are sent only on requests whose site-for-cookies matches the cookie's site; SameSite=Lax additionally allows top-level navigations. A cookie with no SameSite attribute is treated by WebKit as SameSite=None (the test comment cites coreSameSitePolicy() in CookieCocoa.mm), so it rides along on cross-site credentialed requests — which is why the tests use it as a positive control.

Registrable domain. The eTLD+1 form of a host; RegistrableDomain::matches(url) asks whether a URL belongs to the same site as the domain the object was constructed from.

Origin inheritance. Per the HTML origin rules, some URLs have no origin of their own and take the origin of the element that created them. SecurityPolicy::shouldInheritSecurityOriginFromOwner(url) implements this predicate; it is true for URLs like about:blank and about:srcdoc.

<iframe srcdoc>. An iframe whose document content is supplied inline as an attribute value; the resulting document's URL is about:srcdoc and its origin is inherited from the embedding document.

Frame tree traversal. FrameTree links a frame to its parent and descendants; FrameLoader::setFirstPartyForCookies walks descendants of a frame to push first-party state down. Under site isolation a parent may be a RemoteFrame rather than a LocalFrame, which is why the code uses dynamicDowncast<LocalFrame>.

This is a security policy logic error — same-site cookie scope confusion, with no memory-safety component. The pre-fix code treated "this document inherits its security origin from its owner" as if it were equivalent to "this document is same-site with the top-level frame", and fed both into the same assignment.

  Frame tree                          Pre-fix siteForCookies
  ──────────                          ──────────────────────
  top: 127.0.0.1:8000  (victim A)     A
    └─ iframe: localhost:8000  (B)    (unchanged: B's URL is not
        │                              same-registrable-domain as A)
        └─ iframe srcdoc  (about:srcdoc)
             shouldInheritSecurityOriginFromOwner == true
             ──────────────────────────────►  A   ← wrong; origin is B's

  Post-fix:                            = parent's siteForCookies (B's)

shouldInheritSecurityOriginFromOwner returns true for URLs such as about:srcdoc and about:blank, whose origin is taken from the owning element's document — the immediate parent — not from the top of the tree. The missing invariant is that an origin-inheriting document's cookie site must track the origin it actually inherits.

When a page at origin A embeds a cross-origin iframe at origin B, and B creates an <iframe srcdoc=...>, the srcdoc document's URL is about:srcdoc and its security origin is inherited from B, so any subresource request it makes to A is genuinely cross-site. But the old branch matched on the inheritance predicate and stamped the srcdoc Document's site-for-cookies with A's URL. That per-document value is what a document's outgoing requests carry as their first-party URL and what the SameSite computation consults (standard WebCore behavior, consistent with the shipped tests), so requests originating in the srcdoc were classified as same-site with A, and cookies scoped to A were attached to requests B fully controls.

After the fix, the srcdoc document copies its parent's siteForCookies. In the attack shape the parent is B, whose document is not stamped with A's URL by this loop because B's URL does not match A's registrable domain — so whatever value B holds, the srcdoc no longer acquires A's site. (Whether B's document could already hold A's URL depends on prior navigation state and on the order in which the loop visits frames — a runtime question this change does not settle either way.) In the benign shape — srcdoc directly inside the top-level frame — the parent is the top frame, so the srcdoc still gets the top-level site and same-site cookies keep working.

The bug is reachable from ordinary web content: no special API, no privileged caller. Walking the shipped regression test:

  1. The victim page loads at http://127.0.0.1:8000 and holds cookies strict (SameSite=Strict), lax (SameSite=Lax) and implicit-default (no SameSite) for that host.
  2. It appends an iframe pointing at http://localhost:8000/.../srcdoc-creator-inside-cross-origin-iframe.html — a different host, hence cross-origin and cross-site.
  3. That attacker-controlled document creates <iframe srcdoc=...>; the nested document's URL is about:srcdoc and its origin is inherited from localhost:8000.
  4. When FrameLoader::setFirstPartyForCookies walks the tree, the pre-fix predicate short-circuits the OR and the srcdoc Document receives setSiteForCookies(url), where url is the top-level 127.0.0.1:8000 URL.
  5. The srcdoc script issues fetch("http://127.0.0.1:8000/cookies/resources/echo-json.py", {credentials: "include", mode: "cors"}); the request carries the srcdoc document's first-party URL, so the network layer classifies it same-site with 127.0.0.1 and attaches strict and lax.

The <img> variant does the same for a plain subresource load, recording the received Cookie header server-side. The generalized attack shape is that nesting: the victim site must be the top-level document, the attacker must control any subframe within it, and the attacker's frame creates a srcdoc child from which it issues state-changing or data-reading credentialed requests to the top-level site. Because shouldInheritSecurityOriginFromOwner also covers about:blank, a cross-origin iframe that creates a same-process about:blank child and document.writes into it would plausibly have reached the same branch — that variant follows from the predicate's documented semantics rather than from a shipped test.

The primitive is cross-site attachment of SameSite=Strict and Lax cookies to attacker-initiated subresource requests: a CSRF primitive against any endpoint relying on SameSite as its defense, and, where the target endpoint answers with permissive CORS plus Access-Control-Allow-Credentials, an authenticated cross-site read that could expose per-user response data. There is no leak of the cookie values to attacker script — the cookies ride the request, and reading the response still requires the target's cooperation via CORS. The whole thing lives in the WebContent process: this is a policy decision made in WebCore before the request reaches the network process, so the resulting request is well-formed and the network process has no independent signal that the site-for-cookies is wrong. No process boundary is crossed.

Discovery reads as pattern auditing of origin-inheritance special cases, or variant analysis on prior site-for-cookies bugs in the same directory — the LayoutTests/http/tests/cookies/same-site/ suite already existed and this commit only adds the nested-srcdoc variants, the signature of someone enumerating frame nesting shapes against an existing test corpus. The bug is a pure logic issue with no crash, so fuzzing is an unlikely origin; the deliberate implicit-default positive control in both tests also suggests a human reasoning carefully about what the correct cookie set should be.

This vulnerability weakens the same-site cookie boundary — the boundary separating requests initiated by a site from requests initiated by an unrelated site embedded within it. The security model assumption at stake is that SameSite=Strict/Lax cookies are only attached to requests whose initiator belongs to the target's site; before the fix, a document that inherited its origin from a cross-origin parent was nevertheless credited with the top-level document's cookie site. The attack requires the victim origin to be the top-level document with an attacker-controlled subframe beneath it. From such a frame an attacker could have issued authenticated cross-site requests carrying the top-level origin's SameSite-restricted cookies: classic CSRF against endpoints relying on SameSite as their defense, and, where the target endpoint answers with permissive CORS plus credentials, cross-site read of authenticated responses. The mirrored arrangement — attacker as the top frame — does not yield the victim's cookies, because the srcdoc would then be stamped with the attacker's own site.

The pre-fix code used a disjunction of two predicates that answer different questions. registrableDomain.matches(url) answers "is this document part of the top-level site?" — a site membership question. shouldInheritSecurityOriginFromOwner(url) answers "does this document take its origin from somewhere else?" — a delegation question that says nothing about where. OR-ing them silently promoted "origin is delegated" to "origin is the root's", which is only true at nesting depth one; the bug therefore only appears once a second level of nesting exists, which is exactly why an existing same-site test corpus missed it. Any place that special-cases origin-inheriting URLs is worth re-reading with the question "inherits from whom?" rather than "inherits, therefore trusted".