Compromised Web Content can use a navigation-response Download policy on a data: URL to write attacker-controlled bytes to disk
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
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
Source/WebKit/UIProcess/API/APINavigation.h
Source/WebCore/loader/DocumentLoader.cpp
Patch Details
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.
Background
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.
Analysis
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:
- Compromised WebContent forges a user-gesture token in
NavigationActionDatato satisfy the popup blocker. - It opens a popup navigating to
data:application/octet-stream;base64,<payload>. - The navigation response policy fires;
application/octet-streamis not showable, so the delegate's stock answer isWKNavigationResponsePolicyDownload. receivedNavigationResponsePolicyDecisionreceivesPolicyAction::Downloadand, pre-fix, mints a realDownloadProxyfor thedata:URL.- The download machinery runs unmodified and the inline payload lands at
~/Downloads/Unknown— nodownloadattribute, noContent-Dispositionheader, 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.
Insight
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.
Audit directions
-
Privileged-side gates keyed on sandbox-supplied provenance. The invariant: a capability gate in the trusted process must not consult a flag whose value the untrusted process can choose. Narrow — grep
Source/WebKit/UIProcessfor callers ofNavigation::isRequestFromClientOrUserInput(),Navigation::wasUserInitiated(), and direct reads ofNavigationActionData::userGestureTokenIdentifier, classifying each as advisory (UI polish, heuristics) versus load-bearing (capability grant); the tell is a branch that unlocks a filesystem, network, or app-handoff capability rather than one that only affects presentation. Wider — the class covers any UI-process gate reading a boolean or identifier deserialized from an IPC message struct: sandbox-extension issuance,ShouldOpenExternalURLsPolicyhandling, popup and autoplay gating, permission-prompt suppression; in code search, look forif (someStructFromIPC.someBool)guarding a side effect the renderer cannot perform itself. Widest — this is the general "attestation supplied by the party being attested" class and holds in any privilege-separated system: Chromium's user-activation state propagated over Mojo, AndroidIntentextras trusted by a privileged receiver, any RPC server trusting a client-setis_adminfield. Carry the invariant across codebases: if the requester can set the field, the field is a request, not evidence. -
Validation inside one arm of an enum dispatch instead of at the join point. Exactly the
disallowDataRequest()asymmetry between theUseandDownloadbranches this patch corrects. Narrow — enumerate every site inSource/WebCore/loaderthat switches or branches onPolicyAction(DocumentLoader::continueAfterContentPolicy,PolicyChecker,FrameLoader) and diff the guard set applied per arm; the tell is any predicate invoked in one branch and absent from another branch reaching an equally privileged outcome. Wider — the same shape recurs across WebKit's loader wherever CSP, mixed-content, content-blocker, or local-scheme checks are applied to a subset of load paths; navigate to it by finding a validation helper with few callers and asking why the non-callers are exempt. Widest — a security predicate belongs at the narrowest point that dominates all privileged outcomes, not replicated per outcome; this applies to any policy engine, router middleware, or authorization layer where checks live in handlers rather than the dispatcher, and per-arm checks are only correct if someone can enumerate every arm and prove coverage. -
Renderer-authored URL schemes treated by privileged code as fetched resources. The fix checks
request.url().protocolIsData()only. Narrow — inreceivedNavigationResponsePolicyDecisionand the surrounding download plumbing, determine whetherblob:URLs (renderer-created, renderer-populated) andabout:/webkit-fake-url:variants can reachPolicyAction::Downloadthrough the same unshowable-MIME-type mechanism; the tell is any privileged path consuming response bytes with no corresponding network fetch that a server or the user could have influenced. Wider — the class covers every WebKit surface where content provenance is inferred from the response rather than the URL scheme: customWKURLSchemeHandlerresponses, service-worker-synthesized responses, cached substitute-data loads; search for a privileged consumer branching on MIME type orContent-Dispositionwithout first establishing who authored the body. Widest — provenance must be tracked from the byte source, not reconstructed from response metadata; the same failure appears in email clients trusting declared attachment types, package managers trusting embedded manifests, and upload pipelines trusting client-declared MIME. -
Capability grants that depend on an embedder delegate's default answer. The stock "download anything unshowable" response is what converted a renderer-chosen MIME type into a filesystem write here. Narrow — review the other
WebPageProxyentry points that act on client policy callbacks and ask which privileged operations they can be steered into when the embedder returns its default answer; start from thePolicyDecisioncompletion handlers andDownloadProxycreation sites inSource/WebKit/UIProcess/WebPageProxy.cpp, and flag any path where the engine performs a privileged action solely because a delegate did not object. Wider — the class extends to every WebKit delegate/client hook whose omission or default answer is permissive: UI-client popup handling, form-submission listeners, authentication-challenge defaults; navigate via theAPI::*Clientbase classes and inspect the unoverridden implementations. Widest — fail-open defaults in extension points become the effective security policy for every embedder that does not customise them, which holds for browser extension APIs, servlet filters, and policy-callback SDKs alike; the default implementation of a hook is a security decision, not a placeholder.