← WebKit — 2026-W41

87b3951978a0e00dd5ca5322deb6168843af91a9  Local Network Access trusts the web process's description of the requesting document

severity medium class PolicyBypass confidence 0.78 signal score 11.06 Source/WebCore/dom patch-now AI review CONFIRMED
Primitive: Compromised web process spoofs isMainFrameLoad / under-reports source origin+address space to skip the Local Network Access permission check
Triage note: NetworkResourceLoader::checkLocalNetworkAccess no longer blindly skips the LNA check for isMainFrameLoad(); it now also requires RegistrableDomain(url)==RegistrableDomain(firstPartyForCookies), validates sourceOrigin against a network-process-recorded LocalNetworkAccessFrameRecord (falling back to an opaque origin on mismatch) and re-derives clientAddressSpace/clientIsSecureContext. This closes the web-process-controlled LNA bypass the removed FIXME (bug 319908/326009) described; the Internals/testing setters are just test scaffolding for it.
Structural signals: smartptr_member_added (+6.47), null_guard (+2.59), area:Source/WebCore/dom (+2.0)

The bug at a glance

The strongest bug in the batch (triage confidence 0.78): a PolicyBypass where NetworkProcess’s Local Network Access (LNA) gate trusted web-process-supplied inputs. Pre-patch, checkLocalNetworkAccess skipped the LNA permission check entirely whenever isMainFrameLoad() was true — a label a compromised web process controls — and derived clientAddressSpace/clientIsSecureContext/sourceOrigin from renderer-supplied NetworkResourceLoadParameters, which a removed FIXME admitted a compromised process could under-report to appear “already as private as the target” and skip the check. The effect is that a public, malicious origin could issue requests into the user’s private/loopback network (routers, local admin panels, localhost services) without the LNA permission prompt — a CSRF-style reach into the local network. NetworkProcess is the trust boundary and must not trust these fields. Medium severity (local-network exposure / CSRF, not direct RCE); the fix is a solid network-process-side re-derivation and is the most clearly exploitable of the six.

The security check lived behind a one-line || isMainFrameLoad() short-circuit whose boolean was supplied by the very process being policed, and the address-space comparison trusted a privacy label the renderer could set to whatever skipped the check.

Root cause

The invariant: the LNA decision (may a context in a more-public address space reach a more-private one?) must be computed in the NetworkProcess from values the NetworkProcess itself observed, never from the web process’s self-report. Pre-patch NetworkResourceLoader::checkLocalNetworkAccess began with if (!canDetermineConnectionAddressSpace() || !connectionToWebProcess().localNetworkAccessEnabled() || isMainFrameLoad()) return completionHandler(std::nullopt); — so any load flagged main-frame was exempt — and then used clientAddressSpace, clientIsSecureContext and sourceOrigin straight from m_parameters (NetworkResourceLoadParameters), which the web process fills in. The struct’s own comments (now revised) conceded the hole: a compromised web process can under-report clientAddressSpace to claim it is already at least as private as the target (skipping the check), tracked as bugs 319908/326009.

Pre-patch attacker chain: 1. a public-origin page (or a compromised web process) issues a subresource/navigation toward a private/loopback host; 2. it labels the load isMainFrameLoad() and/or reports clientAddressSpace as Local/Loopback and clientIsSecureContext as convenient; 3. checkLocalNetworkAccess either short-circuits on the main-frame label or computes isLessPublicThan against the forged address space and finds no transition to gate; 4. the request proceeds to the private host with no LNA permission prompt.

The fix keeps the main-frame exemption only when it is trustworthy: if (isMainFrameLoad() && RegistrableDomain{originalRequest().url()} == RegistrableDomain{originalRequest().firstPartyForCookies()}) return completionHandler(std::nullopt); — i.e. trust the main-frame label only for a load that actually started at its own validated first party. For everything else it cross-checks against NetworkProcess-recorded state: NetworkConnectionToWebProcess now keeps m_localNetworkAccessFrameRecords (FrameIdentifier→{IPAddressSpace addressSpace, SecurityOriginData origin}), populated by recordLocalNetworkAccessFrame() from real responses (continueDidReceiveResponseAfterLocalNetworkAccessCheck, cache-entry retrieval, cross-process transfer). In the check it resolves the record for the client/parent frame, sets recordMatchesClient = record && record->origin == sourceOrigin, and on mismatch forces sourceOrigin = SecurityOriginData::createOpaque(); it clamps clientAddressSpace up to the recorded (more-private-wins) space via if (isLessPublicThan(clientAddressSpace, recordedAddressSpace)) clientAddressSpace = recordedAddressSpace; so under-reporting cannot make the client look more private than observed; and it narrows clientIsSecureContext to also require the top origin be an allowed first party and shouldTreatAsPotentiallyTrustworthy(sourceOrigin). All of this is NetworkProcess-side state; the Internals setters (setDocumentIPAddressSpace, setLoadSourceOriginOverride) and WebLoaderStrategy override are test scaffolding. No lifetime issue — this is an authority/provenance fix.

