← All reports

[4] LoadImageForDecoding accepted arbitrary schemes and spoofed first parties

HighWebKit NetworkProcess IPC surfaceAuthBypass

One field of the request was checked. The ones carrying authority were not.

ccf0c48

High. A network-process handler that fetches bytes on the renderer's behalf validated one property of one field on a struct carrying URL, first party, and cookie attribution. Not a first-stage bug — it needs a renderer compromise — but from there it is a direct read across the sandbox boundary with no further conditions.

WebKit splits work across processes: the WebContent process renders untrusted content in a tight sandbox, while the NetworkProcess performs network and cache I/O under a different sandbox profile. WebContent asks the NetworkProcess to do work by sending IPC messages, and NetworkConnectionToWebProcess is the per-renderer message receiver on the network side that services resource-loading requests. Those requests arrive as a WebCore::ResourceRequest — a compound serialized struct carrying not only the URL but firstPartyForCookies, isTopSite, sameSiteDisposition, allowCookies, and arbitrary header fields — and the receiver is expected to treat every field as attacker-controlled.

The angle: a compromised renderer can point this handler at file:///private/etc/hosts, or at a cross-origin HTTPS URL under a spoofed first party, and get the fetched bytes handed back into its own address space.

The commit message is unambiguous about both legs:

LoadImageForDecoding accepted arbitrary ResourceRequest fields with only a url.isValid() check. This allowed file:// reads of NetworkProcess-sandbox files and credentialed cross-origin body reads via spoofed firstPartyForCookies. Restrict the URL to HTTP(S) and enforce allowsFirstPartyForCookies, matching every other cookie-touching IPC entry point.

Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp

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();

Source/WebKit/UIProcess/WebPageProxy.cpp

