High CVSS 5.4 webkit Cross Origin 🔧 Commit mapped

Overview

High
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may bypass Same Origin Policy
ComponentWebCore
Sub-componentWebCore Page
Bug ClassCross Origin
Tracker306050
Fix commitb537a57c092d (WebKit/WebKit) +74/-5
CWECWE-20, CWE-346 (Improper input validation, Origin validation error)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N
CISA KEVNot listed
CreditedThomas Espach
Disclosed2026-03-17

Background

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.

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.

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.

Files Changed

  • Source/WebCore/page/Navigation.cpp
  • Tools/TestWebKitAPI/Tests/WebKit/WKWebView/NavigationAPI.mm

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().
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:password1@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://username2:password2@example.com/page2';"
+        "});" withArguments:nil error:&error];
+
+    EXPECT_NULL(error);
+    EXPECT_TRUE([result isKindOfClass:[NSString class]]);
+    EXPECT_TRUE([result hasPrefix:@"intercept failed"]);
+}
+
 } // namespace TestWebKitAPI
Loading diff…

Regression Test / PoC

shipped with the fix
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:password1@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://username2:password2@example.com/page2';"
+        "});" withArguments:nil error:&error];
+
+    EXPECT_NULL(error);
+    EXPECT_TRUE([result isKindOfClass:[NSString class]]);
+    EXPECT_TRUE([result hasPrefix:@"intercept failed"]);
+}
+
 } // namespace TestWebKitAPI
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.