Initiator-omitted samesite classification can lead to SameSite=Strict cookie cross-site leakage
CVE: CVE-2026-28958 · Safari 26.5 · Released May 13, 2026 Impact: An app may be able to access sensitive user data Apple's description: This issue was addressed with improved data protection. Credit: Cantina
Medium. Nothing here corrupts memory — the diff restores a policy classification, and the capability it gives back is CSRF against endpoints whose only defense is Strict. What keeps it below High is that the attacker drives an authenticated request but never gets to read the response.
Every navigation a browser issues carries a verdict about its own provenance: same-site or cross-site, computed by comparing who asked for the load against where the load is going, and consumed downstream by the cookie layer. In WebKit that verdict is stamped onto the ResourceRequest as it travels through FrameLoader, and it is stamped in more than one place — an early pass in FrameLoader::load and a later, better-informed pass in updateRequestAndAddExtraFields. The invariant holding the two passes together is a tri-state: the disposition starts unspecified, and only a stage that actually knows the initiator is entitled to resolve it.
The angle: An attacker page that can steer a top-level navigation to a victim site gets that request to carry the victim's SameSite=Strict cookies — an authenticated cross-site request against endpoints whose only CSRF defense is Strict.
Source/WebCore/loader/FrameLoader.cpp
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm
Patch Details
The production change is a single call site. WebCore::FrameLoader::load(FrameLoadRequest&&, std::optional<NavigationRequirement>...) previously called addSameSiteInfoToRequestIfNeeded(loader->request()) — the one-argument form, which supplies no initiator document. The patch hoists the requester document out of the FrameLoadRequest (Ref initiator = request.requester()) and passes it as the second argument, so the helper computes the disposition from a real initiator rather than falling back to its no-initiator default.
The conditional wrapped around that argument is the second half of the fix. When SecurityPolicy::shouldInheritSecurityOriginFromOwner(initiator->url()) is true — the initiator's URL is about:blank or empty, meaning its origin was inherited from a creating context rather than derived from the URL — the patch passes nullptr instead, deliberately keeping the old permissive default for that case. A frame's initial empty document has exactly such a URL, and comparing it against a target site would produce a verdict about a string, not about a principal.
Note that request.requester() returns a Document&, not a pointer: the Ref initiator binding is unconditional, which is what makes the omission at this call site so quiet. The information the helper needed was already sitting in the object being passed around; the call simply didn't reach for it.
The rest of the diff is coverage. TEST(WKHTTPCookieStore, SameSiteStrictCookieNotSentOnCrossSiteNavigation) stands up a coroutine-driven HTTPServer behind a proxy configuration so that two distinct hostnames — victim.example and attacker.example — resolve to the same test server, then drives a WKWebView through a four-navigation sequence and asserts on whether id=secret appears in the request headers each time.
Background
SameSite cookies. SameSite is a cookie attribute with three values — Strict, Lax, and None — that constrains when the browser attaches a cookie based on the relationship between the requesting context and the cookie's own site. Lax allows the cookie on top-level navigations; Strict withholds it even there, attaching it only when the request's site-for-cookies matches. That extra strictness is precisely why Strict is deployed as a standalone CSRF defense on sensitive endpoints: a site can rely on it without also carrying a token.
Site-for-cookies and registrable domains. The comparison SameSite performs is not origin equality. It is registrable-domain equality — eTLD+1 — between the initiator's site and the request URL's site. a.victim.example and b.victim.example are the same site; attacker.example and victim.example are not.
The initiator (requester) document. A FrameLoadRequest carries a requester Document: the document whose script, markup, or user-gesture context caused the load to be issued. It is the input that answers "same-site relative to what?" — without it, there is no left-hand operand for the registrable-domain comparison.
The tri-state disposition on ResourceRequest. The same-site flag on a request is not a boolean. It has three states — same-site, cross-site, and unspecified, the last meaning "no stage has decided yet" and queried through isSameSiteUnspecified(). addSameSiteInfoToRequestIfNeeded(request, initiator) is a fill-in-if-unset helper over that tri-state: it returns immediately when the disposition is already set, and otherwise computes and writes one. Its initiator parameter is optional, because some loads genuinely have no document behind them.
updateRequestAndAddExtraFields. Further down the loader pipeline sits a stage that stamps request-level fields onto the outgoing request — extra headers, policy flags, and an initiator-aware same-site recomputation. That recomputation is gated: it runs only while the disposition is still unspecified.
Inherited origins. SecurityPolicy::shouldInheritSecurityOriginFromOwner(url) returns true for URLs whose origin comes from the creating context rather than from the URL itself — about:blank and empty URLs. Such a document's URL is not a site identifier in any meaningful sense.
Navigation flow. FrameLoader::load builds a DocumentLoader around the request, applies its policy stamps, and proceeds through policy checks toward the network process, which consults the cookie store using the request's site-for-cookies and its same-site disposition as ground truth.
Analysis
The bug is a sentinel-consumption error: an early, low-information writer resolved a tri-state that a later, better-informed writer was waiting to resolve.
Before After
────────────────────────────── ──────────────────────────────
FrameLoader::load FrameLoader::load
addSameSiteInfo(req) addSameSiteInfo(req, initiator)
no initiator → true eTLD+1 compare → false
sentinel cleared sentinel cleared
updateRequestAndAddExtraFields updateRequestAndAddExtraFields
isSameSiteUnspecified()? no isSameSiteUnspecified()? no
recompute SKIPPED recompute SKIPPED
network: Strict cookie SENT network: Strict cookie WITHHELD
Both columns of the diagram skip the recomputation — that is the point. The fix does not restore the later stage's ability to run; it makes the earlier stage's answer correct, because by the time updateRequestAndAddExtraFields looks at the gate, the value has already been pinned either way. What changes is which value gets pinned. In the left column, the no-initiator form of addSameSiteInfoToRequestIfNeeded unconditionally writes isSameSite = true, and that write does double damage: it asserts a same-site verdict that nothing computed, and it clears the unspecified state that was the only signal telling the downstream stage it still had work to do. In the right column the same write happens at the same moment, but it is now the product of an actual registrable-domain comparison against the requester document.
The reason this reads as "harmless" at the call site is the shape of the helper. A fill-in-if-unset function looks idempotent — call it twice, the second call is a no-op — which invites callers to invoke it early and defensively. The idempotence is real but it points the wrong way: the first writer wins, so calling early with less information than a later stage possesses is not a conservative default, it is a preemption. The permissiveness of the no-initiator branch turns that preemption into a security failure, because the fallback resolves toward the trusted answer rather than the untrusted one. An absent principal was treated as a trusted principal.
The test in the diff makes the resulting capability concrete, and it is worth reading as a proof-of-concept rather than as coverage:
- Navigate to
victim.example/setcookie, which responds withSet-Cookie: id=secret; Path=/; SameSite=Strict. - Navigate to
victim.example/same-site-check. The server checks forid=secretin the request;EXPECT_TRUE— Strict cookies must still work for genuine same-site navigation. - Navigate to
attacker.example/attacker, establishing an attacker-origin document as the current frame content, and therefore as the requester for whatever it navigates to next. - Navigate to
victim.example/cross-site-check.EXPECT_FALSE(crossSiteCheckHasCookie)— the Strict cookie must not ride along.
Step 4 is the vulnerability. Before the fix, the requester at that point is an attacker.example document and the target is victim.example, a plainly cross-site pair, yet the request left FrameLoader::load stamped same-site and arrived at the network process's cookie-attachment decision carrying that stamp as ground truth. Step 2 is why the fix could not simply hard-code cross-site or drop the early call: the same code path serves legitimate same-site navigations, and the disposition has to be computed, not defaulted in either direction.
The capability this yields is policy-level, not memory-level. An attacker page able to drive a navigation to a victim site — the ordinary reach of any page that controls a link, a form target, window.location, or a frame it owns — obtains an authenticated cross-site request primitive against endpoints whose only CSRF defense is SameSite=Strict. The attacker does not get to read the cross-origin response; the same-origin policy still holds on that side. Any actual disclosure has to come from the victim endpoint's own behavior — a redirect chain that lands somewhere observable, a token reflected into a URL, or side effects of the authenticated request that the attacker can measure. What the attacker reliably gets is state change under the victim's identity, which is the whole threat model Strict was deployed against.
The process picture is worth being precise about, since Apple's advisory wording ("An app may be able to access sensitive user data") flattens it. The misclassification originates in the WebContent process, where FrameLoader assembles the request; the consequence lands in the Networking process, which consumes the disposition without re-deriving it. No sandbox is escaped and none needs to be — the boundary crossed here is the same-site cookie boundary, not a process boundary. That asymmetry is itself the interesting part: a field stamped by the less-trusted process is treated as authoritative by the more-trusted one, so a logic error on the producing side becomes a policy bypass on the consuming side with no memory corruption anywhere in the chain.
The shouldInheritSecurityOriginFromOwner guard closes the correctness gap the fix would otherwise open. Once initiator URLs start being used as site identities, URLs that carry no intrinsic identity become invalid inputs. A frame's initial empty document has an about:blank URL whose origin was inherited from its creator; comparing that URL's registrable domain against a target would classify a perfectly ordinary fresh navigation as cross-site. Passing nullptr in that case routes those loads back through the old same-site default, which is the correct disposition for a navigation that has no prior site to be cross to.
A navigation classified as same-site without ever consulting its initiator handed cross-site page loads the target site's SameSite=Strict cookies.
Insight
A fill-in-if-unset helper is not idempotent in the way its name suggests — it is a first-writer-wins race between pipeline stages, and the stage with the least information usually runs first. Takeaway: when a policy field has an explicit "unset" state, treat every write as a claim of authority, and before adding an early defensive call, check whether the stage you are writing from actually holds the inputs the late gate was waiting for.
Audit directions
-
Optional caller-identity parameters whose absent form takes the permissive branch. The invariant: a security decision made without the identity of the requester must fail closed, not open. Narrow — grep
Source/WebCore/loaderfor every call toaddSameSiteInfoToRequestIfNeededand check whether an initiator document was reachable at that call site but not passed; the tell is a single-argument call inside a function whose enclosing scope already holds aFrameLoadRequest,Document, orCachedResourceRequest. Wider — the same shape appears in any WebCore function with a defaultedconst Document* initiator,SecurityOrigin*,sourceOrigin, ortriggeringDocumentparameter; auditPingLoader,CachedResourceLoader::requestResource, redirect handling inSubresourceLoader, and theNavigationSchedulerentry points, looking for a defaulted pointer consumed by anif (!param) { <permissive default> }branch. Widest — this is the general "absent principal treated as trusted" class and it holds anywhere a policy check accepts optional caller context: Chromium's initiator-origin plumbing, Gecko's triggering principal,SECURITY DEFINERroutines in databases, proxies that drop forwarding headers. Carry the question across codebases: if the identity argument is optional, find the null branch and ask whether it is the most permissive outcome. -
Tri-state policy fields whose "unspecified" sentinel is consumed by an under-informed writer. The invariant: only the stage holding full information may clear the sentinel. Narrow — grep
Source/WebCore/platform/network/ResourceRequestBase.*for the same-site tri-state accessors (isSameSiteUnspecified,setIsSameSite) and enumerate every writer, checking each against whether that stage actually has the initiator in hand. Wider — search the loader for theif (!x.isUnspecified()) return;early-out idiom applied to other layered request policies: referrer policy resolution, credentials mode, CORP/COEP stamping,ShouldOpenExternalURLsPolicy. For each, identify the intended authoritative writer; the tell is two writers to one field where the earlier one is reachable on strictly more code paths than the later one. Widest — this is the "default poisons late-binding resolution" class, applicable to any layered configuration system where a sentinel distinguishes unset from explicitly set to the default value: HTTP header defaulting, feature-flag layering, Kubernetes admission-controller defaulting webhooks. -
Inherited-origin URLs used as intrinsic site identity. Narrow — grep for callers of
SecurityPolicy::shouldInheritSecurityOriginFromOwner, and conversely for code that comparesdocument->url()(rather than a resolved origin) against a request URL for cookie or site-for-cookies purposes; the tell is a registrable-domain comparison whose left operand is a raw document URL with noabout:blank/empty carve-out. Wider — extend the same check to the other schemes with inherited or opaque origins that flow through those comparisons:srcdociframes,blob:,data:, andjavascript:initiators, verifying each yields the intended disposition rather than an accidental cross-site or accidental same-site verdict; in search results, the shape to notice is a same-site or same-origin decision keyed on a URL string whose origin was inherited rather than derived. Widest — this is the "inherited context misread as intrinsic identity" class, present wherever a child inherits its principal from a creator: Chromium's initiator origin, Gecko's triggering principal, sandboxed-iframe opaque origins, OS-level process credential inheritance. The invariant to carry: any identity comparison must operate on the resolved principal, never on the surface identifier the principal was inherited around.