← All reports

[4] Cookie access validation for startDownload() and convertMainResourceLoadToDownload()

MediumWebKit NetworkProcessCrossOrigin

The download path took the renderer's word for which site it was on.

e30ca29

Medium — two download entry points accepted a renderer-chosen first-party value that every other cookie-sensitive load path validates. The gap needs a prior renderer compromise to reach and yields no memory-corruption primitive, but it converts that compromise into authenticated state-changing requests against every site with live cookies.

WebKit runs network loads in a separate NetworkProcess that owns the cookie jar, precisely so that a sandboxed renderer cannot dictate which cookies are attached to a request. Every ResourceRequest carries a firstPartyForCookies URL naming the top-level site the request belongs to, and the cookie layer uses it to decide SameSite attachment and third-party blocking. The network process is supposed to independently confirm that the sending renderer is actually hosting content for the domain it names, rather than taking the claim at face value.

The angle: a compromised renderer can start a download whose request names any victim site as its first party, and the network process attaches that site's SameSite-restricted cookies to an attacker-chosen endpoint — CSRF that the server sees as user-initiated first-party traffic.

The commit message is explicit: NetworkConnectionToWebProcess::startDownload() and convertMainResourceLoadToDownload() do not validate the firstPartyForCookies in the requests sent to them by the web process, so a web process could send in a cross-site origin, leading to requests being sent to that cross-site origin containing the cookies for it — requests that would be seen as legitimate even though they did not come from the user. The fix adds message checks confirming the web process actually has cookie access to the origin it names. The check had to be made conditional on a non-empty firstPartyForCookies because unconditional checking crashed at the ASSERT_NOT_REACHED() in NetworkProcess::allowsFirstPartyForCookies for tests such as TEST(_WKDownload, DownloadRequestOriginalURLDirectDownload), since the PolicyAction::Download path never adds the web process to the map.

Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp

void NetworkConnectionToWebProcess::startDownload(DownloadID downloadID, const ResourceRequest& request, const std::optional<WebCore::SecurityOriginData>& topOrigin, ...)
{
+ if (!request.firstPartyForCookies().isEmpty())
+ MESSAGE_CHECK(m_networkProcess->allowsFirstPartyForCookies(m_webProcessIdentifier, request.firstPartyForCookies()) == NetworkProcess::AllowCookieAccess::Allow);
+
protect(m_networkProcess->downloadManager())->startDownload(m_sessionID, downloadID, request, topOrigin, isNavigatingToAppBoundDomain, suggestedName, fromDownloadAttribute, frameID, pageID, webProcessIdentifier());
}
 
