CVE-2026-11206
Overview
Background
- Service Worker Static Routing API
- A mechanism that lets a service worker declare routing rules so certain requests are served directly from a named source (e.g., the cache) without running fetch handler JavaScript.
- CORP (Cross-Origin Resource Policy)
- An HTTP response header (
Cross-Origin-Resource-Policy) that lets a resource restrict which origins may embed it, enforced browser-side to prevent cross-origin leaks. - `matched_source_type` / `kRaceNetworkAndCache`
- The static router source that produced a response;
kCachemeans the cache won, whilekRaceNetworkAndCacheraces network and cache and commits whichever wins first. - Opaque / synthesized response
- A response with
response_typeofkOpaque/kOpaqueRedirect(cross-origin, body hidden) and, for synthesized responses, an emptyurl_list.
Root Cause Analysis
Before the fix, both the browser-side and renderer-side static-router validators only ran IsValidStaticRouterResponse (which performs the CORP/opaque check) when matched_source_type == kCache; any other matched source — notably kRaceNetworkAndCache — skipped CORP validation entirely, letting a cache-sourced response escape the check when served in race mode. Separately, in service_worker_resource_loader.cc the CORP header check was gated behind !response->url_list.empty(), so a synthesized response with an empty url_list bypassed the check while still being allowed to carry an opaque response_type, letting a response falsely claim cross-origin/opaque status while concealing its true origin.
The fix removes the matched_source_type == kCache gate in both loaders so validation runs for every static-router response regardless of winning source, and replaces the empty-url_list skip with CHECK_NE assertions that a synthesized (empty url_list) response is never kOpaque or kOpaqueRedirect. Together these tighten the checks so race-mode cache responses are validated and poisoned synthesized responses are rejected before commit.
kCache source and skipping it for empty-url_list (synthesized) responses; the fix universally validates static-router responses and asserts that synthesized responses cannot be opaque.Attack Path
- Configure static routing in race mode
A service worker registers a static routing rule using the race-network-and-cache source so responses can be committed via
kRaceNetworkAndCacherather thankCache. (inferred) - Serve a cross-origin/opaque resource from cache
The cache source returns a cross-origin response that would fail CORP, but because
matched_source_typeis notkCache, the pre-patch loaders skipIsValidStaticRouterResponse. - Or forge a synthesized opaque response
Alternatively, produce a response with an empty
url_listand an opaqueresponse_type; the pre-patch!response->url_list.empty()gate skips the CORP header check entirely. - Commit unvalidated response The loader commits the response without CORP enforcement, delivering cross-origin content to a context that CORP was meant to protect.
- Cross-origin data exposure The embedding document can access or infer the cross-origin resource, bypassing the isolation CORP/COEP/DIP provide. (inferred)
Impact Assessment
ServiceWorker; per the commit the kRaceNetworkAndCache branch is “under construction,” and no exploited-in-the-wild status or further escalation is stated (any broader impact is (inferred)).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/common/service_worker/service_worker_resource_loader.cc |
modified | |
ifcontent/renderer/service_worker/service_worker_subresource_loader.cc |
modified |
Files Changed
content/browser/service_worker/service_worker_main_resource_loader.cccontent/common/service_worker/service_worker_resource_loader.cccontent/renderer/service_worker/service_worker_subresource_loader.cc
Audit Directions
- Source-type-gated security checksAudit routing/loader code that conditions validation on a specific
matched_source_typeor feature branch; new or “under construction” sources likekRaceNetworkAndCachemay silently escape the check. - Empty-list / synthesized short-circuitsSearch for
url_list.empty()(or similar emptiness guards) that skip origin/CORP validation, since synthesized responses can carry mismatchedresponse_typevalues. - CORP/COEP/DIP enforcement pointsCross-reference every path that commits a response with
CrossOriginResourcePolicy::IsBlockedByHeaderValueto ensure all delivery routes (main resource, subresource, race, cache) funnel through equivalent validation.
Patch
From 5046d3cddfac35b34e15bcf4afea7e85d17c30c5 Mon Sep 17 00:00:00 2001 From: Yoshisto Yanagisawa <[email protected]> Date: Thu, 30 Apr 2026 23:49:27 -0700 Subject: [PATCH] Strengthen security for Service Worker Static Router This CL addresses a security vulnerability where Cross-Origin Resource Policy (CORP) could be bypassed when using the Service Worker Static Routing API's cache source. 1. Ensure coverage for RaceNetworkAndCache The CORP check condition is expanded to include kRaceNetworkAndCache. This ensures that even when the static router is in race mode and the cache source wins, the resulting response is correctly validated before being committed. (The feature is under construction but just in case) 2. Prevent "poisoned" synthesized responses A CHECK is added to ensure that synthesized responses (those with an empty url_list) are never marked as kOpaque. This prevents potential attack vectors where a response claims to be cross-origin/opaque while hiding its actual origin. Bug: 505427216 Change-Id: Ifba499a8ca5742679aef26a6d6c80416e448ff1e Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7794699 Auto-Submit: Yoshisato Yanagisawa <[email protected]> Reviewed-by: Shunya Shishido <[email protected]> Commit-Queue: Yoshisato Yanagisawa <[email protected]> Cr-Commit-Position: refs/heads/main@{#1623731} --- diff --git a/content/browser/service_worker/service_worker_main_resource_loader.cc b/content/browser/service_worker/service_worker_main_resource_loader.cc index 3dd2dc4..8531d08a 100644 --- a/content/browser/service_worker/service_worker_main_resource_loader.cc +++ b/content/browser/service_worker/service_worker_main_resource_loader.cc @@ -912,25 +912,21 @@ cache_matcher_->cache_lookup_duration(); // Block invalid responses from the static router. - if (response_head_->service_worker_router_info->matched_source_type == - network::mojom::ServiceWorkerRouterSourceType::kCache) { - if (service_worker_client_ && service_worker_client_->container_host()) { - ServiceWorkerContainerHostForClient* container_host = - service_worker_client_->container_host(); - if (!IsValidStaticRouterResponse( - resource_request_, response, - container_host->policy_container_policies() - .cross_origin_embedder_policy, - container_host->cross_origin_embedder_policy_reporter().get(), - container_host->policy_container_policies() - .document_isolation_policy, - container_host->document_isolation_policy_reporter().get()) && - base::FeatureList::IsEnabled( - features::kServiceWorkerStaticRouterOpaqueCheck)) { - CommitCompleted(net::ERR_FAILED, - "Invalid response from static router"); - return; - } + if (service_worker_client_ && service_worker_client_->container_host()) { + ServiceWorkerContainerHostForClient* container_host = + service_worker_client_->container_host(); + if (!IsValidStaticRouterResponse( + resource_request_, response, + container_host->policy_container_policies() + .cross_origin_embedder_policy, + container_host->cross_origin_embedder_policy_reporter().get(), + container_host->policy_container_policies() + .document_isolation_policy, + container_host->document_isolation_policy_reporter().get()) && + base::FeatureList::IsEnabled( + features::kServiceWorkerStaticRouterOpaqueCheck)) { + CommitCompleted(net::ERR_FAILED, "Invalid response from static router"); + return; } } } diff --git a/content/common/service_worker/service_worker_resource_loader.cc b/content/common/service_worker/service_worker_resource_loader.cc index 850623d..92449413 100644 --- a/content/common/service_worker/service_worker_resource_loader.cc +++ b/content/common/service_worker/service_worker_resource_loader.cc @@ -102,15 +102,21 @@ // response's URL list for the CORP check instead of the request URL to // prevent a same-origin alias URL from bypassing the check. // This matches the behavior in CrossOriginResourcePolicyChecker::IsBlocked. - if (!response->url_list.empty() && - network::CrossOriginResourcePolicy::IsBlockedByHeaderValue( - response->url_list.back(), response->url_list.front(), - resource_request.request_initiator, corp_header_value, - resource_request.mode, resource_request.destination, - response->request_include_credentials, cross_origin_embedder_policy, - is_enabled ? cross_origin_embedder_policy_reporter : nullptr, - document_isolation_policy, - is_enabled ? document_isolation_policy_reporter : nullptr)) { + if (response->url_list.empty()) { + // Synthesized responses must not be opaque. + CHECK_NE(response->response_type, + network::mojom::FetchResponseType::kOpaque); + CHECK_NE(response->response_type, + network::mojom::FetchResponseType::kOpaqueRedirect); + } else if (network::CrossOriginResourcePolicy::IsBlockedByHeaderValue( + response->url_list.back(), response->url_list.front(), + resource_request.request_initiator, corp_header_value, + resource_request.mode, resource_request.destination, + response->request_include_credentials, + cross_origin_embedder_policy, + is_enabled ? cross_origin_embedder_policy_reporter : nullptr, + document_isolation_policy, + is_enabled ? document_isolation_policy_reporter : nullptr)) { if (is_enabled) { is_valid = false; result = CORPCheckResult::kBlocked; diff --git a/content/renderer/service_worker/service_worker_subresource_loader.cc b/content/renderer/service_worker/service_worker_subresource_loader.cc index a33d6c37..29addbfa 100644 --- a/content/renderer/service_worker/service_worker_subresource_loader.cc +++ b/content/renderer/service_worker/service_worker_subresource_loader.cc @@ -1496,20 +1496,16 @@ auto& response = result.value()->get_response(); // Block invalid responses from the static router. - if (response_head_->service_worker_router_info && - response_head_->service_worker_router_info->matched_source_type == - network::mojom::ServiceWorkerRouterSourceType::kCache) { - if (!IsValidStaticRouterResponse( - resource_request_, response, - controller_connector_->cross_origin_embedder_policy(), - controller_connector_->cross_origin_embedder_policy_reporter(), - controller_connector_->document_isolation_policy(), - controller_connector_->document_isolation_policy_reporter()) && - base::FeatureList::IsEnabled( - features::kServiceWorkerStaticRouterOpaqueCheck)) { - CommitCompleted(net::ERR_FAILED, "Invalid response from static router"); - return; - } + if (!IsValidStaticRouterResponse( + resource_request_, response, + controller_connector_->cross_origin_embedder_policy(), + controller_connector_->cross_origin_embedder_policy_reporter(), + controller_connector_->document_isolation_policy(), + controller_connector_->document_isolation_policy_reporter()) && + base::FeatureList::IsEnabled( + features::kServiceWorkerStaticRouterOpaqueCheck)) { + CommitCompleted(net::ERR_FAILED, "Invalid response from static router"); + return; } if (response->parsed_headers) {
Original Bug Report
Service Worker Static Routing API cache source bypasses CORP in crossOriginIsolated context (fix wiring ineffective)
Security Bug
Important: Please do not change the component of this bug manually.
Please READ THIS FAQ before filing a bug: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/faq.md
Please see the following link for instructions on filing security bugs: https://www.chromium.org/Home/chromium-security/reporting-security-bugs
Reports may be eligible for reward payments under the Chrome VRP: https://g.co/chrome/vrp
NOTE: Security bugs are normally made public once a fix has been widely deployed.
VULNERABILITY DETAILS
The CORP enforcement that commit e2b91876eb013 (crbug/497436273) added for the Service Worker Static Routing API cache source is wired incorrectly and does not actually block cross-origin responses. Enabling kServiceWorkerStaticRouterCORPCheck (and, for completeness, kServiceWorkerStaticRouterOpaqueCheck) has no effect on the subresource-embed bypass. A page in a crossOriginIsolated (COEP:require-corp) context can embed a cross-origin credentialed opaque response whose bytes were stored by a Service Worker via cache.put(...) and routed through the Static Routing API cache source.
Root cause: content/common/service_worker/service_worker_resource_loader.cc:99-106 passes resource_request.url as both the request_url and original_url arguments to network::CrossOriginResourcePolicy::IsBlockedByHeaderValue:
if (network::CrossOriginResourcePolicy::IsBlockedByHeaderValue(
resource_request.url, resource_request.url,
resource_request.request_initiator, corp_header_value,
resource_request.mode, resource_request.destination,
response->request_include_credentials, cross_origin_embedder_policy,
is_enabled ? cross_origin_embedder_policy_reporter : nullptr,
document_isolation_policy,
is_enabled ? document_isolation_policy_reporter : nullptr)) {
resource_request.url is the SW-chosen cache key URL (for example a.test/service_worker/x), which is same-origin to the document because the attacker controls both the SW and the page. The CORP logic at services/network/public/cpp/cross_origin_resource_policy.cc:184-186 then derives target_origin from request_url and returns std::nullopt (allowed) on the same-origin short-circuit:
url::Origin target_origin = url::Origin::Create(request_url);
if (initiator == target_origin)
return std::nullopt;
The check should compare the response’s actual origin. The general CacheStorage CORP check at content/browser/cache_storage/cache_storage_dispatcher_host.cc:169-176 does this correctly:
return CrossOriginResourcePolicy::IsBlockedByHeaderValue(
response->url_list.back(), response->url_list.front(),
document_origin, corp_header_value, RequestMode::kNoCors,
...)
With the SW Static Router path using resource_request.url instead, the added CORP flag is a no-op for the cache source. A compromised origin in a crossOriginIsolated context still embeds cross-origin credentialed no-CORP responses as subresources, defeating the exact protection CORP was designed to enforce in COEP contexts against Spectre-style reads.
Scope distinction from the previously-addressed class: the surrounding issue family (static router cache source needs CORP/opaque enforcement) is tracked at crbug/495999481 and crbug/497436273. This report is not the class-level discovery. It is a defect in the CORP fix implementation that ships in current main. Turning the flag on via Finch would not close the bypass.
Reachability:
- Attacker controls origin
attacker.example. - Attacker’s page sets
Cross-Origin-Embedder-Policy: require-corpandCross-Origin-Opener-Policy: same-origin, reacheswindow.crossOriginIsolated === true. - Attacker SW does
fetch(victim, {mode: 'no-cors', credentials: 'include'})and stores the opaque response in a cache. - Attacker SW registers a router rule mapping a same-origin URL to the cache source.
- Attacker page embeds the same-origin URL as an
<img>. The image loads.naturalWidthandnaturalHeightare readable cross-origin.
With all fix flags enabled, the same attack succeeds.
Capability uplift:
- Cross-origin credentialed image dimensions are directly readable, defeating the CORP protection Chrome is rolling out.
- crossOriginIsolated context exposes SharedArrayBuffer and precise timers. CORP blocks cross-origin content from entering this process for Spectre-defence reasons. The bypass reopens Spectre-style side channels against any cross-origin resource the attacker chooses to cache.
VERSION
Chrome Version: built from main branch 2026-04-23, out/fuzz_asan with is_asan=true, commit tree at HEAD.
Operating System: Linux (debian bullseye sysroot), x86_64.
REPRODUCTION CASE REPRODUCTION CASE
Two attached files drive an end-to-end repro against stable Chrome. No Chromium build required.
Attached files:
POC-sw-static-router-CORP-bypass.html— attacker client page + driver script (origin A).POC-sw-static-router-CORP-bypass.sw.js— Service Worker script, saved assw.jsat origin A web root.
Setup (two distinct secure origins mapped to loopback):
-
Create web roots and drop the attached files:
mkdir -p /tmp/sw-poc/a /tmp/sw-poc/b cp POC-sw-static-router-CORP-bypass.html /tmp/sw-poc/a/index.html cp POC-sw-static-router-CORP-bypass.sw.js /tmp/sw-poc/a/sw.js # any 100x50 PNG works — keep the dimensions small so the leak is visible convert -size 100x50 xc:red /tmp/sw-poc/b/animated.png -
Generate a self-signed cert with SANs for both hostnames:
openssl req -x509 -newkey rsa:2048 -nodes -days 30 \ -keyout /tmp/sw-poc/key.pem -out /tmp/sw-poc/cert.pem \ -subj /CN=a.test \ -addext 'subjectAltName = DNS:a.test, DNS:b.test' -
Start origin A (COEP/COOP only on HTML;
sw.jsMUST NOT carry COEP or the worker inherits it and cannot fetch cross-origin):# /tmp/sw-poc/server_a.py import http.server, ssl class H(http.server.SimpleHTTPRequestHandler): def __init__(self, *a, **k): super().__init__(*a, directory='/tmp/sw-poc/a', **k) def end_headers(self): if self.path in ('/', '/index.html') or self.path.endswith('.html'): self.send_header('Cross-Origin-Embedder-Policy', 'require-corp') self.send_header('Cross-Origin-Opener-Policy', 'same-origin') super().end_headers() s = http.server.ThreadingHTTPServer(('0.0.0.0', 4443), H) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain('/tmp/sw-poc/cert.pem', '/tmp/sw-poc/key.pem') s.socket = ctx.wrap_socket(s.socket, server_side=True); s.serve_forever() -
Start origin B (no CORP header on the victim image):
# /tmp/sw-poc/server_b.py — identical to server_a but directory=/tmp/sw-poc/b, # port 4444, and empty end_headers() (no COEP/COOP/CORP). -
Launch Chrome against the loopback cert:
google-chrome \ --ignore-certificate-errors \ --host-resolver-rules='MAP a.test 127.0.0.1, MAP b.test 127.0.0.1' \ --user-data-dir=/tmp/sw-poc-profile \ https://a.test:4443/--ignore-certificate-errorsis required because Service Worker subresource fetches fail silently against untrusted certs (no interstitial bypass path inside the SW).mkcert+ OS trust store works too. -
In the browser: enter
https://b.test:4444/animated.pngas the victim URL and click Run POC. On first run the SW registers but is not yet controlling; reload the page once to finish controlling, then click Run POC again.
Expected output (runtime-confirmed on stable Chrome, 2026-04-23, all fix flags enabled via --enable-features=ServiceWorkerStaticRouterOpaqueCheck,ServiceWorkerStaticRouterCORPCheck):
isolation = {"crossOriginIsolated":true,"isSecureContext":true}
[1] register sw.js?https%3A%2F%2Fb.test%3A4444%2Fanimated.png
SW installed + controlling
[2] control: direct <img src="https://b.test:4444/animated.png">
{"status":"error"}
[3] attack: <img src="/service_worker/x"> (router -> cache -> victim)
{"status":"loaded","naturalWidth":100,"naturalHeight":50}
VERDICT
CORP BYPASS CONFIRMED — cross-origin opaque bytes embedded
leaked dimensions: 100x50
DevTools network panel shows the control request blocked as
ERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoep — the exact CORP defence the attack sidesteps.
Interpretation:
crossOriginIsolated=trueconfirms the COEP context is active.- Control (direct cross-origin
<img>) fails with the standard CORP / defaulted-to-same-origin-by-COEP block. - Attack (
/service_worker/xvia router → cache → victim) loads.naturalWidthandnaturalHeightexpose information about the cross-origin credentialed response. Result is unchanged with both fix flags enabled.
Suggested fix: at content/common/service_worker/service_worker_resource_loader.cc:99, pass response->url_list.back() (and an appropriate entry for original_url) instead of resource_request.url, matching the pattern already used at cache_storage_dispatcher_host.cc:169-176.
FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION
Not a crash. Cross-origin policy bypass.
CREDIT INFORMATION
Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited? Reporter credit: David Bors, Catalin Iovita