Released June 29, 2026

CVE-2026-43725

CVSS 7.1 severity high CWE-20 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L

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.cpp
  • Source/WebKit/UIProcess/WebPageProxy.cpp

Test Files

  • LayoutTests/ipc/load-image-for-decoding-file-url-expected.txt
  • LayoutTests/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>