void NetworkConnectionToWebProcess::convertMainResourceLoadToDownload(std::optional<...>)
{
RELEASE_ASSERT(RunLoop::isMain());
 
+ if (!request.firstPartyForCookies().isEmpty())
+ MESSAGE_CHECK(m_networkProcess->allowsFirstPartyForCookies(m_webProcessIdentifier, request.firstPartyForCookies()) == NetworkProcess::AllowCookieAccess::Allow);
+
if (!mainResourceLoadIdentifier) {
protect(m_networkProcess->downloadManager())->startDownload(m_sessionID, downloadID, request, topOrigin, isNavigatingToAppBoundDomain);
return;
}

Both handlers gain an input-validation guard before forwarding the WebProcess-supplied ResourceRequest to DownloadManager: MESSAGE_CHECK(m_networkProcess->allowsFirstPartyForCookies(m_webProcessIdentifier, request.firstPartyForCookies()) == NetworkProcess::AllowCookieAccess::Allow), which via MESSAGE_CHECK_BASE(assertion, this->connection()) tears down the offending IPC connection when the assertion fails. The guard is deliberately conditional, running only if (!request.firstPartyForCookies().isEmpty()). In convertMainResourceLoadToDownload() the check sits after the existing RELEASE_ASSERT(RunLoop::isMain()) and before the !mainResourceLoadIdentifier fallback that calls DownloadManager::startDownload directly. No other files, tests, or call sites are modified.

Privileged component trusting a security-policy field supplied by the untrusted component it is meant to arbitrate for, on one entry point while validating it on all the others.

Process model. WebContent processes are sandboxed and treated as fully untrusted — any renderer bug is assumed to end in arbitrary code execution there. The NetworkProcess is a separate, more privileged process that owns the cookie jar and performs all actual network I/O; every message crossing that boundary is attacker-controlled by assumption.

firstPartyForCookies. A URL carried on every WebCore::ResourceRequest naming the top-level site the request belongs to. The cookie layer uses it to decide whether a request is first-party or third-party, which drives SameSite=Lax/Strict cookie attachment and third-party-cookie blocking.

NetworkProcess::allowsFirstPartyForCookies(ProcessIdentifier, URL). The network process's authoritative check that a given web process is actually hosting content for a given first-party domain. It returns a NetworkProcess::AllowCookieAccess tri-state (Allow being the only accepting value in this comparison) based on a map of process → permitted first-party domains that is populated as pages load in that process.

MESSAGE_CHECK. WebKit's IPC-validation macro. Here it expands to MESSAGE_CHECK_BASE(assertion, this->connection()) — on failure it treats the message as malformed and tears down the connection to the sending process, the standard response to a renderer that has sent something it should be incapable of sending.

Download entry points. startDownload() begins a download from a fresh request; convertMainResourceLoadToDownload() converts an in-flight main-resource load into a download and, when no mainResourceLoadIdentifier is supplied, falls through to DownloadManager::startDownload() as well. Both end in DownloadManager::startDownload(), which builds NetworkLoadParameters from the request, sets StoredCredentialsPolicy::Use for non-ephemeral sessions, and creates a PendingDownload that performs the load.

CSRF. An attack in which a request is issued to a target site carrying the victim's ambient credentials, so the server executes it as if the user had initiated it.

The two handlers accepted a ResourceRequest straight off the IPC wire and handed it to DownloadManager::startDownload() without validating any of its cookie-policy-bearing fields:

  Before:                                  After:
  WebContent                               WebContent
    └─► StartDownload(req)                   └─► StartDownload(req)
          │  firstPartyForCookies = victim         │
          ▼                                        ▼
  NetworkProcess                           NetworkProcess
    └─► DownloadManager::startDownload       ├─ firstParty non-empty?
          └─► NetworkLoadParameters          │    └─► allowsFirstPartyForCookies(pid, fp)
                StoredCredentialsPolicy::Use │          └─ !Allow ──► MESSAGE_CHECK: kill connection
                  └─► victim's cookies sent  └─► DownloadManager::startDownload

The supplied DownloadManager::startDownload() source confirms the request is forwarded verbatim into NetworkLoadParameters parameters; parameters.request = request; and that credentials are enabled for non-ephemeral sessions (parameters.storedCredentialsPolicy = sessionID.isEphemeral() ? StoredCredentialsPolicy::DoNotUse : StoredCredentialsPolicy::Use;) before PendingDownload::create(...) issues the network load — so the forged first-party value reaches a real, credentialed outbound request. This is a logic error, not a memory-safety bug: nothing is corrupted, the wrong policy is applied.

The precondition is a compromised WebContent process — a normal renderer populates firstPartyForCookies from the document it is actually hosting, so this is not reachable by scripting alone. Given renderer code execution, the attacker forges a StartDownload or ConvertMainResourceLoadToDownload IPC message whose ResourceRequest points its URL at a state-changing endpoint on the victim site and sets firstPartyForCookies to that victim site's URL. Before the fix nothing compared that value against m_webProcessIdentifier, so the resulting PendingDownload load carried the victim's SameSite-restricted cookies to an endpoint the attacker chose. Repeating this per target site gives a broad authenticated-request primitive across every site with live cookies in the profile.

This change also leaves one intentional gap. The guard is conditional on !request.firstPartyForCookies().isEmpty(), so an empty first-party value still reaches DownloadManager::startDownload() with no process-to-origin check — the reachable state that remains is "download initiated by a web process for which no first-party relationship has been asserted". The assumption held rather than enforced is that an empty firstPartyForCookies cannot impersonate a specific victim site and that the resulting cookie classification is at least as restrictive as third-party. If any downstream code path treats an empty first-party as same-site by default, or derives it later from request.url(), the CSRF condition could reappear through the empty-value branch. The carve-out exists because the PolicyAction::Download path never registers the process in the map allowsFirstPartyForCookies consults, which is itself an enforcement gap worth closing at its source rather than at the check site.

This vulnerability weakened the WebContent-to-NetworkProcess trust boundary. WebKit's security model assumes the network process independently verifies that a renderer is entitled to the origins it names, so cookie attachment and SameSite classification cannot be dictated by the sandboxed side; before the fix these two download entry points violated that. An attacker with renderer code execution could have caused the network process to issue credentialed cross-site requests bearing a victim site's cookies, with the victim's server seeing them as legitimate first-party, user-initiated traffic — converting renderer compromise into authenticated state-changing actions on unrelated sites without defeating any additional sandbox layer.

The interesting part of this commit is not the missing check but why it could not simply be added unconditionally. The honest fix — validate always — fired an ASSERT_NOT_REACHED() on legitimate downloads, and the author worked around it by exempting empty first-party values. That is a recurring shape in IPC hardening: the validator's backing state is populated by one subsystem (page loads) while the entry point being hardened belongs to another (downloads), and the two have divergent registration lifecycles. Wherever a validator's ground-truth map is populated as a side effect of a different operation than the one being validated, expect either false rejections or, as here, a carve-out that reintroduces a narrower version of the original gap. The download/navigation-policy path is a good general hunting ground: it is the branch where a load stops being a load, so per-load bookkeeping tends to be skipped.