[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
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
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/NavigationAPI.mm
Patch Details
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.
Background
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.
Analysis
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:
- Load a document on a subdomain sharing the victim's registrable domain.
- Register a
navigatelistener that callsevent.intercept({ handler: () => {} }). - Trigger a navigation to the sibling origin (
location.href = 'https://page2.example.com/page2'). - Before the fix: no throw. The load to
page2.example.comis cancelled, the attacker's document stays resident, and the browsing context's URL becomeshttps://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.
Insight
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.
Audit directions
- Trust-relation lattice collapse — the relation in the check must be exactly as strict as the relation in the policy text. Coarser relations are a silent superset, and the code reads as though it enforces the stricter one. Narrow: grep
Source/WebCoreforisSameSiteAs(andisSameOriginDomainAs(and, at each call site, read the adjacent spec-link comment to see which relation the spec actually names — start withpage/Navigation.cpp,loader/, and the history/session-restore paths, since those quote spec algorithms verbatim. Wider: the class shows up in any multi-relation boolean gate — huntif (!a && !b) return false;andif (a || b) proceed;shapes whereaandbare different trust relations, plus checks keyed on registrable domain, host suffix, orSecurityOriginData::isSameSchemeHostPortwhere the spec names full-tuple equality. Widest: this is general trust-relation lattice collapse and applies wherever authorization compares identities at more than one granularity — Chromium's SiteInstance-versus-origin distinction, cookieSameSiteversus origin-scoped storage, OAuth issuer-versus-client matching, cloud IAM account-versus-role scoping. Match tell on every rung: a gate whose comment or spec link names relation X while the code evaluates relation Y, with Y ⊇ X. - Comparison performed on a canonicalized proxy that already discarded a component the policy enumerates. Canonicalization is lossy, so any policy naming a component list must be checked against the components, not against a digest of them — here
SecurityOrigin::create(targetURL)drops the userinfo the spec step explicitly lists. Narrow: audit WebCore call sites that construct aSecurityOriginfrom aURLpurely to compare it, and check whether the governing spec text enumerates components the origin tuple does not carry (username, password, path, fragment);documentCanHaveURLRewrittenwas one — enumerate the rest by searching forSecurityOrigin::create(immediately followed byisSameOriginAs. Wider: any comparison mediated by a normalized surrogate — registrable-domain objects, lowercased host strings,URL::protocolHostAndPort(), pre-canonicalized cache keys — where the policy is written over the raw form. Widest: the principle transfers to every system that authorizes on canonicalized identifiers — OAuthredirect_urimatching, CSP source-expression matching, cookie domain matching, S3/GCS bucket policy path matching, container image reference resolution. Match tell: the canonicalization step drops at least one field appearing in the policy's enumerated component list. - Authorization computed against mutable identity. Any state a security decision reads must either be immutable for the decision's lifetime or be re-derived at each use; a document's URL is script-mutable without renavigation (history state APIs, Navigation interception) while its
SecurityOriginis not. Narrow: grepSource/WebCorefor security-relevant consumers ofDocument::url(),Document::baseURL(), andDocument::cookieURL()and decide for each whether an origin lookup would have been the correct source — the loader, cookie, and mixed-content paths are the highest-yield starting points. Wider: enumerate every API that mutates a document's URL without a load (history.pushState/replaceState, Navigationintercept()commit paths) and determine what recomputes from the new URL versus what caches the pre-mutation value. Widest: the general class of authorization computed against mutable identity — session principals cached before a role change, JWT claims trusted after token refresh, capability objects holding a path a rename can move. Match tell: a security check reading a field some script- or user-reachable API can rewrite in place, with no re-validation between mutation and use. - Spec-algorithm transcription drift in
Source/WebCore/page/Navigation.cpp. Narrow: review each spec-linked helper in that file against the current HTML text step by step, the way this patch reconstructed "can have its URL rewritten" — the remaining steps of that predicate (about:blank,srcdoc,javascript:, opaque-origin handling) and the surrounding inner-navigate-event-firing steps are the natural next targets. Wider: the same drift class applies to any WebCore function carrying a spec permalink comment whose body is an optimized or "simplified" restatement rather than a step-for-step transcription — those permalink comments are a searchable index of places to diff. Widest: applies to any implementation of an externally specified security algorithm — TLS certificate-validation paths, URL parsers, sanitizer allowlists, SAML/OIDC validation. Match tell: a function whose comment cites a spec step enumerating a list, where the body does not visibly test every element of that list.