CVE-2026-20643
Overview
Impact:
Description: Processing maliciously crafted web content may bypass Same Origin Policy
Researchers: Thomas Espach
Key insight. Navigation rewrite eligibility must require same-origin (and matching userinfo), not merely same-site; the same-site allowance let cross-origin subdomains be intercepted.
Patch Details
diff --git a/Source/WebCore/page/Navigation.cpp b/Source/WebCore/page/Navigation.cpp
index 5ec713b9e73b..bc56932bbe11 100644
--- a/Source/WebCore/page/Navigation.cpp
+++ b/Source/WebCore/page/Navigation.cpp
@@ -831,12 +831,11 @@ static bool documentCanHaveURLRewritten(const Document& document, const URL& tar
const URL& documentURL = document.url();
Ref documentOrigin = document.securityOrigin();
auto targetOrigin = SecurityOrigin::create(targetURL);
- bool isSameSite = documentOrigin->isSameSiteAs(targetOrigin);
- bool isSameOrigin = documentOrigin->isSameOriginAs(targetOrigin);
- // For cross-window navigation with document.domain, we need to check same-origin rather than same-site
- // to account for document.domain modifications that make cross-origin windows same-origin-domain
- if (!isSameSite && !isSameOrigin)
+ if (!documentOrigin->isSameOriginAs(targetOrigin))
+ return false;
+
+ if (documentURL.user() != targetURL.user() || documentURL.password() != targetURL.password())
return false;
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#can-have-its-url-rewritten
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/NavigationAPI.mm b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/NavigationAPI.mm
index f3ca187b37f6..c68902414a4c 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/NavigationAPI.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/NavigationAPI.mm
@@ -121,4 +121,74 @@ TEST(NavigationAPI, ReplaceStateAfterBackPreservesKey)
EXPECT_TRUE([keyPageA isEqualToString:keyAfterReplace]);
}
+TEST(NavigationAPI, InterceptFailsForDifferentSubdomain)
+{
+ HTTPServer server({
+ { "/page1"_s, { "page1"_s } },
+ { "/page2"_s, { "page2"_s } }
+ }, HTTPServer::Protocol::HttpsProxy);
+
+ RetainPtr configuration = server.httpsProxyConfiguration();
+ RetainPtr webView = adoptNS([[TestWKWebView alloc] initWithFrame:CGRectMake(0, 0, 300, 300) configuration:configuration.get()]);
+
+ RetainPtr navigationDelegate = adoptNS([TestNavigationDelegate new]);
+ [navigationDelegate allowAnyTLSCertificate];
+ [webView setNavigationDelegate:navigationDelegate.get()];
+
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://page1.example.com/page1"]]];
+ [navigationDelegate waitForDidFinishNavigation];
+
+ NSError *error = nil;
+ id result = [webView objectByCallingAsyncFunction:@"return await new Promise((resolve) => {"
+ " navigation.addEventListener('navigate', (event) => {"
+ " try {"
+ " event.intercept({ handler: () => {} });"
+ " resolve('intercept succeeded');"
+ " } catch (e) {"
+ " resolve('intercept failed: ' + e.name);"
+ " }"
+ " });"
+ " location.href = 'https://page2.example.com/page2';"
+ "});" withArguments:nil error:&error];
+
+ EXPECT_NULL(error);
+ EXPECT_TRUE([result isKindOfClass:[NSString class]]);
+ EXPECT_TRUE([result hasPrefix:@"intercept failed"]);
+}
+
+TEST(NavigationAPI, InterceptFailsForDifferentUsernameAndPassword)
+{
+ HTTPServer server({
+ { "/page1"_s, { "page1"_s } },
+ { "/page2"_s, { "page2"_s } }
+ }, HTTPServer::Protocol::HttpsProxy);
+
+ RetainPtr configuration = server.httpsProxyConfiguration();
+ RetainPtr webView = adoptNS([[TestWKWebView alloc] initWithFrame:CGRectMake(0, 0, 300, 300) configuration:configuration.get()]);
+
+ RetainPtr navigationDelegate = adoptNS([TestNavigationDelegate new]);
+ [navigationDelegate allowAnyTLSCertificate];
+ [webView setNavigationDelegate:navigationDelegate.get()];
+
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://username1:[email protected]/page1"]]];
+ [navigationDelegate waitForDidFinishNavigation];
+
+ NSError *error = nil;
+ id result = [webView objectByCallingAsyncFunction:@"return await new Promise((resolve) => {"
+ " navigation.addEventListener('navigate', (event) => {"
+ " try {"
+ " event.intercept({ handler: () => {} });"
+ " resolve('intercept succeeded');"
+ " } catch (e) {"
+ " resolve('intercept failed: ' + e.name);"
+ " }"
+ " });"
+ " location.href = 'https://username2:[email protected]/page2';"
+ "});" withArguments:nil error:&error];
+
+ EXPECT_NULL(error);
+ EXPECT_TRUE([result isKindOfClass:[NSString class]]);
+ EXPECT_TRUE([result hasPrefix:@"intercept failed"]);
+}
+
} // namespace TestWebKitAPI
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-20643 |
| Bugzilla | 306050 |
| Component | WebCore |
| Bug Class | CrossOrigin |
| Severity | high |
| Commit | b537a57c092d669f… |
| Advisory | Apple Advisory |
Terminology
- Navigation API intercept — navigation.intercept()/rewrite lets a page handle a navigation; only permitted for eligible (same-origin) navigations.
- Same-site vs same-origin — Same-site compares registrable domains; same-origin compares scheme+host+port. The API must gate on same-origin.
- URL userinfo — The username/password in a URL, which also participates in origin/credential separation.
Root Cause Analysis
This tightens the Navigation API’s URL-rewrite eligibility to enforce the Same-Origin Policy. documentCanHaveURLRewritten() decides whether a navigation can be intercepted/rewritten (navigation.intercept). Before the fix it allowed the rewrite when the document and target were same-SITE OR same-origin, with a comment about accommodating document.domain — so a page could intercept/rewrite a navigation to a different subdomain of the same registrable domain (same site but a different origin), a same-origin-policy bypass. It also did not compare URL credentials. The fix replaces that logic with a strict check: return false unless documentOrigin->isSameOriginAs(targetOrigin), and additionally return false if documentURL.user() != targetURL.user() or the passwords differ. The restored invariant is that the Navigation API may only rewrite/intercept navigations that are truly same-origin (and carry matching userinfo), so a page cannot use it to observe or manipulate cross-origin (cross-subdomain) navigations. The added API tests confirm intercept() now fails for a different subdomain and for differing username/password. Established by the diff.
Attack Path
1. Load attacker page on one origin
The page runs on e.g. page1.example.com and registers a navigation ’navigate’ event listener that calls event.intercept().
2. Navigate to a same-site, cross-origin target
It sets location.href to page2.example.com (same registrable domain, different origin) — or changes the URL userinfo.
3. Intercept a cross-origin navigation
Pre-patch documentCanHaveURLRewritten allowed the rewrite because it was same-site, so intercept() succeeded across origins.
4. Bypass the Same-Origin Policy
The handler runs for the cross-origin navigation, letting the page observe/control a navigation it should not, violating SOP.
Impact Assessment
A Same-Origin-Policy bypass: a page could intercept/observe cross-origin (cross-subdomain) navigations; high. Information disclosure / navigation manipulation across origins, no memory safety.
Audit Directions
same-site used where same-origin is required
Grep isSameSiteAs used in security decisions that should use isSameOriginAs (navigation, messaging, storage).
URL credential comparisons
Check origin/eligibility checks that ignore url.user()/password().
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
documentCanHaveURLRewritten |
Source/WebCore/page/Navigation.cpp |
modified | Replaces the same-site-or-same-origin test with a strict same-origin requirement and additionally rejects when URL username/password differ, so the Navigation API can no longer rewrite/intercept cross-origin (cross-subdomain) navigations. |
Files Changed
Source Files
Source/WebCore/page/Navigation.cpp
Test Files
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/NavigationAPI.mm
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/NavigationAPI.mm b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/NavigationAPI.mm
index f3ca187b37f6..c68902414a4c 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/NavigationAPI.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/NavigationAPI.mm
@@ -121,4 +121,74 @@ TEST(NavigationAPI, ReplaceStateAfterBackPreservesKey)
EXPECT_TRUE([keyPageA isEqualToString:keyAfterReplace]);
}
+TEST(NavigationAPI, InterceptFailsForDifferentSubdomain)
+{
+ HTTPServer server({
+ { "/page1"_s, { "page1"_s } },
+ { "/page2"_s, { "page2"_s } }
+ }, HTTPServer::Protocol::HttpsProxy);
+
+ RetainPtr configuration = server.httpsProxyConfiguration();
+ RetainPtr webView = adoptNS([[TestWKWebView alloc] initWithFrame:CGRectMake(0, 0, 300, 300) configuration:configuration.get()]);
+
+ RetainPtr navigationDelegate = adoptNS([TestNavigationDelegate new]);
+ [navigationDelegate allowAnyTLSCertificate];
+ [webView setNavigationDelegate:navigationDelegate.get()];
+
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://page1.example.com/page1"]]];
+ [navigationDelegate waitForDidFinishNavigation];
+
+ NSError *error = nil;
+ id result = [webView objectByCallingAsyncFunction:@"return await new Promise((resolve) => {"
+ " navigation.addEventListener('navigate', (event) => {"
+ " try {"
+ " event.intercept({ handler: () => {} });"
+ " resolve('intercept succeeded');"
+ " } catch (e) {"
+ " resolve('intercept failed: ' + e.name);"
+ " }"
+ " });"
+ " location.href = 'https://page2.example.com/page2';"
+ "});" withArguments:nil error:&error];
+
+ EXPECT_NULL(error);
+ EXPECT_TRUE([result isKindOfClass:[NSString class]]);
+ EXPECT_TRUE([result hasPrefix:@"intercept failed"]);
+}
+
+TEST(NavigationAPI, InterceptFailsForDifferentUsernameAndPassword)
+{
+ HTTPServer server({
+ { "/page1"_s, { "page1"_s } },
+ { "/page2"_s, { "page2"_s } }
+ }, HTTPServer::Protocol::HttpsProxy);
+
+ RetainPtr configuration = server.httpsProxyConfiguration();
+ RetainPtr webView = adoptNS([[TestWKWebView alloc] initWithFrame:CGRectMake(0, 0, 300, 300) configuration:configuration.get()]);
+
+ RetainPtr navigationDelegate = adoptNS([TestNavigationDelegate new]);
+ [navigationDelegate allowAnyTLSCertificate];
+ [webView setNavigationDelegate:navigationDelegate.get()];
+
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://username1:[email protected]/page1"]]];
+ [navigationDelegate waitForDidFinishNavigation];
+
+ NSError *error = nil;
+ id result = [webView objectByCallingAsyncFunction:@"return await new Promise((resolve) => {"
+ " navigation.addEventListener('navigate', (event) => {"
+ " try {"
+ " event.intercept({ handler: () => {} });"
+ " resolve('intercept succeeded');"
+ " } catch (e) {"
+ " resolve('intercept failed: ' + e.name);"
+ " }"
+ " });"
+ " location.href = 'https://username2:[email protected]/page2';"
+ "});" withArguments:nil error:&error];
+
+ EXPECT_NULL(error);
+ EXPECT_TRUE([result isKindOfClass:[NSString class]]);
+ EXPECT_TRUE([result hasPrefix:@"intercept failed"]);
+}
+
} // namespace TestWebKitAPI