← All reports

Compromised Web Content can use a navigation-response Download policy on a data: URL to write attacker-controlled bytes to disk

HighUIProcess navigation policyLogicError

CVE: CVE-2026-43701 · Safari 26.5.2 · Released June 29, 2026 Impact: A malicious website may be able to process restricted web content outside the sandbox Apple's description: The issue was addressed with improved checks. Credit: Aaron Grattafiori - NVIDIA AI Red Team

f23ffb5 | Bugzilla 315004

High. Not memory corruption — a provenance check the trusted side never made. The renderer picks the bytes, picks the MIME type, and the UI process mints the download; escalation past "a file landed in ~/Downloads" needs a second bug or a later user action.

Every navigation that gets as far as having a response passes through a policy decision, and the answer the embedding app returns — render it, abandon it, or download it — is carried out by the privileged side of the browser, not by the renderer that asked for it. On Cocoa, the stock answer for a response WebKit cannot display is WKNavigationResponsePolicyDownload, which is a sound default as long as the bytes came off the network and somebody other than the requester chose them. It stops being sound the moment the requester authored the bytes itself, because then the only thing standing between web content and the filesystem is whether the privileged side bothers to ask where the request came from.

The angle: A compromised renderer can drop fully attacker-chosen bytes into the user's download folder — a file write outside the sandbox with no click, no download attribute, and no server involved.

Source/WebKit/UIProcess/WebPageProxy.cpp