Key code

Trust the main-frame label only at a validated first party; re-derive origin/address-space in NetworkProcess

if (!WebCore::canDetermineConnectionAddressSpace() || !connectionToWebProcess().localNetworkAccessEnabled())
    return completionHandler(std::nullopt);
// A web process can label any load as a main frame load, so only trust the label for a load that started at its own validated first party.
if (isMainFrameLoad() && RegistrableDomain { originalRequest().url() } == RegistrableDomain { originalRequest().firstPartyForCookies() })
    return completionHandler(std::nullopt);
...
auto record = clientFrameID ? connection->localNetworkAccessFrameRecord(*clientFrameID, clientParentFrameID) : std::nullopt;
bool recordMatchesClient = record && record->origin == sourceOrigin;
if (!recordMatchesClient)
    sourceOrigin = SecurityOriginData::createOpaque();
auto recordedAddressSpace = recordMatchesClient && record->addressSpace != IPAddressSpace::Unknown ? record->addressSpace : IPAddressSpace::Public;
if (isLessPublicThan(clientAddressSpace, recordedAddressSpace))
    clientAddressSpace = recordedAddressSpace;

Fix approach

The patch replaces blind trust in web-process-supplied load metadata with NetworkProcess-held ground truth. The main-frame short-circuit is narrowed to loads whose URL and firstPartyForCookies share a registrable domain, so a web process cannot win the exemption by mislabelling an arbitrary cross-site load as a main-frame load. For non-exempt loads, the NetworkProcess looks up a per-frame LocalNetworkAccessFrameRecord it recorded from actual responses; if the recorded origin does not match the claimed sourceOrigin it substitutes an opaque origin (denying any origin-based allowance), clamps the client address space to be no more private than what it observed (defeating under-reporting), and only keeps clientIsSecureContext if the top origin is an allowed first party and the source origin is actually potentially-trustworthy. The decision then flows into WebCore::checkLocalNetworkAccess with sanitized inputs. Net: every input to the LNA gate that the web process could previously forge is now either validated against or overridden by NetworkProcess state.

Patch walkthrough

  • Source/WebKit/NetworkProcess/NetworkResourceLoader.cpp — Rewrites checkLocalNetworkAccess to drop the bare isMainFrameLoad() exemption (now requires registrable-domain match with firstPartyForCookies), and adds record lookup + origin/address-space/secure-context re-derivation; adds recordLocalNetworkAccessFrame() called from response, cache-entry, and cross-process-transfer paths.
  • Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp/.h — Adds the LocalNetworkAccessFrameRecord struct, the m_localNetworkAccessFrameRecords map, and record/lookup helpers (with parent-frame fallback); clears records on browsingContextRemoved.
  • Source/WebKit/NetworkProcess/NetworkResourceLoadParameters.h — Rewrites the FIXMEs: acknowledges clientAddressSpace/clientIsSecureContext/permissions-policy fields are web-process-controlled and must not be trusted blindly (the vulnerability’s root).
  • Source/WebKit/WebProcess/Network/WebLoaderStrategy.cpp — Honors a testing-only loadSourceOriginOverride when populating loadParameters.sourceOrigin (test scaffolding for the new validation).
  • Source/WebCore/dom/Document.cpp/.h + Source/WebCore/testing/Internals.* — Add setLoadSourceOriginOverrideForTesting / setDocumentIPAddressSpace / setLoadSourceOriginOverride test hooks to drive the new checks from layout tests.

Background

Local Network Access (LNA / formerly Private Network Access) — A policy gating requests that cross from a more-public address space (public internet) to a more-private one (local network, loopback); such transitions require a permission/secure-context and otherwise prompt or fail, to stop public sites from attacking LAN/localhost devices.

IPAddressSpace (Public/Local/Loopback/Unknown) — Classification of an endpoint’s network location; isLessPublicThan(a,b) is true when a is more private than b, and a transition from more-public to more-private is what LNA guards.

NetworkProcess trust boundary — The NetworkProcess is trusted and performs the actual socket I/O; NetworkResourceLoadParameters come from the untrusted web process, so any security decision based on them must be re-validated against NetworkProcess-observed data.

isMainFrameLoad() / firstPartyForCookies — isMainFrameLoad() marks a top-level navigation; firstPartyForCookies is the top-level site of a load. Requiring RegistrableDomain(url)==RegistrableDomain(firstPartyForCookies) ties the main-frame label to a load genuinely at its own first party.

