[4] Srcdoc iframes inherit the top-level site for cookies
A srcdoc frame inherited its origin from one parent and its cookies from another.
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.
Commit message
When we set
firstPartyForCookieson a subframe, we check if either:
shouldInheritSecurityOriginFromOwneris true for the current document's URL, or- the current document's URL is same-registrable-domain as the top-level document URL
In the case of an iframe with srcdoc,
shouldInheritSecurityOriginFromOwnerreturns true (as documented), and this causes us to set the page'smainFrameURLas thefirstPartyForCookies. We need a conditional exception forshouldInheritSecurityOriginFromOwner, but it should take nested iframes into account. This patch adjusts the logic so we inherit the ancestor frame'ssiteForCookiesinstead of the page's URL. The same-registrable-domain check remains unchanged.
Source/WebCore/loader/FrameLoader.cpp
LayoutTests/http/tests/cookies/same-site/resources/srcdoc-creator-inside-cross-origin-iframe.html
Patch Details
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.
Background
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>.
Analysis
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:
- The victim page loads at
http://127.0.0.1:8000and holds cookiesstrict(SameSite=Strict),lax(SameSite=Lax) andimplicit-default(no SameSite) for that host. - 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. - That attacker-controlled document creates
<iframe srcdoc=...>; the nested document's URL isabout:srcdocand its origin is inherited fromlocalhost:8000. - When
FrameLoader::setFirstPartyForCookieswalks the tree, the pre-fix predicate short-circuits the OR and the srcdocDocumentreceivessetSiteForCookies(url), whereurlis the top-level127.0.0.1:8000URL. - 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 with127.0.0.1and attachesstrictandlax.
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.
Insight
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".
Audit directions
-
A predicate that establishes delegation read as if it established a destination — "this value is inherited" treated as "this value equals the root's". Inheritance queries must be resolved against the actual ancestor that supplies the value, never against a convenient global default. Narrow: grep
Source/WebCoreandSource/WebKitforshouldInheritSecurityOriginFromOwnerand audit each consumer for whether it resolves the inherited value from a specific parent/creator/requester handle or substitutes a tree-wide default; extend the same reading to other per-document policy state pushed down frame trees (referrer policy, CSP inheritance, sandbox flags, COEP/COOP, storage partition keys). Wider: the same shape appears in any "inherits from owner" special case in WebKit —about:blankanddata:document creation inDocumentLoader/FrameLoader::init, origin propagation intoDocument::initSecurityContext— look for a boolean inheritance predicate whose true-branch writes a value not derived from the owner. Widest: this is the general "inheritance predicate conflated with inheritance source" class; the invariant transfers to any hierarchical policy system — CSS cascade inheritance, filesystem ACL inheritance, Kubernetes namespace policy inheritance, OAuth scope delegation chains. Match tell on each rung: a branch whose condition mentions inheritance or delegation but whose body references a root/global/default variable instead of the parent handle. -
Security state propagated by a top-down tree walk where the per-node decision is computed from the root rather than from the already-computed parent value, so nesting depth changes correctness. A fold over a tree must consume the parent's computed result, not the seed. Narrow: audit the rest of
FrameLoader::setFirstPartyForCookiesand its neighbours inSource/WebCore/loader/FrameLoader.cpp— verify the descendant traversal visits parents before children so a parent'ssiteForCookiesis already updated when a child reads it, since the fixed code now depends on that ordering; also check what happens on frame reparenting or when a srcdoc is created after the loop last ran. Wider: apply the same reading to other frame-tree-wide pushes in WebCore — sandbox flag propagation viaFrameTree/HTMLFrameOwnerElement,Page-level settings pushed to all frames, and any loop of the formfor (descendant of tree) descendant->setX(rootValue). Widest: the "tree fold seeded from the root instead of the parent" class recurs in policy inheritance engines, permission propagation in actor/DI hierarchies, and cascading config resolvers. Match tell: a traversal loop whose body writes a value captured from outside the loop rather than read fromnode->parent(). -
A security-relevant assignment silently skipped when a cross-process downcast fails, leaving stale state that then feeds a policy decision. A policy update must not become a no-op merely because the neighbouring node is remote. Narrow: examine the new branch —
if (RefPtr parent = dynamicDowncast<LocalFrame>(localFrame->tree().parent()))has no else; determine whatsiteForCookiesan origin-inheriting document holds when its parent is aRemoteFrameunder site isolation, and whether the value set at document construction is correct in that configuration. Wider: grepSource/WebCoreandSource/WebKitfordynamicDowncast<LocalFrame>(...->tree().parent())anddynamicDowncast<LocalFrame>(...->tree().top())in security-decision code paths, and check each failure path for a defined fallback rather than an implicit skip. Widest: the "capability check degrades to silent no-op on the unhandled variant" class appears wherever a type-narrowing cast, a feature probe, or an RPC availability check guards a security tightening step;if (downcast succeeded) { tighten(); }with no else is a fail-open. Match tell on the widest rung: any conditional whose body strengthens a restriction and whose implicit else preserves the weaker prior state. -
Policy predicates OR-ed together that answer different questions and share one consequence. A disjunction is only sound when every disjunct independently justifies the whole action. Narrow: review
Source/WebCore/loaderandSource/WebCore/page/SecurityPolicy.cppforif (A(url) || B(url))shapes in origin/cookie/CSP decisions and check, for each disjunct in isolation, whether it justifies the strongest thing the body does — the pre-fix line here is the template. Wider: the same shape recurs inSecurityOrigin::canAccess-style checks and mixed-content/upgrade decisions where a scheme test is OR-ed with an origin test; the two disjuncts often have different proof strength and the body was written for the stronger one. Widest: the "disjunction of heterogeneous justifications" class applies to any authorization check combining unrelated grounds (role check OR ownership check OR feature flag) in one branch, in any policy engine or ACL evaluator. Match tell: a boolean OR whose operands have different subject matter — one about identity, one about location or scheme or type — feeding a single privilege-granting statement.