CVE-2026-28958
Overview
Background
- SameSite cookies
- A cookie attribute (Strict/Lax/None) controlling whether a cookie is sent on cross-site requests; a core CSRF/tracking defense.
- Same-site vs same-origin
- Same-site compares registrable domains (site); same-origin compares scheme+host+port. SameSite eligibility is a site-level check that depends on the request initiator.
- Request initiator / requester
- The document that initiated a load; its site is what a request should be classified same-site against.
- shouldInheritSecurityOriginFromOwner
- True for about:srcdoc/about:blank documents that inherit their owner’s origin; such initiators are handled conservatively (nullptr) here.
Root Cause Analysis
FrameLoader::load computed and attached SameSite cookie information to the outgoing request by calling addSameSiteInfoToRequestIfNeeded(loader->request()) WITHOUT passing the request’s initiator. As the commit title states (‘Initiator-omitted samesite classification can lead to SameSite=Strict cookie cross-site leakage’), omitting the initiator means the same-site determination is not made against the document that actually initiated the load, so a cross-site request could be misclassified as same-site and therefore have the target origin’s SameSite=Strict/Lax cookies attached — leaking those cookies to a cross-site context.
The fix takes Ref initiator = request.requester() and passes it to addSameSiteInfoToRequestIfNeeded, but passes nullptr when the initiator inherits its security origin from its owner (about:srcdoc/about:blank, via SecurityPolicy::shouldInheritSecurityOriginFromOwner) so such frames are treated conservatively instead of as a concrete same-site initiator.
The restored invariant is that SameSite eligibility is computed against the true initiating document’s site. INFERENCE: the addSameSiteInfoToRequestIfNeeded overload that consumes the initiator and the exact same-site comparison are outside this hunk; the diff establishes that the initiator was omitted and is now supplied.
Attack Path
- Attacker initiates a cross-site load An attacker-controlled document triggers a navigation/subresource load targeting a victim origin for which the user holds SameSite=Strict/Lax cookies.
- Same-site classification omits the initiator Pre-patch, addSameSiteInfoToRequestIfNeeded is called without the initiator, so the request’s same-site status is computed on a default/omitted basis.
- Cross-site request misclassified as same-site The classification treats the request as same-site, making SameSite-restricted cookies eligible.
- Leak SameSite cookies cross-site The victim origin’s SameSite=Strict/Lax cookies are attached to the cross-site request and delivered to the attacker-influenced endpoint, defeating the CSRF/cookie isolation the SameSite attribute provides.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
whileTools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm |
modified |
Files Changed
Source/WebCore/loader/FrameLoader.cppTools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm
Audit Directions
- Other addSameSiteInfoToRequestIfNeeded callersGrep every call to addSameSiteInfoToRequestIfNeeded and confirm each passes a concrete initiator; any call that omits it (single-argument form) is a candidate for the same misclassification.
- First-party / same-site computed without the initiatorLook for isSameSite / firstPartyForCookies / siteForCookies decisions in the loader and cookie layers that default the initiator or use the target URL alone; the tell is a same-site check lacking the requesting document.
- Origin-inheriting frames in cookie logicAudit how about:srcdoc/about:blank (shouldInheritSecurityOriginFromOwner) initiators are treated across the cookie/same-site paths; ensure they are handled conservatively rather than as a concrete same-site party (cross-reference CVE-2026-64728).
Patch
diff --git a/Source/WebCore/loader/FrameLoader.cpp b/Source/WebCore/loader/FrameLoader.cpp
index 027a2c193f9c..e7a2f13a4af4 100644
--- a/Source/WebCore/loader/FrameLoader.cpp
+++ b/Source/WebCore/loader/FrameLoader.cpp
@@ -1816,7 +1816,8 @@ void FrameLoader::load(FrameLoadRequest&& request, std::optional<NavigationReque
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);
loader->setIsHandledByAboutSchemeHandler(request.isHandledByAboutSchemeHandler());
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm
index 2af5a9a27177..84aa1fa89fd9 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm
@@ -1227,3 +1227,62 @@ TEST(WKHTTPCookieStore, DeletePartitionedCookie)
Util::run(&done);
}
#endif // ENABLE(OPT_IN_PARTITIONED_COOKIES)
+
+TEST(WKHTTPCookieStore, SameSiteStrictCookieNotSentOnCrossSiteNavigation)
+{
+ using namespace TestWebKitAPI;
+ bool receivedSameSiteCheck { false };
+ bool sameSiteCheckHasCookie { false };
+ bool receivedCrossSiteCheck { false };
+ bool crossSiteCheckHasCookie { false };
+ HTTPServer server(HTTPServer::UseCoroutines::Yes, [&](Connection connection) -> ConnectionTask {
+ while (true) {
+ auto request = co_await connection.awaitableReceiveHTTPRequest();
+ auto path = HTTPServer::parsePath(request);
+ request.append(0);
+
+ 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("/attacker"_s)) {
+ co_await connection.awaitableSend(HTTPResponse("<html><body>attacker page</body></html>"_s).serialize());
+ } else if (path.endsWith("/same-site-check"_s)) {
+ sameSiteCheckHasCookie = contains(request.span(), "id=secret"_span);
+ co_await connection.awaitableSend(HTTPResponse("checked"_s).serialize());
+ receivedSameSiteCheck = true;
+ } else if (path.endsWith("/cross-site-check"_s)) {
+ crossSiteCheckHasCookie = contains(request.span(), "id=secret"_span);
+ co_await connection.awaitableSend(HTTPResponse("checked"_s).serialize());
+ receivedCrossSiteCheck = true;
+ }
+ }
+ });
+
+ RetainPtr storeConfiguration = adoptNS([[_WKWebsiteDataStoreConfiguration alloc] initNonPersistentConfiguration]);
+ [storeConfiguration setProxyConfiguration:@{
+ (NSString *)kCFStreamPropertyHTTPProxyHost: @"127.0.0.1",
+ (NSString *)kCFStreamPropertyHTTPProxyPort: @(server.port()),
+ }];
+ RetainPtr dataStore = adoptNS([[WKWebsiteDataStore alloc] _initWithConfiguration:storeConfiguration.get()]);
+ RetainPtr configuration = adoptNS([WKWebViewConfiguration new]);
+ [configuration setWebsiteDataStore:dataStore.get()];
+ RetainPtr webView = adoptNS([[WKWebView alloc] initWithFrame:CGRectZero configuration:configuration.get()]);
+
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"http://victim.example:%d/setcookie", server.port()]]]];
+ [webView _test_waitForDidFinishNavigation];
+
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"http://victim.example:%d/same-site-check", server.port()]]]];
+ Util::run(&receivedSameSiteCheck);
+ EXPECT_TRUE(sameSiteCheckHasCookie);
+
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"http://attacker.example:%d/attacker", server.port()]]]];
+ [webView _test_waitForDidFinishNavigation];
+
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"http://victim.example:%d/cross-site-check", server.port()]]]];
+ Util::run(&receivedCrossSiteCheck);
+ EXPECT_FALSE(crossSiteCheckHasCookie);
+}
Regression Test / PoC
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm
index 2af5a9a27177..84aa1fa89fd9 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm
@@ -1227,3 +1227,62 @@ TEST(WKHTTPCookieStore, DeletePartitionedCookie)
Util::run(&done);
}
#endif // ENABLE(OPT_IN_PARTITIONED_COOKIES)
+
+TEST(WKHTTPCookieStore, SameSiteStrictCookieNotSentOnCrossSiteNavigation)
+{
+ using namespace TestWebKitAPI;
+ bool receivedSameSiteCheck { false };
+ bool sameSiteCheckHasCookie { false };
+ bool receivedCrossSiteCheck { false };
+ bool crossSiteCheckHasCookie { false };
+ HTTPServer server(HTTPServer::UseCoroutines::Yes, [&](Connection connection) -> ConnectionTask {
+ while (true) {
+ auto request = co_await connection.awaitableReceiveHTTPRequest();
+ auto path = HTTPServer::parsePath(request);
+ request.append(0);
+
+ 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("/attacker"_s)) {
+ co_await connection.awaitableSend(HTTPResponse("<html><body>attacker page</body></html>"_s).serialize());
+ } else if (path.endsWith("/same-site-check"_s)) {
+ sameSiteCheckHasCookie = contains(request.span(), "id=secret"_span);
+ co_await connection.awaitableSend(HTTPResponse("checked"_s).serialize());
+ receivedSameSiteCheck = true;
+ } else if (path.endsWith("/cross-site-check"_s)) {
+ crossSiteCheckHasCookie = contains(request.span(), "id=secret"_span);
+ co_await connection.awaitableSend(HTTPResponse("checked"_s).serialize());
+ receivedCrossSiteCheck = true;
+ }
+ }
+ });
+
+ RetainPtr storeConfiguration = adoptNS([[_WKWebsiteDataStoreConfiguration alloc] initNonPersistentConfiguration]);
+ [storeConfiguration setProxyConfiguration:@{
+ (NSString *)kCFStreamPropertyHTTPProxyHost: @"127.0.0.1",
+ (NSString *)kCFStreamPropertyHTTPProxyPort: @(server.port()),
+ }];
+ RetainPtr dataStore = adoptNS([[WKWebsiteDataStore alloc] _initWithConfiguration:storeConfiguration.get()]);
+ RetainPtr configuration = adoptNS([WKWebViewConfiguration new]);
+ [configuration setWebsiteDataStore:dataStore.get()];
+ RetainPtr webView = adoptNS([[WKWebView alloc] initWithFrame:CGRectZero configuration:configuration.get()]);
+
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"http://victim.example:%d/setcookie", server.port()]]]];
+ [webView _test_waitForDidFinishNavigation];
+
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"http://victim.example:%d/same-site-check", server.port()]]]];
+ Util::run(&receivedSameSiteCheck);
+ EXPECT_TRUE(sameSiteCheckHasCookie);
+
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"http://attacker.example:%d/attacker", server.port()]]]];
+ [webView _test_waitForDidFinishNavigation];
+
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"http://victim.example:%d/cross-site-check", server.port()]]]];
+ Util::run(&receivedCrossSiteCheck);
+ EXPECT_FALSE(crossSiteCheckHasCookie);
+}