LocalNetworkAccessFrameRecord — New NetworkProcess-side per-frame record {addressSpace, origin} captured from real responses, used to validate the web process’s later claims about a client frame’s origin and address space.

Opaque origin fallback — SecurityOriginData::createOpaque() yields an origin that is same-origin with nothing; substituting it when the claimed origin can’t be corroborated denies any origin-keyed LNA allowance.

Vulnerability window

  1. Setup — User visits (or is navigated to) a public-origin page; attacker wants to reach a private/loopback endpoint (router admin, localhost service).
  2. Forge — The web process issues the request labelling it a main-frame load and/or reporting clientAddressSpace as Local/Loopback and a convenient sourceOrigin/secure-context.
  3. Bypass (pre-patch) — checkLocalNetworkAccess short-circuits on isMainFrameLoad() or finds no public→private transition against the forged address space, so no permission check runs.
  4. Reach — The request completes to the private host, enabling CSRF/exfiltration against LAN/localhost services.
  5. Post-patch — NetworkProcess narrows the main-frame exemption and re-derives origin/address-space from its own frame records, so the transition is correctly gated.

Proof of concept

No end-to-end web regression test ships in the diff (only Internals test hooks). Conceptual trigger: from a public-origin page, fetch() a resource on a private/loopback host (e.g. http://192.168.1.1/ or http://localhost:PORT/) in a way that the pre-patch code treats as a main-frame load or lets the web process report a private clientAddressSpace; pre-patch the LNA prompt is skipped and the request proceeds. Driving the forged inputs directly requires an instrumented/compromised web process; the layout-test scaffolding (setDocumentIPAddressSpace/setLoadSourceOriginOverride) simulates it. Full exploit specifics are (inferred).

Exploitation

  1. Reach — A malicious public web page (for the main-frame-label path) or a compromised web process (for the address-space/origin under-reporting path).
  2. Trigger — Issue a request toward a private/loopback endpoint while mislabelling the load or under-reporting the client’s address space/origin to dodge the transition check.
  3. Primitive — Unprompted cross-address-space request into the user’s local network / loopback — CSRF, local-service probing, and read-back where responses are observable.
  4. Escalation — Depends on the targeted local service (unauthenticated router config, dev servers, IoT admin); escalation beyond CSRF/recon is service-specific and (inferred).

Detection & hunting

For defenders and detection engineers:

  • LNA check skipped for a cross-site main-frame load — Log loads taking the isMainFrameLoad() exemption where RegistrableDomain(url) != RegistrableDomain(firstPartyForCookies) — the exact pre-patch bypass.
  • Claimed clientAddressSpace more private than recorded — Alert when isLessPublicThan(clientAddressSpace, recordedAddressSpace) triggers a clamp, indicating web-process under-reporting.
  • sourceOrigin not matching the frame record — Monitor the opaque-origin fallback (recordMatchesClient == false) as a sign of origin spoofing in load parameters.

Remaining risk in the patch

  • Permissions-policy booleans still trusted — The revised FIXME (bug 326009) states localNetworkAllowedByPermissionsPolicy / loopbackNetworkAllowedByPermissionsPolicy remain web-process-controlled and can still be claimed to skip the permissions-policy refusal — this commit does not close that field.
  • Record availability gaps — localNetworkAccessFrameRecord only exists once a qualifying main-resource response was observed (HTTP(S)/file); before that, or when the record is Unknown, the code falls back to Public, which is safe-by-default but means the origin/address-space validation has no positive record to match and relies on the opaque-origin fallback.
  • Parent-frame fallback matching — When no record exists for the client frame, the code falls back to the parent frame’s record; whether that fallback can ever attribute a parent’s more-private space to an unrelated child is not fully verifiable from the diff.

Audit directions

  • Other NetworkProcess decisions on NetworkResourceLoadParameters — Audit every security check keyed on clientAddressSpace, clientIsSecureContext, sourceOrigin, isMainFrameLoad(), or permissions-policy booleans supplied by the web process (e.g. the remaining localNetworkAllowedByPermissionsPolicy FIXME, bug 326009).
  • Frame-record lifetime and parent fallback — Review localNetworkAccessFrameRecord parent-frame fallback and removal on browsingContextRemoved for stale/confused records across navigations and process transfers.
  • Other isMainFrameLoad()/firstParty trust points — Grep for load-type or first-party labels used to skip security checks and confirm they’re corroborated against NetworkProcess state.
  • transferToNewWebProcess record carry-over — Verify recordLocalNetworkAccessFrame on cross-process transfer cannot be used to plant a favorable record for a frame the new process shouldn’t speak for.

Before / after

Loading diff…