← All reports

[Navigation API] intercept() wrongly succeeds for cross-subdomain navigations

CVE: CVE-2026-20643 · Safari 26.4 · Released March 24, 2026 Impact: Processing maliciously crafted web content may bypass Same Origin Policy Apple's description: A cross-origin issue in the Navigation API was addressed with improved input validation. Credit: Thomas Espach

Severity: Medium | Component: WebCore Navigation API | b537a57 | Bugzilla 306050

Medium — no memory corruption, but the gate that decides whether a document may rewrite its own URL was keyed on registrable domain rather than origin. Any host sharing the victim's eTLD+1 could take over a sibling origin's URL; the security origin itself never moves, which is what keeps this out of the High band.

Every navigation of a browsing context passes through a script-visible checkpoint before it commits: the navigate event. A handler can call intercept() on that event to cancel the load and instead keep the current document alive while the browsing context's URL becomes the destination — the page stays, the URL changes. The whole construction rests on one predicate: a document may only rewrite its URL to a URL it could already legitimately claim, which the HTML spec defines component-wise over scheme, username, password, host, and port.

The angle: A page on any subdomain sharing a victim's registrable domain could swallow a user's navigation to that sibling origin and keep serving its own content under the sibling's URL.

Source/WebCore/page/Navigation.cpp

static bool documentCanHaveURLRewritten(const Document& document, const URL& targetURL)
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

Tools/TestWebKitAPI/Tests/WebKit/WKWebView/NavigationAPI.mm

+TEST(NavigationAPI, InterceptFailsForDifferentSubdomain)
+{
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://page1.example.com/page1"]]];
+ [navigationDelegate waitForDidFinishNavigation];
+
+ 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_TRUE([result hasPrefix:@"intercept failed"]);
+}
+
+TEST(NavigationAPI, InterceptFailsForDifferentUsernameAndPassword)
+{
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://username1:password1@example.com/page1"]]];
+ ...
+ " location.href = 'https://username2:password2@example.com/page2';"
+ EXPECT_TRUE([result hasPrefix:@"intercept failed"]);
+}

The entire security change is four lines inside one static helper, WebCore::documentCanHaveURLRewritten(const Document&, const URL& targetURL). The old body computed two booleans against two different trust relations — isSameSite = documentOrigin->isSameSiteAs(targetOrigin) and isSameOrigin = documentOrigin->isSameOriginAs(targetOrigin) — and bailed out only when both were false. The patch deletes both locals, deletes the comment that justified the same-site leg, and substitutes a single-relation gate: if (!documentOrigin->isSameOriginAs(targetOrigin)) return false;.

It then adds a check that had no equivalent anywhere on the old path — a direct comparison of the URL userinfo components, documentURL.user() against targetURL.user() and documentURL.password() against targetURL.password(). Note that this comparison is made against the URL objects, not the SecurityOrigin objects; that distinction is the point, and it is why the check could not simply be folded into the origin comparison above it. The remaining spec-referenced steps further down the function are untouched.

The collateral is two API tests appended to Tools/TestWebKitAPI/Tests/WebKit/WKWebView/NavigationAPI.mm. Both stand up an HTTPS-proxy HTTPServer, load a page, register a navigate listener that calls event.intercept({ handler: () => {} }) inside a try/catch, assign location.href to the cross-boundary destination, and assert the resolved string has prefix intercept failed. InterceptFailsForDifferentSubdomain navigates page1.example.com → page2.example.com; InterceptFailsForDifferentUsernameAndPassword navigates between two same-origin URLs on example.com that differ only in embedded credentials. Before the patch, both resolved to intercept succeeded.

The Navigation API and the navigate event. window.navigation is the modern script-visible surface over a browsing context's history and navigations. Before a navigation proceeds — one initiated by the document itself via an assignment to location.href, by link activation, or by history traversal — the user agent fires a navigate event at that object, giving page script a chance to inspect and act on the pending navigation.

intercept() and what it commits to. A navigate handler may call NavigateEvent.intercept() to take the navigation over. The destination document is never loaded; instead the user agent keeps the current document in place, runs the URL-and-history-update steps so the browsing context's URL becomes the destination URL, and invokes the supplied handler callback. This is the machinery behind client-side routing in single-page applications — the URL moves, the document does not.

canIntercept and the predicate behind it. Because intercept() lets a document claim a URL, it is gated. Step 9 of the spec's inner navigate-event-firing algorithm initializes the event's canIntercept attribute, and intercept()'s own first step throws a SecurityError DOMException when that attribute was initialized to false. The predicate consulted at step 9 is "can have its URL rewritten", whose first step reads: "If targetURL and documentURL differ in their scheme, username, password, host, or port components, then return false." It is an enumerated component-wise comparison, deliberately so.

Same-origin versus same-site. Two origins are same-origin when scheme, host, and port all match exactly. They are same-site when they merely share a registrable domain — the eTLD+1, so page1.example.com and page2.example.com are same-site on example.com. Same-site is the strictly coarser relation: every same-origin pair is same-site, but the converse fails for every distinct subdomain of a shared registrable domain.

