CVE-2026-43701
Overview
Background
- data: URL
- A URL that inlines its bytes; can carry attacker-controlled content and an unshowable MIME type.
- Download of unshowable responses
- The navigation delegate’s stock behavior to download responses it cannot display.
- isFromAPIClientRequest
- Marks navigations initiated by the API client (e.g. -loadRequest:) vs by web content.
Root Cause Analysis
This fixes a sandbox/content-restriction bypass where a data: URL navigation could be silently turned into a file download of attacker-controlled bytes. When a navigation response has an unshowable MIME type, the UI process may hand it to the navigation delegate, whose stock behavior is to download unshowable responses. WebPageProxy::receivedNavigationResponsePolicyDecision did not special-case data: URLs, so a compromised Web Content process could navigate the top frame to a data: URL with an unshowable MIME type and rely on that stock ‘download unshowable responses’ path to write attacker-chosen bytes to disk without any user interaction — processing restricted content/writing files outside the intended flow.
The fix, in the UI process, converts action == PolicyAction::Download to PolicyAction::Ignore when request.url().protocolIsData() and the navigation was not driven by the API client (!navigation || !navigation->isFromAPIClientRequest()); legitimate data: downloads (<a download>, explicit download API, or client -loadRequest:) do not reach this path. A defense-in-depth mirror is added in WebCore’s DocumentLoader::continueAfterContentPolicy (disallowDataRequest()), and APINavigation gains isFromAPIClientRequest() to distinguish client-initiated loads.
The restored invariant is that a data: URL response is only downloadable when the load was initiated by the user or the API client. Established by the diff.
Attack Path
- Compromise or drive the Web Content process Attacker-controlled web content (or a compromised WebContent process) initiates a top-frame navigation.
- Navigate to a data: URL with an unshowable MIME type The navigation targets data:<unshowable-mime>,<attacker bytes>, which cannot be displayed.
- Response policy becomes Download Pre-patch, the navigation delegate’s stock ‘download unshowable responses’ behavior turns the response into a download with no user gesture.
- Write attacker bytes to disk The data: URL’s attacker-controlled content is written to a file automatically, outside any user-initiated download flow.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
WebPageProxy::receivedNavigationResponsePolicyDecisionSource/WebKit/UIProcess/WebPageProxy.cpp |
modified | Converts a Download policy to Ignore when the request is a data: URL and the navigation is not from the API client, blocking silent data:-URL downloads driven by web content. |
DocumentLoader::continueAfterContentPolicySource/WebCore/loader/DocumentLoader.cpp |
modified | Defense-in-depth: refuses the download and stops the load when disallowDataRequest() holds, mirroring the UI-process check for ports/flows that don't cross that boundary. |
API::Navigation::isFromAPIClientRequestSource/WebKit/UIProcess/API/APINavigation.h |
added | Exposes whether the navigation request originated from the API client, used to permit legitimate client-initiated data: downloads. |
Files Changed
Source/WebCore/loader/DocumentLoader.cppSource/WebKit/UIProcess/API/APINavigation.hSource/WebKit/UIProcess/WebPageProxy.cpp
Audit Directions
- Policy paths that auto-downloadAudit navigation-response policy where Download can be reached for data:/blob: URLs without a user/API-client gate.
- Defense-in-depth mirrorsCheck that WebContent-side loaders (DocumentLoader) mirror UI-process restrictions for ports/flows not crossing that boundary.
Patch
diff --git a/Source/WebCore/loader/DocumentLoader.cpp b/Source/WebCore/loader/DocumentLoader.cpp
index 9ee9f3231e57..d757709c2535 100644
--- a/Source/WebCore/loader/DocumentLoader.cpp
+++ b/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);
diff --git a/Source/WebKit/UIProcess/API/APINavigation.h b/Source/WebKit/UIProcess/API/APINavigation.h
index 75076f101b82..69ca01776969 100644
--- a/Source/WebKit/UIProcess/API/APINavigation.h
+++ b/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; }
diff --git a/Source/WebKit/UIProcess/WebPageProxy.cpp b/Source/WebKit/UIProcess/WebPageProxy.cpp
index 572ea8d6537f..ceee9ead3799 100644
--- a/Source/WebKit/UIProcess/WebPageProxy.cpp
+++ b/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();