Released March 17, 2026

CVE-2026-20643

CVSS 5.4 severity medium CWE-20CWE-346 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N

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