← All reports

Initiator-omitted samesite classification can lead to SameSite=Strict cookie cross-site leakage

MediumWebCore loader /CrossOrigin

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

Severity: Medium | Component: WebCore loader / FrameLoader | 093f346 | Bugzilla 311228

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

void FrameLoader::load(FrameLoadRequest&& request, std::optional<NavigationRequirement> ...)
...
if (auto advancedPrivacyProtections = request.advancedPrivacyProtections())
loader->setOriginatorAdvancedPrivacyProtections(*advancedPrivacyProtections);
- addSameSiteInfoToRequestIfNeeded(loader->request());
+ Ref initiator = request.requester();
+ addSameSiteInfoToRequestIfNeeded(loader->request(), SecurityPolicy::shouldInheritSecurityOriginFromOwner(initiator->url()) ? nullptr : initiator.ptr());
applyShouldOpenExternalURLsPolicyToNewDocumentLoader(protect(m_frame), loader, request);

Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm

+TEST(WKHTTPCookieStore, SameSiteStrictCookieNotSentOnCrossSiteNavigation)
+{
+ if (path.endsWith("/setcookie"_s)) {
+ co_await connection.awaitableSend(
+ "HTTP/1.1 200 OK\r\n"
+ "Content-Length: 4\r\n"
+ "Set-Cookie: id=secret; Path=/; SameSite=Strict\r\n"
+ "\r\n"
+ "Done"_s);
+ } else if (path.endsWith("/cross-site-check"_s)) {
+ crossSiteCheckHasCookie = contains(request.span(), "id=secret"_span);
...
+ [webView loadRequest:... @"http://victim.example:%d/same-site-check" ...];
+ EXPECT_TRUE(sameSiteCheckHasCookie);
+
+ [webView loadRequest:... @"http://attacker.example:%d/attacker" ...];
+ [webView _test_waitForDidFinishNavigation];
+
+ [webView loadRequest:... @"http://victim.example:%d/cross-site-check" ...];
+ Util::run(&receivedCrossSiteCheck);
+ EXPECT_FALSE(crossSiteCheckHasCookie);
+}

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.

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.

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:

  1. Navigate to victim.example/setcookie, which responds with Set-Cookie: id=secret; Path=/; SameSite=Strict.
  2. Navigate to victim.example/same-site-check. The server checks for id=secret in the request; EXPECT_TRUE — Strict cookies must still work for genuine same-site navigation.
  3. 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.
  4. 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.

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.