@@ -5924,6 +5924,19 @@ void WebPageProxy::receivedNavigationResponsePolicyDecision(WebCore::PolicyActio
if (!hasRunningProcess())
return completionHandler(PolicyDecision { });
 
+ // Refuse to convert a navigation response into a download when the request is a data: URL,
+ // unless the navigation was driven by the API client (e.g. -loadRequest: with a data: URL).
+ // Otherwise a compromised Web Content process could navigate to a data: URL with an unshowable
+ // MIME type and rely on the navigation delegate's stock "download unshowable responses"
+ // behavior to write attacker-controlled bytes to disk without user interaction. Legitimate
+ // downloads of data: URLs go through the navigation action policy (e.g. <a href="data:..." download>)
+ // or the explicit download API, neither of which reaches this code path.
+ if (action == PolicyAction::Download && request.url().protocolIsData()
+ && (!navigation || !navigation->isFromAPIClientRequest())) {
+ WEBPAGEPROXY_RELEASE_LOG(Loading, "receivedNavigationResponsePolicyDecision: refusing to download data: URL not initiated by API client");
+ action = PolicyAction::Ignore;
+ }
+
Ref pageLoadState = internals().pageLoadState;
auto transaction = pageLoadState->transaction();

Source/WebKit/UIProcess/API/APINavigation.h

@@ -132,6 +132,7 @@ class Navigation : public ObjectImpl<Object::Type::Navigation> {
 
bool wasUserInitiated() const { return m_lastNavigationAction && !!m_lastNavigationAction->userGestureTokenIdentifier; }
bool NODELETE isRequestFromClientOrUserInput() const;
+ bool isFromAPIClientRequest() const { return m_requestIsFromClientInput; }
void NODELETE markRequestAsFromClientInput();
void markAsFromLoadData() { m_isFromLoadData = true; }
bool isFromLoadData() const { return m_isFromLoadData; }

Source/WebCore/loader/DocumentLoader.cpp

@@ -1142,6 +1142,16 @@ void DocumentLoader::continueAfterContentPolicy(PolicyAction policy)
return;
}
 
+ // Defense-in-depth: refuse to download a data: URL through a top-frame navigation that
+ // wasn't initiated by the user or the API client, mirroring the existing check in the
+ // PolicyAction::Use branch. The primary defense lives in the UI process; this guards
+ // ports / future flows that don't share that boundary.
+ if (disallowDataRequest()) {
+ protect(frameLoader())->policyChecker().cannotShowMIMEType(m_response);
+ stopLoadingForPolicyChange();
+ return;
+ }
+
if (RefPtr mainResourceLoader = this->mainResourceLoader())
InspectorInstrumentation::continueWithPolicyDownload(*frame, *mainResourceLoader->identifier(), *this, m_response);

The primary fix is thirteen lines in WebPageProxy::receivedNavigationResponsePolicyDecision. The guard is placed deliberately early — immediately after the hasRunningProcess() bail-out and before the pageLoadState transaction is taken, so the refusal happens before any page-load state is mutated on the strength of a decision that is about to be thrown away. If the incoming action is PolicyAction::Download, the request URL's scheme is data:, and either there is no navigation object at all or navigation->isFromAPIClientRequest() is false, the action is rewritten in place to PolicyAction::Ignore and the refusal is recorded through WEBPAGEPROXY_RELEASE_LOG(Loading, …). Note the !navigation arm: a missing navigation object is treated as untrusted, not as a pass — the guard fails closed.

The second hunk is what makes the first one correct. API::Navigation gains a one-line inline accessor, isFromAPIClientRequest(), returning m_requestIsFromClientInput verbatim. It is added directly beside isRequestFromClientOrUserInput(), the pre-existing helper that answers a superficially identical question but folds in a signal the WebContent process supplies. The new accessor deliberately exposes only the raw flag that UI-process entry points set via markRequestAsFromClientInput().

The third hunk is explicitly labelled defense-in-depth by the commit message. DocumentLoader::continueAfterContentPolicy already ran disallowDataRequest() on its PolicyAction::Use branch; the patch adds the same predicate to the PolicyAction::Download branch, unwinding the load through policyChecker().cannotShowMIMEType(m_response) and stopLoadingForPolicyChange(). Since the WebContent process is the compromised party in this report, a check that lives in WebContent is not the defense that matters here — its value is keeping non-Cocoa ports and future flows consistent with the boundary the UI process now enforces.

The process split. WebKit2 separates the sandboxed WebContent process, which parses and renders untrusted content, from the UI process, which hosts the embedding application and owns the capabilities that reach outside the sandbox — filesystem writes among them. Navigation policy decisions originate in WebContent, travel over IPC, and are finalised in the UI process. Everything in this bug turns on which side of that boundary is allowed to answer a given question.

PolicyAction and the two checkpoints. A policy decision resolves to a PolicyAction: principally Use (continue the load), Ignore (abandon it), or Download (hand the response to the download machinery). Two distinct checkpoints produce these values. The navigation action policy runs before the request is issued and sees the initiating context — including whether an anchor carried a download attribute. The navigation response policy runs afterwards, once the MIME type and headers are known, and that is where receivedNavigationResponsePolicyDecision sits.

Showable MIME types. WebKit classifies response MIME types as renderable or not. When the engine cannot display a response, the conventional thing for an embedder's navigation delegate to do is return a download policy — WKNavigationResponsePolicyDownload in the Cocoa API. This is the default behaviour of essentially every WebKit embedder, not a quirk of one app.

data: URLs. A data: URL carries its payload inline in the URL string — data:application/octet-stream;base64,…. There is no network fetch, so whoever composes the URL determines both the declared MIME type and every byte of the response body.

DownloadProxy. The UI-process object representing an in-flight download. Constructing one is the point at which the browser commits to writing a file into the user's download directory.

Provenance signals on a navigation. WebKit tracks several different notions of "who asked for this". Navigation::wasUserInitiated() consults userGestureTokenIdentifier, carried in NavigationActionData — a struct sent from the WebContent process; popup blocking is one consumer. m_requestIsFromClientInput is set by markRequestAsFromClientInput() on UI-process-driven loads such as -loadRequest:. The helper isRequestFromClientOrUserInput() is a broader predicate spanning both notions.

disallowDataRequest(). A DocumentLoader predicate already consulted on the PolicyAction::Use branch of continueAfterContentPolicy, rejecting data: URL main-frame navigations that lack appropriate initiation, and paired with cannotShowMIMEType() plus stopLoadingForPolicyChange() to unwind the load cleanly.

This is a confused deputy across the renderer/UI-process trust boundary, not a memory-safety bug — the privileged side performed a privileged act because the untrusted side asked in a way it was not equipped to distrust.

  WebContent (sandboxed)              │  UIProcess (trusted)
  ────────────────────────────────────┼──────────────────────────────────
  window.open("data:application/      │
    octet-stream;base64,<payload>")   │
  forged userGestureTokenIdentifier ──┼─►  popup blocker: allowed
                                      │
  navigation response (MIME =         │    delegate: not showable
    application/octet-stream) ────────┼─►    └─► PolicyAction::Download
                                      │    receivedNavigationResponse-
                                      │      PolicyDecision()
                                      │      ✗ no scheme / provenance check
                                      │      └─► DownloadProxy
  WebFrame::startDownload ◄───────────┼──────┘
    └─► StartDownload ────────────────┼─►  ~/Downloads/Unknown

Follow the arrows. Everything crossing the boundary in that diagram is renderer-authored: the URL, and therefore the payload; the declared MIME type, and therefore the delegate's answer; and the gesture token, and therefore the popup blocker's answer. The commit message states the popup blocker was defeated by forging the renderer-supplied user-gesture token, and that everything after the policy decision — WebFrame::startDownload into NetworkConnectionToWebProcess::StartDownload — is stock behaviour. Only the box on the right had the authority to say no, and pre-fix it never looked at the request's scheme before minting a DownloadProxy.

The missing invariant is narrow and precise: the UI process should not grant an out-of-sandbox capability — materialising bytes onto the filesystem — on the strength of a request whose content is entirely supplied by the untrusted renderer. Every guard that did run in that flow was guarding something else. The popup blocker was asking about user activation. The navigation delegate was asking about renderability. Neither was asking "did somebody outside this sandbox choose these bytes?", and nothing downstream asks either, because by the time startDownload runs the decision has already been made.

The concrete trigger, as the commit message describes it:

  1. Compromised WebContent forges a user-gesture token in NavigationActionData to satisfy the popup blocker.
  2. It opens a popup navigating to data:application/octet-stream;base64,<payload>.
  3. The navigation response policy fires; application/octet-stream is not showable, so the delegate's stock answer is WKNavigationResponsePolicyDownload.
  4. receivedNavigationResponsePolicyDecision receives PolicyAction::Download and, pre-fix, mints a real DownloadProxy for the data: URL.
  5. The download machinery runs unmodified and the inline payload lands at ~/Downloads/Unknown — no download attribute, no Content-Disposition header, no user interaction.

The resulting primitive is bounded but real: fully controlled file contents and MIME type, destination constrained to the browser's download directory under the filename the commit message reports for the no-Content-Disposition case. There is no memory corruption, no read primitive, and no direct code execution — the decision point runs in the UI process but this does not by itself yield execution there, so a classic sandbox escape still needs a separate bug. What it does yield is a confined renderer reaching a filesystem location it cannot touch directly, which is precisely the consequence Apple's summary describes as processing restricted web content outside the sandbox. As a staging step that is valuable: planting a payload for a later user action, seeding input for a second bug in a file-handling component, or leaving on-disk artifacts. A user-click-gated variant appears reachable from ordinary web content, without the compromised-renderer prerequisite.

The fix restores the invariant by making the trusted side ask a question only the trusted side can answer. The load-bearing detail is which predicate it asks. isRequestFromClientOrUserInput() was sitting right there, and it is the helper a reviewer would reach for by reflex — but it folds in a renderer-supplied signal, so a compromised renderer can satisfy it, which makes it unsound under exactly the threat model this CVE describes. So the patch adds isFromAPIClientRequest(), reading m_requestIsFromClientInput alone:

bool wasUserInitiated() const { return m_lastNavigationAction && !!m_lastNavigationAction->userGestureTokenIdentifier; }
bool NODELETE isRequestFromClientOrUserInput() const;
bool isFromAPIClientRequest() const { return m_requestIsFromClientInput; }   // UIProcess-set only
void NODELETE markRequestAsFromClientInput();

Three predicates, three different trust levels, one header. The first is renderer-derived. The second mixes. Only the third is set exclusively by UI-process entry points such as -loadRequest:, and only the third is safe to gate a capability on.

The carve-out costs nothing legitimate, which is why a blanket refusal was viable: <a href="data:…" download> resolves through the navigation action policy, and -[WKWebView startDownloadUsingRequest:] is a separate API path. Neither reaches receivedNavigationResponsePolicyDecision, so the only traffic the new guard can turn away is traffic that arrived by the route the report describes. Meanwhile the WebCore hunk closes the sibling-branch asymmetry: disallowDataRequest() had been applied to the Use arm of the same dispatch and quietly omitted from the Download arm, so one arm of a two-arm decision carried a scheme restriction the other did not.

The UI process granted a filesystem write on the strength of provenance flags the compromised renderer itself supplied — a confused deputy, not a memory bug.

The most instructive artifact in this patch is the accessor it had to invent. Two same-shaped predicates now sit adjacent in APINavigation.h with materially different trust properties, distinguished only by a name that does not advertise the distinction — isRequestFromClientOrUserInput() versus isFromAPIClientRequest(). That is a standing naming hazard, and a strong hint about where to look next: any other UI-process security decision currently keyed on the broader helper, or on wasUserInitiated() — which is derived entirely from NavigationActionData sent by the renderer — inherits the same unsoundness under a compromised-renderer model and deserves the same audit.