CVE-2026-43725
Overview
Background
- 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
- Compromise WebContent An attacker with code execution in the WebContent process can send crafted IPC to the Network process.
- Request a file:// URL Send NetworkConnectionToWebProcess::LoadImageForDecoding with a file:///… request that previously only had to be url.isValid().
- Network process reads the file The Network process loads the local file and returns its bytes as image data.
- Exfiltrate outside the sandbox The renderer reconstructs the local file contents it should not have access to — a sandbox escape.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifLayoutTests/ipc/load-image-for-decoding-file-url.html |
modified |
Files Changed
LayoutTests/ipc/load-image-for-decoding-file-url-expected.txtLayoutTests/ipc/load-image-for-decoding-file-url.htmlSource/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cppSource/WebKit/UIProcess/WebPageProxy.cpp
Audit Directions
- Same file: renderer-reachable loadsGrep NetworkConnectionToWebProcess for IPC load handlers that check only url.isValid() without protocolIsInHTTPFamily()/scheme allow-listing.
- Scheme validation on IPC URLsAudit UIProcess/NetworkProcess entry points that accept a ResourceRequest from the renderer for file://, data:, and custom-scheme handling and first-party policy checks.
Patch
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())
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>
Regression Test / PoC
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>