SecurityOrigin and what it carries. WebCore models an origin tuple as a SecurityOrigin. isSameOriginAs() compares the tuple; isSameSiteAs() compares at registrable-domain granularity; isSameOriginDomainAs() is the variant that accounts for document.domain mutations. Critically, a SecurityOrigin constructed from a URL carries no userinfo — username and password are URL components that the origin abstraction discards by construction, and they remain reachable only through URL::user() and URL::password() on the URL itself.

document.domain. A legacy setter that relaxes a document's effective origin toward its registrable domain, so that cooperating documents which would otherwise be cross-origin can pass same-origin-domain checks against one another.

This is a lattice collapse: a gate implemented against a coarser equivalence relation than the policy it enforces names, admitting a strict superset of the principals it was meant to admit.

  Before:                              After:
  documentCanHaveURLRewritten()        documentCanHaveURLRewritten()
    isSameSite  = sameSiteAs(t)          !sameOriginAs(t) ──► return false
    isSameOrigin= sameOriginAs(t)        user/password differ ──► return false
    if (!isSameSite && !isSameOrigin)    (spec steps) ──► ...
        return false           ╳ never taken for
    (spec steps) ──► ...         page1→page2.example.com

Walk the left column with the test case's values. Navigating from https://page1.example.com/page1 to https://page2.example.com/page2, documentOrigin is https://page1.example.com:443 and targetOrigin is https://page2.example.com:443. isSameOriginAs returns false — the hosts differ. isSameSiteAs returns true — both hang off the registrable domain example.com. The guard therefore evaluates !true && !false → false && true → false, and the early return marked ╳ above is skipped. Because same-site is coarser than same-origin, any destination sharing the document's registrable domain satisfied the disjunction no matter what the same-origin leg said; the same-origin test was, in practice, dead weight in the expression.

From there the failure propagates mechanically. documentCanHaveURLRewritten reports true for a genuinely cross-origin destination, step 9 initializes NavigateEvent.canIntercept to true, and intercept() — which only throws when canIntercept was initialized to false — succeeds. The sequence the reader can run is exactly the added test:

  1. Load a document on a subdomain sharing the victim's registrable domain.
  2. Register a navigate listener that calls event.intercept({ handler: () => {} }).
  3. Trigger a navigation to the sibling origin (location.href = 'https://page2.example.com/page2').
  4. Before the fix: no throw. The load to page2.example.com is cancelled, the attacker's document stays resident, and the browsing context's URL becomes https://page2.example.com/page2.

The second, quieter half of the bug is the userinfo comparison that never existed. Neither isSameSiteAs nor isSameOriginAs inspects username or password — the SecurityOrigin that SecurityOrigin::create(targetURL) produces has already dropped those components. So on the old code there was no path, coarse or strict, on which the username and password the spec explicitly enumerates were compared at all. Two URLs that are same-origin on https://example.com:443 but carry different embedded credentials sailed through, which is what InterceptFailsForDifferentUsernameAndPassword pins down. The patch's second check reaches past the origin abstraction to the raw URL precisely because the abstraction cannot answer the question.

What an attacker gains is URL takeover, not origin takeover. The document's SecurityOrigin is untouched by a same-document URL rewrite, so origin-keyed machinery — CORS, storage partitioning, cookie scoping — still consults the attacker's real origin and still holds. What breaks is the invariant binding a document's URL to its security origin. Anyone holding any same-site host (a user-content subdomain, a stale or delegated subdomain, a compromised sibling service) could suppress a user's navigation to a sensitive sibling origin and continue serving its own document under that origin's URL: address-bar and location.href/document.URL spoofing across an origin boundary, with credential phishing as the direct consequence, plus whatever downstream logic derives identity from the document URL rather than from the origin. In the userinfo case the same primitive lets an attacker place arbitrary embedded credentials in the document URL. There is no memory-safety component and no bearing on the WebContent sandbox — a sandbox escape would need a wholly separate bug.

A security gate keyed on registrable domain rather than origin let any same-site subdomain intercept a navigation and claim a sibling origin's URL while keeping its own document and origin.

The deleted comment is the most instructive artifact in the patch. The same-site leg was not an oversight — it was a deliberate relaxation, written with a justification attached: "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." A mitigation-shaped comment explaining why a check was weakened. The accommodation it named is real, but the instrument chosen was orders of magnitude broader than the case: document.domain sheds a bounded prefix of the host under strict opt-in from both parties, whereas isSameSiteAs discards the host label entirely for everyone. The correct instrument for that requirement was isSameOriginDomainAs, which exists on SecurityOrigin for exactly this purpose. Worth noting that even the fixed check compares SecurityOrigin objects where the spec text enumerates URL components — origin equality and component equality diverge for schemes whose origin is not derived tuple-wise from the URL (opaque origins from sandboxed documents, blob:, file: under varying settings), so the implementation remains a close approximation of the spec step rather than a transcription of it. The userinfo comparison the patch adds is precisely the component the origin abstraction discards.