void WebPageProxy::loadAndDecodeImage(WebCore::ResourceRequest&& request, ...)
{
- if (isClosed() || !request.url().isValid())
+ if (isClosed() || !request.url().isValid() || !request.url().protocolIsInHTTPFamily())
return completionHandler(makeUnexpected(decodeError(request.url())));

LayoutTests/ipc/load-image-for-decoding-file-url.html

+ const fileRequest = {
+ getRequestDataToSerialize: {
+ variantType: 'WebCore::ResourceRequest::RequestData',
+ variant: {
+ m_url: { string: 'file:///private/etc/hosts' },
+ m_firstPartyForCookies: { string: 'file:///private/etc/hosts' },
+ ...
+ m_allowCookies: true,
+ m_isTopSite: true,
+ const definition = IPC.messages.NetworkConnectionToWebProcess_LoadImageForDecoding;
+ const args = ArgumentSerializer.serializeArguments(definition.arguments,
+ { request: fileRequest, pageID: BigInt(IPC.webPageProxyID), maximumBytesFromNetwork: 1024n });
+ IPC.connectionForProcessTarget('Networking').sendWithAsyncReply(0, definition.name, args, () => { });
+ if (messageCheck && messageCheck.includes('Message check failed') && messageCheck.includes('protocolIsInHTTPFamily'))
+ log('PASS: file:// rejected by MESSAGE_CHECK');

Two entry points that fetch an image on behalf of a client are tightened. NetworkConnectionToWebProcess::loadImageForDecoding() previously guarded the IPC-supplied ResourceRequest with a single MESSAGE_CHECK_COMPLETION(url.isValid(), ...). The patch extends that check to url.isValid() && url.protocolIsInHTTPFamily(), and adds a second MESSAGE_CHECK_COMPLETION requiring m_networkProcess->allowsFirstPartyForCookies(m_webProcessIdentifier, request.firstPartyForCookies()) == NetworkProcess::AllowCookieAccess::Allow. Both failures invoke the completion handler with an empty ResourceError and then trip the message-check path for the connection.

WebPageProxy::loadAndDecodeImage() gains the same scheme restriction in its early-out: isClosed() || !request.url().isValid() || !request.url().protocolIsInHTTPFamily(). The collateral hunks are a new IPCTestingAPI layout test that hand-serializes a LoadImageForDecoding message carrying m_url and m_firstPartyForCookies set to file:///private/etc/hosts, sends it directly on the Networking connection, and asserts that the resulting invalid-message string mentions protocolIsInHTTPFamily.

Partial validation of a compound, attacker-serialized request struct — one field's well-formedness checked, while the fields carrying authority are trusted as sent.

Where this lives. WebKit splits work across processes — the WebContent process renders untrusted web content in a tight sandbox, the NetworkProcess performs network and cache I/O under a different sandbox profile. The NetworkProcess treats every field of an incoming message as attacker-controlled, since a renderer compromise is an assumed threat.

MESSAGE_CHECK_COMPLETION. A WebKit macro used in IPC handlers; when the asserted condition is false it runs the supplied completion expression (so the async reply is not dropped) and reports an invalid message on the connection, which terminates the misbehaving sender. The IPCTestingAPI exposes the resulting invalid-message string to test code via TakeInvalidMessageStringForTesting.

WebCore::ResourceRequest. A compound serializable type carrying the URL plus a large set of loading attributes — firstPartyForCookies, httpMethod, header fields, cache policy, sameSiteDisposition, isTopSite, allowCookies, priority, requester. All of these cross the IPC boundary as part of the RequestData variant.

URL::isValid() vs URL::protocolIsInHTTPFamily(). isValid() reports whether the string parsed into a structurally well-formed URL; protocolIsInHTTPFamily() reports whether the scheme is http or https. They are independent properties.

firstPartyForCookies and its guard. firstPartyForCookies is the URL the cookie machinery treats as the top-level site for a given load; it drives SameSite enforcement and cookie partitioning decisions. NetworkProcess::allowsFirstPartyForCookies(processIdentifier, url) is the NetworkProcess-side registry check answering whether a particular web process has been granted the right to claim a given first party, returning an AllowCookieAccess enum whose Allow value is the only passing state. It is the standard guard on cookie-touching IPC entry points.

Reply shape. The handler completes with Expected<Ref<WebCore::FragmentedSharedBuffer>, WebCore::ResourceError> — the fetched bytes themselves travel back to the caller.

IPCTestingAPI. A test-only facility, gated behind the IPCTestingAPIEnabled test-runner flag, that lets a layout test hand-serialize and send raw IPC messages on a named process connection — used here to simulate a compromised renderer.

This is missing input validation at a privilege boundary, producing two distinct consequences: a confused-deputy local file read (SSRF-shaped information disclosure across the sandbox) and a cookie-attribution spoof (first-party policy bypass leading to credentialed cross-origin body disclosure).

  Pre-fix                                   Post-fix
  WebContent (compromised)                  WebContent (compromised)
    │ LoadImageForDecoding                    │ LoadImageForDecoding
    ▼                                         ▼
  ┌─ NetworkProcess ───────────┐            ┌─ NetworkProcess ───────────┐
  │  url.isValid()  ✓          │            │  isValid() && HTTPFamily ✗ │
  │  scheme:        (unchecked)│            │  allowsFirstParty…      ✗  │
  │  firstParty:    (unchecked)│            │      └─► invalid message,  │
  │      └─► fetch file:// ────┼──┐         │          connection killed │
  └────────────────────────────┘  │         └────────────────────────────┘
       FragmentedSharedBuffer ◄───┘
       (bytes returned to sender)

The handler received a fully attacker-serializable ResourceRequest and validated exactly one property of one field: url.isValid(), which only asserts the URL parsed into a well-formed URL and says nothing about the scheme. The two missing invariants are the ones the rest of the NetworkProcess IPC surface enforces on request-bearing messages: the request URL must be in the HTTP family, so that a lower-privileged process cannot direct the NetworkProcess to read from a scheme resolving against the NetworkProcess's own resource namespace; and firstPartyForCookies must be a value the sending web process is registered as allowed to claim.

The handler passes the request through to the network session's loader and returns the fetched bytes to the caller as Expected<Ref<FragmentedSharedBuffer>, ResourceError> — the raw response body is handed back over IPC to the requesting process, not merely decoded pixels rendered somewhere the requester already controls. That reply shape is what turns both missing checks into disclosure primitives. On the scheme axis: because the URL scheme was unconstrained, a file:// URL passed isValid(), and the fetch was performed by the NetworkProcess, whose sandbox profile differs from WebContent's. On the cookie axis: with no allowsFirstPartyForCookies check, the sender could set firstPartyForCookies — and, in the same serialized struct, m_isTopSite and m_sameSiteDisposition — to the victim origin while requesting a cross-origin URL, so the load would be issued with the victim's cookies attached and the response body returned to the sender. The test case demonstrates the shape precisely, constructing the RequestData variant by hand with m_url and m_firstPartyForCookies both file:///private/etc/hosts, m_allowCookies: true, and m_isTopSite: true.

The precondition is an already-compromised (or IPC-capable) WebContent process; loadImageForDecoding is a NetworkProcess IPC handler, not a web-exposed API, so ordinary JavaScript cannot reach it without either a prior renderer compromise or the test-only IPCTestingAPI. Given that precondition, the test in this commit is the trigger recipe: construct a RequestData variant with m_url set to the target, set m_firstPartyForCookies, m_allowCookies: true and m_isTopSite: true to taste, serialize with the NetworkConnectionToWebProcess_LoadImageForDecoding message definition along with the process's own pageID and an attacker-chosen maximumBytesFromNetwork, and send it on the Networking connection.

Two directions follow. On the scheme leg, with m_url set to a file:// path, the pre-fix isValid()-only guard passes and the load would be serviced by the NetworkProcess's session rather than the renderer's; because the completion handler returns a FragmentedSharedBuffer of the fetched bytes to the sender, the file contents would be readable by the compromised renderer, bounded by maximumBytesFromNetwork which the sender also controls. The commit message states this reached NetworkProcess-sandbox files; the supplied context stops at the MESSAGE_CHECK and does not include the NetworkLoad/session code path that would show file:// actually being serviced, so that leg is relayed as the author's claim. On the cookie leg, requesting a cross-origin HTTPS URL while setting m_firstPartyForCookies to the victim's site means the load would be attributed as first-party for SameSite and partitioning purposes, so credentialed responses could be fetched and their bodies returned over the same completion handler. Realising that additionally requires that the target endpoint serves content the cookie policy would otherwise have withheld and that no separate CORS/response-filtering step sits between the loader and the completion handler — the supplied context does not include that portion of the load path.

This vulnerability weakens the WebContent-to-NetworkProcess privilege boundary and, separately, the cookie/same-site policy boundary. The security model assumes two things about request-bearing IPC from a renderer: that the renderer cannot use the NetworkProcess as a deputy to reach resource namespaces outside its own sandbox, and that a renderer cannot assert an arbitrary first-party identity for cookie-attachment decisions — the latter is exactly what allowsFirstPartyForCookies() exists to enforce. Before the fix, an attacker who had already compromised a WebContent process could have used this message as a read primitive against files reachable from the NetworkProcess sandbox, and could have issued credentialed cross-origin loads under a spoofed first party while receiving the response body back over IPC. The gain is information disclosure — local file contents and cross-origin authenticated response bodies — which is chain material for privilege escalation and cross-site data theft, not memory corruption.

The commit message's own framing — "matching every other cookie-touching IPC entry point" — is the interesting part. allowsFirstPartyForCookies is an established, widely applied guard; this handler was simply the one that did not get it. That is the signature of an entry point added later than the convention it should have followed, and it suggests the productive audit unit is not "find missing bounds checks" but "enumerate every IPC handler that accepts a ResourceRequest and diff its guard prologue against the convention." Worth noting too that the fix constrains only firstPartyForCookies and the scheme; the same serialized RequestData still carries m_isTopSite, m_sameSiteDisposition, m_allowCookies, and arbitrary m_httpHeaderFields from the renderer, and the test explicitly sets m_isTopSite: true. Whether those remaining renderer-supplied trust signals are independently re-derived on the NetworkProcess side is a separate question this patch does not answer.