Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactPolicy bypass in ServiceWorker
DescriptionPolicy bypass in ServiceWorker
ComponentServiceWorker
Bug ClassLogic Error
Tracker505427216
Fix commit5046d3cddfac (chromium/src) +40/-42
CISA KEVNot listed
CreditedDavid Bors, Catalin Iovita
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
content/common/service_worker/service_worker_resource_loader.cc
modified
if
content/renderer/service_worker/service_worker_subresource_loader.cc
modified

Files Changed

  • content/browser/service_worker/service_worker_main_resource_loader.cc
  • content/common/service_worker/service_worker_resource_loader.cc
  • content/renderer/service_worker/service_worker_subresource_loader.cc
From 5046d3cddfac35b34e15bcf4afea7e85d17c30c5 Mon Sep 17 00:00:00 2001
From: Yoshisto Yanagisawa <yyanagisawa@chromium.org>
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 <yyanagisawa@chromium.org>
Reviewed-by: Shunya Shishido <sisidovski@chromium.org>
Commit-Queue: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
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) {
Loading diff…

Original Bug Report

reported by da...@gmail.com

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-corp and Cross-Origin-Opener-Policy: same-origin, reaches window.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. naturalWidth and naturalHeight are readable cross-origin.

With all fix flags enabled, the same attack succeeds.

Capability uplift:

  1. Cross-origin credentialed image dimensions are directly readable, defeating the CORP protection Chrome is rolling out.
  2. 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 as sw.js at origin A web root.

Setup (two distinct secure origins mapped to loopback):

  1. 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
    
  2. 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'
    
  3. Start origin A (COEP/COOP only on HTML; sw.js MUST 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()
    
  4. 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).
    
  5. 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-errors is required because Service Worker subresource fetches fail silently against untrusted certs (no interstitial bypass path inside the SW). mkcert + OS trust store works too.

  6. In the browser: enter https://b.test:4444/animated.png as 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=true confirms 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/x via router → cache → victim) loads. naturalWidth and naturalHeight expose 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

View on issue tracker