Medium watch CVSS 6.5 chrome Policy Bypass 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactPolicy bypass in ServiceWorker
DescriptionPolicy bypass in ServiceWorker
ComponentServiceWorker
Sub-componentBlink>ServiceWorker
Bug ClassPolicy Bypass
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker505427216
Fix commit5046d3cddfac (chromium/src) +40/-42
CWECWE-693
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedDavid Bors, Catalin Iovita
Reported2026-04-23 (per vendor advisory)
Fix landed2026-05-01 (public git)
Disclosed2026-06-02
Patch gap32 days — fix public in source before the advisory

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; kCache means the cache won, while kRaceNetworkAndCache races network and cache and commits whichever wins first.
Opaque / synthesized response
A response with response_type of kOpaque/kOpaqueRedirect (cross-origin, body hidden) and, for synthesized responses, an empty url_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.

Key insight
The core mistake was scoping the CORP/opaque validation to only the 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

  1. 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 kRaceNetworkAndCache rather than kCache. (inferred)
  2. Serve a cross-origin/opaque resource from cache The cache source returns a cross-origin response that would fail CORP, but because matched_source_type is not kCache, the pre-patch loaders skip IsValidStaticRouterResponse.
  3. Or forge a synthesized opaque response Alternatively, produce a response with an empty url_list and an opaque response_type; the pre-patch !response->url_list.empty() gate skips the CORP header check entirely.
  4. Commit unvalidated response The loader commits the response without CORP enforcement, delivering cross-origin content to a context that CORP was meant to protect.
  5. 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

An attacker gains a Cross-Origin Resource Policy bypass in the renderer/browser resource-loading path, exposing cross-origin resources that CORP (and associated COEP/DIP) should have blocked, given a page controlling a service worker with the static routing API’s cache source. The advisory classifies this as a policy bypass in 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

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

Audit Directions

  • Source-type-gated security checks
    Audit routing/loader code that conditions validation on a specific matched_source_type or feature branch; new or “under construction” sources like kRaceNetworkAndCache may silently escape the check.
  • Empty-list / synthesized short-circuits
    Search for url_list.empty() (or similar emptiness guards) that skip origin/CORP validation, since synthesized responses can carry mismatched response_type values.
  • CORP/COEP/DIP enforcement points
    Cross-reference every path that commits a response with CrossOriginResourcePolicy::IsBlockedByHeaderValue to ensure all delivery routes (main resource, subresource, race, cache) funnel through equivalent validation.
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