CVE-2026-43725
Overview
Impact:
Description: A malicious website may be able to process restricted web content outside the sandbox
Researchers: Luke Francis
Key insight. A renderer-reachable image load validated only that the URL was well-formed, not that it was HTTP(S), so a compromised WebContent process could coerce the Network process into reading local file:// paths; restricting to protocolIsInHTTPFamily() and enforcing cookie policy closes it.
Patch Details
diff --git a/LayoutTests/ipc/load-image-for-decoding-file-url-expected.txt b/LayoutTests/ipc/load-image-for-decoding-file-url-expected.txt
new file mode 100644
index 000000000000..4732d73458a8
--- /dev/null
+++ b/LayoutTests/ipc/load-image-for-decoding-file-url-expected.txt
@@ -0,0 +1,2 @@
+PASS: file:// rejected by MESSAGE_CHECK
+
diff --git a/LayoutTests/ipc/load-image-for-decoding-file-url.html b/LayoutTests/ipc/load-image-for-decoding-file-url.html
new file mode 100644
index 000000000000..c4b04a09abf7
--- /dev/null
+++ b/LayoutTests/ipc/load-image-for-decoding-file-url.html
@@ -0,0 +1,68 @@
+<!DOCTYPE html><!-- webkit-test-runner [ IPCTestingAPIEnabled=true ] -->
+<html><body>
+<pre id="log"></pre>
+<script>
+if (window.testRunner) {
+ testRunner.dumpAsText();
+ testRunner.waitUntilDone();
+}
+function log(s) { document.getElementById('log').textContent += s + '\n'; }
+
+async function main() {
+ if (!window.IPC) {
+ log('PASS: file:// rejected by MESSAGE_CHECK');
+ return;
+ }
+
+ const { CoreIPC, ArgumentSerializer } = await import('./coreipc.js');
+
+ const fileRequest = {
+ getRequestDataToSerialize: {
+ variantType: 'WebCore::ResourceRequest::RequestData',
+ variant: {
+ m_url: { string: 'file:///private/etc/hosts' },
+ m_firstPartyForCookies: { string: 'file:///private/etc/hosts' },
+ m_timeoutInterval: 60.0,
+ m_httpMethod: 'GET',
+ m_httpHeaderFields: { commonHeaders: [], uncommonHeaders: [] },
+ m_responseContentDispositionEncodingFallbackArray: [],
+ m_cachePolicy: 0,
+ m_sameSiteDisposition: 1,
+ m_priority: 2,
+ m_requester: 0,
+ m_allowCookies: true,
+ m_isTopSite: true,
+ m_isAppInitiated: true,
+ m_privacyProxyFailClosedForUnreachableNonMainHosts: false,
+ m_useAdvancedPrivacyProtections: false,
+ m_didFilterLinkDecoration: false,
+ m_isPrivateTokenUsageByThirdPartyAllowed: false,
+ m_wasSchemeOptimisticallyUpgraded: false,
+ m_targetAddressSpace: 0,
+ },
+ },
+ cachePartition: '',
+ hiddenFromInspector: false,
+ };
+
+ const definition = IPC.messages.NetworkConnectionToWebProcess_LoadImageForDecoding;
+ const args = ArgumentSerializer.serializeArguments(definition.arguments,
+ { request: fileRequest, pageID: BigInt(IPC.webPageProxyID), maximumBytesFromNetwork: 1024n });
+
+ CoreIPC.Networking.NetworkConnectionToWebProcess.TakeInvalidMessageStringForTesting(0, {}, () => { });
+
+ IPC.connectionForProcessTarget('Networking').sendWithAsyncReply(0, definition.name, args, () => { });
+
+ let messageCheck = null;
+ CoreIPC.Networking.NetworkConnectionToWebProcess.TakeInvalidMessageStringForTesting(0, {},
+ reply => messageCheck = reply.error);
+
+ if (messageCheck && messageCheck.includes('Message check failed') && messageCheck.includes('protocolIsInHTTPFamily'))
+ log('PASS: file:// rejected by MESSAGE_CHECK');
+ else
+ log('FAIL: expected MESSAGE_CHECK on protocolIsInHTTPFamily, got: "' + messageCheck + '"');
+}
+
+main().catch(e => log('FAIL: ' + e + '\n' + (e.stack || ''))).finally(() => window.testRunner?.notifyDone());
+</script>
+</body></html>
diff --git a/Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp b/Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp
index 407bc78dae17..9b5a76d15a83 100644
--- a/Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp
+++ b/Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp
@@ -241,7 +241,8 @@ void NetworkConnectionToWebProcess::hasUploadStateChanged(bool hasUpload)
void NetworkConnectionToWebProcess::loadImageForDecoding(WebCore::ResourceRequest&& request, WebPageProxyIdentifier pageID, uint64_t maximumBytesFromNetwork, CompletionHandler<void(Expected<Ref<WebCore::FragmentedSharedBuffer>, WebCore::ResourceError>&&)>&& completionHandler)
{
auto url = request.url();
- MESSAGE_CHECK_COMPLETION(url.isValid(), completionHandler(makeUnexpected<WebCore::ResourceError>({ })));
+ MESSAGE_CHECK_COMPLETION(url.isValid() && url.protocolIsInHTTPFamily(), completionHandler(makeUnexpected<WebCore::ResourceError>({ })));
+ MESSAGE_CHECK_COMPLETION(m_networkProcess->allowsFirstPartyForCookies(m_webProcessIdentifier, request.firstPartyForCookies()) == NetworkProcess::AllowCookieAccess::Allow, completionHandler(makeUnexpected<WebCore::ResourceError>({ })));
CheckedPtr networkSession = this->networkSession();
if (!networkSession)
return completionHandler(makeUnexpected<WebCore::ResourceError>({ }));
diff --git a/Source/WebKit/UIProcess/WebPageProxy.cpp b/Source/WebKit/UIProcess/WebPageProxy.cpp
index 3aa1e00aa724..2ada3cb73b6e 100644
--- a/Source/WebKit/UIProcess/WebPageProxy.cpp
+++ b/Source/WebKit/UIProcess/WebPageProxy.cpp
@@ -11542,7 +11542,7 @@ void WebPageProxy::dataTaskWithRequest(WebCore::ResourceRequest&& request, const
void WebPageProxy::loadAndDecodeImage(WebCore::ResourceRequest&& request, std::optional<WebCore::FloatSize> sizeConstraint, size_t maximumBytesFromNetwork, CompletionHandler<void(Expected<Ref<WebCore::ShareableBitmap>, WebCore::ResourceError>&&)>&& completionHandler)
{
- if (isClosed() || !request.url().isValid())
+ if (isClosed() || !request.url().isValid() || !request.url().protocolIsInHTTPFamily())
return completionHandler(makeUnexpected(decodeError(request.url())));
if (!hasRunningProcess())
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-43725 |
| Bugzilla | 312832 |
| Component | WebKit |
| Bug Class | SandboxEscape |
| Severity | medium |
| Commit | ccf0c4874cb25ab1… |
| Advisory | Apple Advisory |
Terminology
- loadImageForDecoding — An IPC path where the renderer asks the Network process to fetch and decode an image, returning the data.
- protocolIsInHTTPFamily — A check that a URL uses http/https; without it, file:// and other schemes are accepted.
- MESSAGE_CHECK — A WebKit IPC guard that rejects a malformed/malicious message (and can terminate the sender) when a precondition fails.
Root Cause Analysis
This fixes a local-file-read / SSRF-style sandbox escape in the Network process’s image-decoding load path. NetworkConnectionToWebProcess::loadImageForDecoding (an IPC entry point callable by the WebContent process) validated only url.isValid() before loading the request and returning the bytes for decoding. Because the scheme was not restricted, a compromised WebContent process could send a request with a file:// URL (or another non-HTTP scheme) and have the Network process read that local file and hand its contents back — reading files outside the WebContent sandbox (the test uses file:///private/etc/hosts). It also did not re-check first-party cookie access. The fix adds two MESSAGE_CHECKs: url.isValid() && url.protocolIsInHTTPFamily() (rejecting file:// and other non-HTTP schemes) and m_networkProcess->allowsFirstPartyForCookies(…) == Allow (enforcing the first-party-for-cookies policy for the request). The sibling UIProcess entry, WebPageProxy::loadAndDecodeImage, likewise adds !request.url().protocolIsInHTTPFamily() to its early rejection. The restored invariant is that renderer-initiated image-decoding loads are limited to HTTP(S) and honor cookie policy, closing the local-file/cross-boundary read. The regression test drives the IPC directly with a file:// request and expects a MESSAGE_CHECK failure mentioning protocolIsInHTTPFamily.
Attack Path
1. Compromise WebContent
An attacker with code execution in the WebContent process can send crafted IPC to the Network process.
2. Request a file:// URL
Send NetworkConnectionToWebProcess::LoadImageForDecoding with a file:///… request that previously only had to be url.isValid().
3. Network process reads the file
The Network process loads the local file and returns its bytes as image data.
4. Exfiltrate outside the sandbox
The renderer reconstructs the local file contents it should not have access to — a sandbox escape.
Impact Assessment
A sandbox-escape-class information disclosure: a compromised WebContent process could make the Network process read arbitrary local files (and reach non-HTTP resources) via the image-decoding path. The advisory frames it as processing restricted content outside the sandbox; it is a cross-process capability leak (local file read/SSRF), not memory corruption, but a meaningful escalation step.
Audit Directions
Same file: renderer-reachable loads
Grep NetworkConnectionToWebProcess for IPC load handlers that check only url.isValid() without protocolIsInHTTPFamily()/scheme allow-listing.
Scheme validation on IPC URLs
Audit UIProcess/NetworkProcess entry points that accept a ResourceRequest from the renderer for file://, data:, and custom-scheme handling and first-party policy checks.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
NetworkConnectionToWebProcess::loadImageForDecoding |
Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp |
modified | Adds MESSAGE_CHECKs that the URL is valid AND protocolIsInHTTPFamily(), and that allowsFirstPartyForCookies(…)==Allow, rejecting file:// and non-HTTP schemes. |
WebPageProxy::loadAndDecodeImage |
Source/WebKit/UIProcess/WebPageProxy.cpp |
modified | Extends the early-return guard to also reject requests whose URL is not in the HTTP family. |
Files Changed
Source Files
Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cppSource/WebKit/UIProcess/WebPageProxy.cpp
Test Files
LayoutTests/ipc/load-image-for-decoding-file-url-expected.txtLayoutTests/ipc/load-image-for-decoding-file-url.html
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/LayoutTests/ipc/load-image-for-decoding-file-url-expected.txt b/LayoutTests/ipc/load-image-for-decoding-file-url-expected.txt
new file mode 100644
index 000000000000..4732d73458a8
--- /dev/null
+++ b/LayoutTests/ipc/load-image-for-decoding-file-url-expected.txt
@@ -0,0 +1,2 @@
+PASS: file:// rejected by MESSAGE_CHECK
+
diff --git a/LayoutTests/ipc/load-image-for-decoding-file-url.html b/LayoutTests/ipc/load-image-for-decoding-file-url.html
new file mode 100644
index 000000000000..c4b04a09abf7
--- /dev/null
+++ b/LayoutTests/ipc/load-image-for-decoding-file-url.html
@@ -0,0 +1,68 @@
+<!DOCTYPE html><!-- webkit-test-runner [ IPCTestingAPIEnabled=true ] -->
+<html><body>
+<pre id="log"></pre>
+<script>
+if (window.testRunner) {
+ testRunner.dumpAsText();
+ testRunner.waitUntilDone();
+}
+function log(s) { document.getElementById('log').textContent += s + '\n'; }
+
+async function main() {
+ if (!window.IPC) {
+ log('PASS: file:// rejected by MESSAGE_CHECK');
+ return;
+ }
+
+ const { CoreIPC, ArgumentSerializer } = await import('./coreipc.js');
+
+ const fileRequest = {
+ getRequestDataToSerialize: {
+ variantType: 'WebCore::ResourceRequest::RequestData',
+ variant: {
+ m_url: { string: 'file:///private/etc/hosts' },
+ m_firstPartyForCookies: { string: 'file:///private/etc/hosts' },
+ m_timeoutInterval: 60.0,
+ m_httpMethod: 'GET',
+ m_httpHeaderFields: { commonHeaders: [], uncommonHeaders: [] },
+ m_responseContentDispositionEncodingFallbackArray: [],
+ m_cachePolicy: 0,
+ m_sameSiteDisposition: 1,
+ m_priority: 2,
+ m_requester: 0,
+ m_allowCookies: true,
+ m_isTopSite: true,
+ m_isAppInitiated: true,
+ m_privacyProxyFailClosedForUnreachableNonMainHosts: false,
+ m_useAdvancedPrivacyProtections: false,
+ m_didFilterLinkDecoration: false,
+ m_isPrivateTokenUsageByThirdPartyAllowed: false,
+ m_wasSchemeOptimisticallyUpgraded: false,
+ m_targetAddressSpace: 0,
+ },
+ },
+ cachePartition: '',
+ hiddenFromInspector: false,
+ };
+
+ const definition = IPC.messages.NetworkConnectionToWebProcess_LoadImageForDecoding;
+ const args = ArgumentSerializer.serializeArguments(definition.arguments,
+ { request: fileRequest, pageID: BigInt(IPC.webPageProxyID), maximumBytesFromNetwork: 1024n });
+
+ CoreIPC.Networking.NetworkConnectionToWebProcess.TakeInvalidMessageStringForTesting(0, {}, () => { });
+
+ IPC.connectionForProcessTarget('Networking').sendWithAsyncReply(0, definition.name, args, () => { });
+
+ let messageCheck = null;
+ CoreIPC.Networking.NetworkConnectionToWebProcess.TakeInvalidMessageStringForTesting(0, {},
+ reply => messageCheck = reply.error);
+
+ if (messageCheck && messageCheck.includes('Message check failed') && messageCheck.includes('protocolIsInHTTPFamily'))
+ log('PASS: file:// rejected by MESSAGE_CHECK');
+ else
+ log('FAIL: expected MESSAGE_CHECK on protocolIsInHTTPFamily, got: "' + messageCheck + '"');
+}
+
+main().catch(e => log('FAIL: ' + e + '\n' + (e.stack || ''))).finally(() => window.testRunner?.notifyDone());
+</script>
+</body></html>