093f34607a3e8c6bdf6fbe178a68ca96ecc067c6 Initiator-omitted samesite classification can lead to SameSite=Strict cookie cross-site leakage
Triage note: FrameLoader::load called addSameSiteInfoToRequestIfNeeded without an initiator, so initiator-omitted navigations were classified as same-site; fix passes request.requester() (unless it inherits origin from owner), preventing cross-site sending of SameSite=Strict cookies.
Contents
The bug at a glance
Any web page can trigger the vulnerable path by causing a browser-initiated top-level navigation (the common case for user-typed URLs, bookmarks, and reloads) to a victim origin, causing SameSite=Strict cookies to be transmitted even though the navigation crossed a site boundary. The impact is confidentiality-only leakage of authentication and session cookies that the SameSite=Strict directive is specifically meant to withhold on cross-site navigations, enabling CSRF-style and session-fixation follow-ons; there is no integrity or availability impact and no code execution, which keeps it at Medium/6.5.
SameSite=Strict is supposed to be the one cookie attribute a developer can trust to never ride along on a cross-site navigation. This bug shows that WebKit’s own navigation entry point quietly told the cookie layer “this is same-site” for every load that lacked an initiator document, so the later, correct, initiator-aware classification never got to run. The result is that a Strict cookie set by victim.example is attached to a victim.example request made immediately after the user was on attacker.example, defeating the entire point of the attribute. The fix is a one-line change to pass the real requester document, but the mechanism is a textbook case of a defaulted-to-safe value that was actually defaulted-to-unsafe.
Root cause
The SameSite disposition of an outgoing request is computed from an initiator document. WebCore models three states via NetworkStorageSession: same-site, cross-site, and “unspecified”. addSameSiteInfoToRequestIfNeeded stamps a disposition onto a ResourceRequest; if it is called with no initiator it takes the conservative-for-functionality-but-dangerous-for-privacy default of forcing isSameSite = true on the request. A request whose SameSite state has already been concretely set (true or false) is no longer isSameSiteUnspecified, so downstream code will not revisit it.
In FrameLoader::load (Source/WebCore/loader/FrameLoader.cpp), the pre-patch code called addSameSiteInfoToRequestIfNeeded(loader->request()) with a single argument and thus no initiator. This unconditionally set isSameSite = true on the document loader’s request. The later, initiator-aware recomputation performed in updateRequestAndAddExtraFields is gated on the request still being isSameSiteUnspecified; because FrameLoader::load had already pinned the value to true, that gate failed and the correct cross-site determination never executed.
The reaching path is any navigation that flows through FrameLoader::load without an explicit initiator being threaded into the SameSite call, most importantly browser/embedder-initiated top-level navigations (the WKWebView loadRequest: path exercised by the regression test). Such a load to victim.example, following a document on attacker.example, would be classified as same-site and would therefore include victim.example’s SameSite=Strict cookies, leaking them across the site boundary.
The fix threads the FrameLoadRequest’s requester document into the call: Ref initiator = request.requester(); then addSameSiteInfoToRequestIfNeeded(loader->request(), SecurityPolicy::shouldInheritSecurityOriginFromOwner(initiator->url()) ? nullptr : initiator.ptr()). When a real requester exists, its origin is used to compute the disposition correctly (yielding cross-site, hence dropping Strict cookies). When the requester is an initial/empty document (about:blank or empty URL, per shouldInheritSecurityOriginFromOwner), nullptr is passed to preserve the same-site default that fresh navigations legitimately need.
Key code
FrameLoader::load now passes the requester document as the SameSite initiator (nullptr only for initial documents).
- addSameSiteInfoToRequestIfNeeded(loader->request());
+ Ref initiator = request.requester();
+ addSameSiteInfoToRequestIfNeeded(loader->request(), SecurityPolicy::shouldInheritSecurityOriginFromOwner(initiator->url()) ? nullptr : initiator.ptr());
Patch walkthrough
Source/WebCore/loader/FrameLoader.cpp— InFrameLoader::load, the bareaddSameSiteInfoToRequestIfNeeded(loader->request())call is replaced with one that capturesrequest.requester()asinitiatorand forwards it as the second argument. A guard onSecurityPolicy::shouldInheritSecurityOriginFromOwner(initiator->url())passesnullptrfor initial documents (about:blank/empty) so their fresh navigations keep the same-site default, and otherwise passesinitiator.ptr()so the disposition is computed against the real requester origin. This lets the request remain SameSite-unspecified when appropriate so the laterupdateRequestAndAddExtraFieldsrecomputation runs.Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm— AddsTEST(WKHTTPCookieStore, SameSiteStrictCookieNotSentOnCrossSiteNavigation). It sets aSameSite=Strictcookie on victim.example, confirms it is sent on a same-site follow-up navigation (EXPECT_TRUE(sameSiteCheckHasCookie)), navigates to attacker.example, then navigates back to a victim.example check endpoint and asserts the Strict cookie is NOT present (EXPECT_FALSE(crossSiteCheckHasCookie)). The cross-site assertion is what fails before the patch.
Background
SameSite=Strict — A cookie attribute instructing the browser to withhold the cookie from any request whose top-level browsing context site differs from the cookie’s site, including on cross-site top-level navigations. It is the primary declarative defense against CSRF and cross-site session leakage.
isSameSiteUnspecified / addSameSiteInfoToRequestIfNeeded — WebCore tracks a request’s SameSite disposition as same-site, cross-site, or unspecified. addSameSiteInfoToRequestIfNeeded sets it from an initiator origin, or forces same-site when no initiator is supplied. Only requests still marked unspecified are later recomputed.
updateRequestAndAddExtraFields — The FrameLoader routine that, among other extra-field work, performs the initiator-aware SameSite recomputation. It only acts when the request is still isSameSiteUnspecified, so an early forced disposition permanently suppresses it.
SecurityPolicy::shouldInheritSecurityOriginFromOwner — Returns true for URLs like about:blank and the empty URL whose documents inherit their security origin from an owning/creator document. The patch uses it to distinguish a genuine requester from an initial document that should retain the same-site default.
Vulnerability window
- Cookie planted — The user visits victim.example, which sets
id=secret; SameSite=Strict. - Attacker context — The user later navigates to attacker.example (or an attacker-controlled page is the current document).
- Cross-site navigation — A browser-initiated top-level navigation to victim.example flows through
FrameLoader::loadwith no initiator threaded into the SameSite call. - Misclassification —
addSameSiteInfoToRequestIfNeededforcesisSameSite = true, clearing the unspecified state and suppressing the initiator-aware recomputation inupdateRequestAndAddExtraFields. - Leak — The request to victim.example is treated as same-site and the
SameSite=Strictcookie is attached and sent across the site boundary. - Fix — The patch forwards
request.requester()(nullptr only for initial documents), so the disposition is computed as cross-site and the Strict cookie is correctly withheld.
Proof of concept
The patch’s own TestWebKitAPI test is a working proof: it plants a Strict cookie, verifies it rides same-site requests, then shows that after visiting attacker.example a subsequent load of victim.example must not carry the cookie. On the vulnerable build the /cross-site-check request arrives with id=secret; on the fixed build it does not. No memory primitives are involved; the observable is the presence of the cookie header on the server side.
// Reconstructed from the regression test.
// 1. Set a SameSite=Strict cookie on victim.example.
[webView loadRequest:[NSURLRequest requestWithURL:
[NSURL URLWithString:@"http://victim.example/setcookie"]]];
[webView _test_waitForDidFinishNavigation];
// 2. Same-site navigation: cookie is (correctly) present.
[webView loadRequest:[NSURLRequest requestWithURL:
[NSURL URLWithString:@"http://victim.example/same-site-check"]]];
// server observes id=secret -> EXPECT_TRUE
// 3. Move to attacker origin.
[webView loadRequest:[NSURLRequest requestWithURL:
[NSURL URLWithString:@"http://attacker.example/attacker"]]];
[webView _test_waitForDidFinishNavigation];
// 4. Cross-site navigation back to victim.example.
[webView loadRequest:[NSURLRequest requestWithURL:
[NSURL URLWithString:@"http://victim.example/cross-site-check"]]];
// Pre-patch: server observes id=secret (LEAK). Post-patch: absent -> EXPECT_FALSE
Exploitation
- Setup — The attacker relies on the victim having an authenticated session cookie on a target site that is marked SameSite=Strict, and on being able to trigger a top-level navigation to that site through a path that reaches FrameLoader::load without an initiator (embedder-driven loads, certain address-bar/bookmark style loads).
- Trigger — From an attacker-controlled document, cause a navigation to the target origin. Because the load is misclassified as same-site, the Strict cookie is attached to the outgoing request.
- Payoff — A server the attacker can observe (or a target endpoint that reflects state) receives the Strict-scoped cookie, enabling CSRF against Strict-protected endpoints or theft/confirmation of the session identifier that Strict was meant to keep from cross-site contexts.
Detection & hunting
For defenders and SOC / detection engineers:
- Server-side cookie logs —
- FrameLoader instrumentation —
- Regression test —
Audit directions
- Other callers of addSameSiteInfoToRequestIfNeeded —
- Initiator threading through navigation entry points —
- isSameSiteUnspecified gating —