High watch CVSS 4.3 chrome Policy Bypass 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in ServiceWorker
DescriptionInsufficient policy enforcement in ServiceWorker
ComponentServiceWorker
Bug ClassPolicy Bypass
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker497436273
Fix commite2b91876eb01 (chromium/src) +497/-108
CWECWE-693
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-29 (per vendor advisory)
Fix landed2026-04-13 (public git)
Disclosed2026-05-19
Patch gap36 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 requests can be served directly from a source such as the Cache Storage without invoking the fetch handler.
Cross-Origin Resource Policy (CORP)
a response header (Cross-Origin-Resource-Policy) that lets a server declare which origins may embed a resource, enforced by the browser to prevent cross-origin leaks (e.g., under Spectre-class threats).
Cross-Origin Embedder Policy (COEP) / Document Isolation Policy (DIP)
client-side policies (kRequireCorp, etc.) that require embedded cross-origin subresources to explicitly opt in via CORP before they may be loaded into a protected context.
`ControllerServiceWorkerInfo`
the Mojo struct sent from the browser process to the renderer describing the controlling service worker, extended here to carry the client’s COEP/DIP policy and reporter endpoints.

Root Cause Analysis

When a document with a kRequireCorp COEP (or a DIP) fetched a cross-origin subresource, the browser normally enforces CORP on the response before it reaches the protected context. However, when the Service Worker Static Routing API served that subresource directly from its cache source, the loader path did not run a CORP check (inferred from the newly added IsValidStaticRouterResponse logic and the features::kServiceWorkerStaticRouterCORPCheck gate). This let an opaque, cross-origin cached response be delivered into a COEP-protected client without the CORP opt-in the policy requires, violating the invariant that every cross-origin subresource in such a context must satisfy CORP.

The fix propagates the client’s cross_origin_embedder_policy and document_isolation_policy (plus their reporters) from the browser into the renderer via ControllerServiceWorkerInfo, and adds IsValidStaticRouterResponse, which returns false (blocking the response) for a cross-origin no-CORP response when the feature flag is enabled. The behavior is gated by the new flag, with UMA (CORPCheckResult) recording kViolation in shadow mode and kBlocked when enforcement is on.

Key insight
The static-router cache source bypassed the CORP enforcement that every other cross-origin subresource load performs; the fix carries the client’s COEP/DIP into the renderer and re-runs the CORP validity check on router-served responses so opaque cross-origin resources are blocked as they would be on a normal fetch.

Attack Path

  1. Set up a controlled service worker An attacker (or a page cooperating with attacker-controlled content) registers a service worker that uses the Static Routing API with a cache source, and populates the cache with an opaque cross-origin response.
  2. Load a COEP/DIP-protected client A document that sets Cross-Origin-Embedder-Policy: require-corp (or DIP) is controlled by that service worker, establishing a context where cross-origin subresources must satisfy CORP.
  3. Trigger the router path The client requests a cross-origin subresource that the static routing rule serves directly from the cache source, skipping the fetch handler.
  4. Bypass CORP Because the pre-patch loader performed no CORP check on the router-served response, the opaque cross-origin resource is delivered into the protected context despite lacking a Cross-Origin-Resource-Policy opt-in (the IsValidStaticRouterResponse “flag OFF” case returns true with a kViolation UMA bucket).
  5. Leak cross-origin data (inferred) The improperly embedded cross-origin resource could be used to defeat the isolation COEP/DIP is meant to guarantee, enabling cross-origin information disclosure.

Impact Assessment

An attacker gains the ability to embed opaque cross-origin resources into a COEP/DIP-protected renderer context without the required CORP opt-in, defeating a cross-origin isolation policy; the resulting cross-origin data exposure is (inferred). Preconditions are a service worker using the Static Routing API cache source and a client that opts into require-corp/DIP. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3. The enforcement is gated behind features::kServiceWorkerStaticRouterCORPCheck.

Changed Functions

FunctionChangeNotes
if
content/browser/service_worker/service_worker_container_host.cc
modified
CreateDocumentIsolationPolicyInfo
content/browser/service_worker/service_worker_container_host.cc
modified
cross_origin_embedder_policy_reporter
content/browser/service_worker/service_worker_container_host.h
modified
document_isolation_policy_reporter
content/browser/service_worker/service_worker_container_host.h
modified

Files Changed

  • content/browser/service_worker/service_worker_container_host.cc
  • content/browser/service_worker/service_worker_container_host.h
  • content/browser/service_worker/service_worker_main_resource_loader.cc

Audit Directions

  • Alternate response sources bypassing security checks
    Audit every path that delivers a response into a document without going through the normal network/CORB/CORP pipeline (cache sources, static routing, prefetch, back-forward cache) to confirm cross-origin policy checks still run.
  • Policy plumbing gaps to the renderer
    Look for cases where the browser holds a client’s COEP/DIP/CORP context but does not propagate it to the renderer-side loader that ultimately serves the resource, leaving enforcement impossible.
  • Feature-flag-gated security enforcement
    Since blocking here is gated behind kServiceWorkerStaticRouterCORPCheck, verify the shadow-mode-to-enforcement rollout does not leave the bypass exploitable by default, and check similar flag-gated CORP/CORB checks for the disabled-by-default window.
From e2b91876eb013ceb7bc7ee5cab2fa44c4f45df16 Mon Sep 17 00:00:00 2001
From: Yoshisto Yanagisawa <yyanagisawa@chromium.org>
Date: Sun, 12 Apr 2026 19:16:13 -0700
Subject: [PATCH] Enforce CORP for Static Router Cache Source

This CL ensures that Cross-Origin Resource Policy (CORP) is correctly
enforced and reported when using the Service Worker Static Routing API's
cache source, gated by a feature flag.

Key changes:
1. Feature Flag: Introduced kServiceWorkerStaticRouterCORPCheck
   to gate the new blocking behavior in content/common/features.h.
2. UMA: Added ServiceWorker.StaticRouter.{Subresource|MainResource}.CORPCheckResult
   to monitor policy violations in both enforcement and shadow modes.
3. Reporting: Pass the client's COEP/DIP Reporters from the browser to
   the renderer via ControllerServiceWorkerInfo, ensuring security
   violations are properly reported.
4. Unit Tests: Added comprehensive unit tests in
   ServiceWorkerResourceLoaderTest to verify CORP blocking logic and
   UMA recording across different scenarios.
5. Testing Config: Added fieldtrial_testing_config.json entry to enable
   the feature for automated testing.
6. Refactoring: Encapsulated policy and reporter information into Mojo
   structs (CrossOriginEmbedderPolicyInfo and
   DocumentIsolationPolicyInfo) to ensure type safety and consistent
   handling. Replaced acronyms with full names (e.g., coep ->
   cross_origin_embedder_policy) for better readability.

DanglingUntriaged-notes: In WebEmbeddedWorkerImplTest, the mock client
holds a pointer to the proxy which can be destroyed first during
termination tests. This is a known lifecycle pattern in this specific
unit test environment.

Bug: 497436273
Change-Id: Iaf3b66daa02573637a196e26a54e0816cf7b87f6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7725522
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Reviewed-by: Shunya Shishido <sisidovski@chromium.org>
Commit-Queue: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
Reviewed-by: Hidehiko Abe <hidehiko@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1613472}
---

diff --git a/content/browser/service_worker/service_worker_container_host.cc b/content/browser/service_worker/service_worker_container_host.cc
index defb9876..72bbd04 100644
--- a/content/browser/service_worker/service_worker_container_host.cc
+++ b/content/browser/service_worker/service_worker_container_host.cc
@@ -86,17 +86,19 @@
     blink::mojom::ServiceWorkerContainerInfoForClientPtr& container_info,
     const PolicyContainerPolicies& policy_container_policies,
     mojo::PendingRemote<network::mojom::CrossOriginEmbedderPolicyReporter>
-        coep_reporter,
+        cross_origin_embedder_policy_reporter,
     mojo::PendingRemote<network::mojom::DocumentIsolationPolicyReporter>
-        dip_reporter,
+        document_isolation_policy_reporter,
     ukm::SourceId ukm_source_id)
     : service_worker_client_(std::move(service_worker_client)),
       container_(
           container_info->client_receiver.InitWithNewEndpointAndPassRemote()),
       ukm_source_id_(std::move(ukm_source_id)),
       policy_container_policies_(policy_container_policies.Clone()),
-      coep_reporter_(std::move(coep_reporter)),
-      dip_reporter_(std::move(dip_reporter)) {
+      cross_origin_embedder_policy_reporter_(
+          std::move(cross_origin_embedder_policy_reporter)),
+      document_isolation_policy_reporter_(
+          std::move(document_isolation_policy_reporter)) {
   CHECK(container_.is_bound());
   CHECK(service_worker_client_);
   CHECK(!service_worker_client_->is_response_committed());
@@ -479,6 +481,11 @@
       controller_info->router_data->initial_running_status =
           controller()->running_status();
     }
+
+    controller_info->cross_origin_embedder_policy =
+        CreateCrossOriginEmbedderPolicyInfo();
+    controller_info->document_isolation_policy =
+        CreateDocumentIsolationPolicyInfo();
   }
 
   // Note that |controller_info->remote_controller| is null if the controller
@@ -705,26 +712,9 @@
 
 void ServiceWorkerContainerHostForClient::CloneControllerServiceWorker(
     mojo::PendingReceiver<blink::mojom::ControllerServiceWorker> receiver) {
-  mojo::PendingRemote<network::mojom::CrossOriginEmbedderPolicyReporter>
-      coep_reporter_to_be_passed;
-  mojo::PendingRemote<network::mojom::DocumentIsolationPolicyReporter>
-      dip_reporter_to_be_passed;
-  if (coep_reporter_) {
-    coep_reporter_->Clone(
-        coep_reporter_to_be_passed.InitWithNewPipeAndPassReceiver());
-  }
-
-  if (dip_reporter_) {
-    dip_reporter_->Clone(
-        dip_reporter_to_be_passed.InitWithNewPipeAndPassReceiver());
-  }
-
-  controller()->controller()->Clone(
-      std::move(receiver),
-      policy_container_policies_.cross_origin_embedder_policy,
-      std::move(coep_reporter_to_be_passed),
-      policy_container_policies_.document_isolation_policy,
-      std::move(dip_reporter_to_be_passed));
+  controller()->controller()->Clone(std::move(receiver),
+                                    CreateCrossOriginEmbedderPolicyInfo(),
+                                    CreateDocumentIsolationPolicyInfo());
 }
 
 bool ServiceWorkerContainerHostForClient::AllowServiceWorker(
@@ -1391,4 +1381,28 @@
   return service_worker_client().controller();
 }
 
+blink::mojom::CrossOriginEmbedderPolicyInfoPtr
+ServiceWorkerContainerHostForClient::CreateCrossOriginEmbedderPolicyInfo()
+    const {
+  auto info = blink::mojom::CrossOriginEmbedderPolicyInfo::New(
+      policy_container_policies_.cross_origin_embedder_policy,
+      mojo::NullRemote());
+  if (cross_origin_embedder_policy_reporter_) {
+    cross_origin_embedder_policy_reporter_->Clone(
+        info->reporter.InitWithNewPipeAndPassReceiver());
+  }
+  return info;
+}
+
+blink::mojom::DocumentIsolationPolicyInfoPtr
+ServiceWorkerContainerHostForClient::CreateDocumentIsolationPolicyInfo() const {
+  auto info = blink::mojom::DocumentIsolationPolicyInfo::New(
+      policy_container_policies_.document_isolation_policy, mojo::NullRemote());
+  if (document_isolation_policy_reporter_) {
+    document_isolation_policy_reporter_->Clone(
+        info->reporter.InitWithNewPipeAndPassReceiver());
+  }
+  return info;
+}
+
 }  // namespace content
diff --git a/content/browser/service_worker/service_worker_container_host.h b/content/browser/service_worker/service_worker_container_host.h
index 7f857d8..1b39ef85 100644
--- a/content/browser/service_worker/service_worker_container_host.h
+++ b/content/browser/service_worker/service_worker_container_host.h
@@ -328,7 +328,25 @@
   ukm::SourceId ukm_source_id() const { return ukm_source_id_; }
   ServiceWorkerVersion* controller() const;
 
+  const mojo::Remote<network::mojom::CrossOriginEmbedderPolicyReporter>&
+  cross_origin_embedder_policy_reporter() const {
+    return cross_origin_embedder_policy_reporter_;
+  }
+  const mojo::Remote<network::mojom::DocumentIsolationPolicyReporter>&
+  document_isolation_policy_reporter() const {
+    return document_isolation_policy_reporter_;
+  }
+
+  const PolicyContainerPolicies& policy_container_policies() const {
+    return policy_container_policies_;
+  }
+
  private:
+  blink::mojom::CrossOriginEmbedderPolicyInfoPtr
+  CreateCrossOriginEmbedderPolicyInfo() const;
+  blink::mojom::DocumentIsolationPolicyInfoPtr
+  CreateDocumentIsolationPolicyInfo() const;
+
   // Callback for ServiceWorkerContextCore::RegisterServiceWorker().
   void RegistrationComplete(const GURL& script_url,
                             const GURL& scope,
@@ -438,11 +456,12 @@
   // An endpoint connected to the COEP reporter. A clone of this connection is
   // passed to the service worker. Bound on response commit.
   mojo::Remote<network::mojom::CrossOriginEmbedderPolicyReporter>
-      coep_reporter_;
+      cross_origin_embedder_policy_reporter_;
 
   // An endpoint connected to the DocumentIsolationPolicy reporter. A clone of
   // this connection is passed to the service worker. Bound on response commit.
-  mojo::Remote<network::mojom::DocumentIsolationPolicyReporter> dip_reporter_;
+  mojo::Remote<network::mojom::DocumentIsolationPolicyReporter>
+      document_isolation_policy_reporter_;
 
   base::WeakPtrFactory<ServiceWorkerContainerHostForClient> weak_ptr_factory_{
       this};
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 25e6685..650a544a 100644
--- a/content/browser/service_worker/service_worker_main_resource_loader.cc
+++ b/content/browser/service_worker/service_worker_main_resource_loader.cc
@@ -25,7 +25,9 @@
 #include "base/trace_event/trace_event.h"
 #include "content/browser/loader/navigation_url_loader.h"
 #include "content/browser/loader/response_head_update_params.h"
+#include "content/browser/renderer_host/policy_container_host.h"
 #include "content/browser/service_worker/service_worker_client.h"
+#include "content/browser/service_worker/service_worker_container_host.h"
 #include "content/browser/service_worker/service_worker_context_core.h"
 #include "content/browser/service_worker/service_worker_context_wrapper.h"
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/common/service_worker/service_worker_resource_loader_unittest.cc b/content/common/service_worker/service_worker_resource_loader_unittest.cc
index 1a4fe5c..fd2f132 100644
--- a/content/common/service_worker/service_worker_resource_loader_unittest.cc
+++ b/content/common/service_worker/service_worker_resource_loader_unittest.cc
@@ -4,12 +4,46 @@
 
 #include "content/common/service_worker/service_worker_resource_loader.h"
 
+#include "base/test/metrics/histogram_tester.h"
+#include "base/test/scoped_feature_list.h"
+#include "content/common/features.h"
+#include "content/public/common/content_features.h"
+#include "services/network/public/cpp/resource_request.h"
 #include "services/network/public/mojom/fetch_api.mojom.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/mojom/fetch/fetch_api_response.mojom.h"
 
 namespace content {
 
+namespace {
+
+class TestServiceWorkerResourceLoader : public ServiceWorkerResourceLoader {
+ public:
+  explicit TestServiceWorkerResourceLoader(bool is_main_resource)
+      : is_main_resource_(is_main_resource) {}
+  ~TestServiceWorkerResourceLoader() override = default;
+
+  bool IsMainResourceLoader() override { return is_main_resource_; }
+
+  void CommitResponseBody(
+      const network::mojom::URLResponseHeadPtr& response_head,
+      mojo::ScopedDataPipeConsumerHandle response_body,
+      std::optional<mojo_base::BigBuffer> cached_metadata) override {}
+
+  void CommitEmptyResponseAndComplete() override {}
+
+  void CommitCompleted(int error_code, const char* reason) override {}
+
+  void HandleRedirect(
+      const net::RedirectInfo& redirect_info,
+      const network::mojom::URLResponseHeadPtr& response_head) override {}
+
+ private:
+  bool is_main_resource_;
+};
+
+}  // namespace
+
 TEST(ServiceWorkerResourceLoaderTest, IsValidServiceWorkerResponse) {
   auto request_mode = network::mojom::RequestMode::kSameOrigin;
   auto redirect_mode = network::mojom::RedirectMode::kFollow;
@@ -99,4 +133,80 @@
   }
 }
 
+TEST(ServiceWorkerResourceLoaderTest, IsValidStaticRouterResponse) {
+  TestServiceWorkerResourceLoader loader(/*is_main_resource=*/false);
+  base::HistogramTester histogram_tester;
+
+  network::ResourceRequest request;
+  request.url = GURL("https://b.test/resource");
+  request.request_initiator = url::Origin::Create(GURL("https://a.test/"));
+  request.mode = network::mojom::RequestMode::kNoCors;
+  request.destination = network::mojom::RequestDestination::kImage;
+
+  auto response = blink::mojom::FetchAPIResponse::New();
+  response->response_type = network::mojom::FetchResponseType::kOpaque;
+
+  network::CrossOriginEmbedderPolicy coep;
+  coep.value = network::mojom::CrossOriginEmbedderPolicyValue::kRequireCorp;
+  network::DocumentIsolationPolicy dip;
+
+  // Case 1: Same-origin request should be valid even without CORP.
+  {
+    network::ResourceRequest same_origin_request;
+    same_origin_request.url = GURL("https://a.test/resource");
+    same_origin_request.request_initiator =
+        url::Origin::Create(GURL("https://a.test/"));
+    same_origin_request.mode = network::mojom::RequestMode::kNoCors;
+
+    EXPECT_TRUE(loader.IsValidStaticRouterResponse(
+        same_origin_request, response, coep, nullptr, dip, nullptr));
+    histogram_tester.ExpectBucketCount(
+        "ServiceWorker.StaticRouter.Subresource.CORPCheckResult",
+        ServiceWorkerResourceLoader::CORPCheckResult::kSuccess, 1);
+  }
+
+  // Case 2: Cross-origin request without CORP, flag OFF.
+  {
+    base::test::ScopedFeatureList scoped_feature_list;
+    scoped_feature_list.InitAndDisableFeature(
+        features::kServiceWorkerStaticRouterCORPCheck);
+
+    EXPECT_TRUE(loader.IsValidStaticRouterResponse(request, response, coep,
+                                                   nullptr, dip, nullptr));
+    histogram_tester.ExpectBucketCount(
+        "ServiceWorker.StaticRouter.Subresource.CORPCheckResult",
+        ServiceWorkerResourceLoader::CORPCheckResult::kViolation, 1);
+  }
+
+  // Case 3: Cross-origin request without CORP, flag ON.
+  {
+    base::test::ScopedFeatureList scoped_feature_list;
+    scoped_feature_list.InitAndEnableFeature(
+        features::kServiceWorkerStaticRouterCORPCheck);
+
+    EXPECT_FALSE(loader.IsValidStaticRouterResponse(request, response, coep,
+                                                    nullptr, dip, nullptr));
+    histogram_tester.ExpectBucketCount(
+        "ServiceWorker.StaticRouter.Subresource.CORPCheckResult",
+        ServiceWorkerResourceLoader::CORPCheckResult::kBlocked, 1);
+  }
+
+  // Case 4: Cross-origin request WITH CORP, flag ON.
+  {
+    base::test::ScopedFeatureList scoped_feature_list;
+    scoped_feature_list.InitAndEnableFeature(
+        features::kServiceWorkerStaticRouterCORPCheck);
+
+    auto corp_response = blink::mojom::FetchAPIResponse::New();
+    corp_response->response_type = network::mojom::FetchResponseType::kOpaque;
+    corp_response->headers["Cross-Origin-Resource-Policy"] = "cross-origin";
+
+    EXPECT_TRUE(loader.IsValidStaticRouterResponse(request, corp_response, coep,
+                                                   nullptr, dip, nullptr));
+    histogram_tester.ExpectBucketCount(
+        "ServiceWorker.StaticRouter.Subresource.CORPCheckResult",
+        ServiceWorkerResourceLoader::CORPCheckResult::kSuccess, 2);
+  }
+}
+
 }  // namespace content
diff --git a/content/renderer/service_worker/service_worker_provider_context_unittest.cc b/content/renderer/service_worker/service_worker_provider_context_unittest.cc
index f533cd03..69f0726 100644
--- a/content/renderer/service_worker/service_worker_provider_context_unittest.cc
+++ b/content/renderer/service_worker/service_worker_provider_context_unittest.cc
@@ -206,11 +206,8 @@
   }
   void Clone(
       mojo::PendingReceiver<blink::mojom::ControllerServiceWorker> receiver,
-      const network::CrossOriginEmbedderPolicy&,
-      mojo::PendingRemote<network::mojom::CrossOriginEmbedderPolicyReporter>,
-      const network::DocumentIsolationPolicy&,
-      mojo::PendingRemote<network::mojom::DocumentIsolationPolicyReporter>)
-      override {
+      blink::mojom::CrossOriginEmbedderPolicyInfoPtr,
+      blink::mojom::DocumentIsolationPolicyInfoPtr) override {
     receivers_.Add(this, std::move(receiver));
   }
 
@@ -480,9 +477,7 @@
   auto controller_info1 = blink::mojom::ControllerServiceWorkerInfo::New();
   mojo::Remote<blink::mojom::ControllerServiceWorker> remote_controller1;
   fake_controller1.Clone(remote_controller1.BindNewPipeAndPassReceiver(),
-                         network::CrossOriginEmbedderPolicy(),
-                         mojo::NullRemote(), network::DocumentIsolationPolicy(),
-                         mojo::NullRemote());
+                         nullptr, nullptr);
   controller_info1->mode =
       blink::mojom::ControllerServiceWorkerMode::kControlled;
   controller_info1->fetch_handler_type =
@@ -528,9 +523,7 @@
   auto controller_info2 = blink::mojom::ControllerServiceWorkerInfo::New();
   mojo::Remote<blink::mojom::ControllerServiceWorker> remote_controller2;
   fake_controller2.Clone(remote_controller2.BindNewPipeAndPassReceiver(),
-                         network::CrossOriginEmbedderPolicy(),
-                         mojo::NullRemote(), network::DocumentIsolationPolicy(),
-                         mojo::NullRemote());
+                         nullptr, nullptr);
   controller_info2->mode =
       blink::mojom::ControllerServiceWorkerMode::kControlled;
   controller_info2->fetch_handler_type =
@@ -622,9 +615,7 @@
   auto controller_info4 = blink::mojom::ControllerServiceWorkerInfo::New();
   mojo::Remote<blink::mojom::ControllerServiceWorker> remote_controller4;
   fake_controller4.Clone(remote_controller4.BindNewPipeAndPassReceiver(),
-                         network::CrossOriginEmbedderPolicy(),
-                         mojo::NullRemote(), network::DocumentIsolationPolicy(),
-                         mojo::NullRemote());
+                         nullptr, nullptr);
   controller_info4->mode =
       blink::mojom::ControllerServiceWorkerMode::kControlled;
   controller_info4->fetch_handler_type =
@@ -781,10 +772,8 @@
   FakeControllerServiceWorker fake_controller;
   auto controller_info = blink::mojom::ControllerServiceWorkerInfo::New();
   mojo::Remote<blink::mojom::ControllerServiceWorker> remote_controller;
-  fake_controller.Clone(remote_controller.BindNewPipeAndPassReceiver(),
-                        network::CrossOriginEmbedderPolicy(),
-                        mojo::NullRemote(), network::DocumentIsolationPolicy(),
-                        mojo::NullRemote());
+  fake_controller.Clone(remote_controller.BindNewPipeAndPassReceiver(), nullptr,
+                        nullptr);
   controller_info->mode =
       blink::mojom::ControllerServiceWorkerMode::kControlled;
   controller_info->fetch_handler_type =
@@ -832,10 +821,8 @@
   FakeControllerServiceWorker fake_controller;
   auto controller_info = blink::mojom::ControllerServiceWorkerInfo::New();
   mojo::Remote<blink::mojom::ControllerServiceWorker> remote_controller;
-  fake_controller.Clone(remote_controller.BindNewPipeAndPassReceiver(),
-                        network::CrossOriginEmbedderPolicy(),
-                        mojo::NullRemote(), network::DocumentIsolationPolicy(),
-                        mojo::NullRemote());
+  fake_controller.Clone(remote_controller.BindNewPipeAndPassReceiver(), nullptr,
+                        nullptr);
   controller_info->mode =
       blink::mojom::ControllerServiceWorkerMode::kControlled;
   controller_info->fetch_handler_type =
diff --git a/content/renderer/service_worker/service_worker_subresource_loader_unittest.cc b/content/renderer/service_worker/service_worker_subresource_loader_unittest.cc
index 1322b19..eb7d7f0 100644
--- a/content/renderer/service_worker/service_worker_subresource_loader_unittest.cc
+++ b/content/renderer/service_worker/service_worker_subresource_loader_unittest.cc
@@ -382,11 +382,8 @@
 
   void Clone(
       mojo::PendingReceiver<blink::mojom::ControllerServiceWorker> receiver,
-      const network::CrossOriginEmbedderPolicy&,
-      mojo::PendingRemote<network::mojom::CrossOriginEmbedderPolicyReporter>,
-      const network::DocumentIsolationPolicy&,
-      mojo::PendingRemote<network::mojom::DocumentIsolationPolicyReporter>)
-      override {
+      blink::mojom::CrossOriginEmbedderPolicyInfoPtr,
+      blink::mojom::DocumentIsolationPolicyInfoPtr) override {
     receivers_.Add(this, std::move(receiver));
   }
 
@@ -493,10 +490,7 @@
     get_controller_service_worker_count_++;
     if (!fake_controller_)
       return;
-    fake_controller_->Clone(
-        std::move(receiver), network::CrossOriginEmbedderPolicy(),
-        mojo::NullRemote(), network::DocumentIsolationPolicy(),
-        mojo::NullRemote());
+    fake_controller_->Clone(std::move(receiver), nullptr, nullptr);
   }
   void CloneContainerHost(
       mojo::PendingReceiver<blink::mojom::ServiceWorkerContainerHost> receiver)
@@ -575,7 +569,11 @@
           mojo::NullRemote() /*remote_controller*/,
           mojo::NullRemote() /*remote_cache_storage*/, "" /*client_id*/,
           blink::mojom::ServiceWorkerFetchHandlerBypassOption::kDefault,
-          std::nullopt, blink::EmbeddedWorkerStatus::kStopped,
+          std::nullopt, network::CrossOriginEmbedderPolicy(),
+          mojo::NullRemote() /*cross_origin_embedder_policy_reporter*/,
+          network::DocumentIsolationPolicy(),
+          mojo::NullRemote() /*document_isolation_policy_reporter*/,
+          blink::EmbeddedWorkerStatus::kStopped,
           mojo::NullReceiver() /*running_status_receiver*/);
     }
     mojo::Remote<network::mojom::URLLoaderFactory>
@@ -1078,8 +1076,12 @@
       std::move(remote_container_host),
       mojo::NullRemote() /*remote_controller*/,
       mojo::NullRemote() /*remote_cache_storage*/, "" /*client_id*/,
-      blink::mojom::ServiceWorkerFetchHandlerBypassOption::kDefault,
-      rules, blink::EmbeddedWorkerStatus::kStopped,
+      blink::mojom::ServiceWorkerFetchHandlerBypassOption::kDefault, rules,
+      network::CrossOriginEmbedderPolicy(),
+      mojo::NullRemote() /*cross_origin_embedder_policy_reporter*/,
+      network::DocumentIsolationPolicy(),
+      mojo::NullRemote() /*document_isolation_policy_reporter*/,
+      blink::EmbeddedWorkerStatus::kRunning,
       mojo::NullReceiver() /*running_status_receiver*/);
 
   // Initialize by calling CreateSubresourceLoaderFactory() once.
diff --git a/third_party/blink/renderer/modules/service_worker/web_embedded_worker_impl_test.cc b/third_party/blink/renderer/modules/service_worker/web_embedded_worker_impl_test.cc
index 5ff7044..22ee365 100644
--- a/third_party/blink/renderer/modules/service_worker/web_embedded_worker_impl_test.cc
+++ b/third_party/blink/renderer/modules/service_worker/web_embedded_worker_impl_test.cc
@@ -399,6 +399,7 @@
   void WorkerContextStarted(
       WebServiceWorkerContextProxy* proxy,
       scoped_refptr<base::SequencedTaskRunner> worker_task_runner) override {
+    proxy_ = proxy;
     worker_task_runner_ = std::move(worker_task_runner);
     mojo::PendingAssociatedRemote<mojom::blink::ServiceWorkerHost> host_remote;
     auto host_receiver = host_remote.InitWithNewEndpointAndPassReceiver();
@@ -458,18 +459,6 @@
         mock_policy_container_host.BindNewEndpointAndPassDedicatedRemote();
     web_policy_container_ = nullptr;
 
-    // ControllerServiceWorker requires Clone to ensure
-    // CrossOriginResourcePolicyChecker. See
-    // ServiceWorkerGlobalScope::DispatchFetchEventForSubresource().
-    mojo::Remote<mojom::blink::ControllerServiceWorker>
-        stub_controller_service_worker;
-    proxy->BindControllerServiceWorker(
-        stub_controller_service_worker.BindNewPipeAndPassReceiver());
-    stub_controller_service_worker->Clone(
-        controller_service_worker_.BindNewPipeAndPassReceiver(),
-        network::CrossOriginEmbedderPolicy(), mojo::NullRemote(),
-        network::DocumentIsolationPolicy(), mojo::NullRemote());
-
     // To make the other side callable.
     host_receiver.EnableUnassociatedUsage();
     associated_interfaces_recevier_from_browser.EnableUnassociatedUsage();
@@ -534,6 +523,17 @@
     test_data_uploader_ = std::make_unique<TestDataUploader>(upload_contents);
... (truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

CORP bypass via Service Worker Static Routing cache source

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: The Service Worker Static Routing API enforces CORP checks using the Service Worker’s COEP instead of the client’s COEP when fetching from the cache source. This allows a cross-origin isolated page to load opaque cross-origin responses that lack CORP headers, bypassing security boundaries.

Affected files:

  • content/browser/service_worker/service_worker_container_host.cc
  • content/browser/service_worker/service_worker_version.cc
  • content/renderer/service_worker/service_worker_subresource_loader.cc
  • content/browser/cache_storage/cache_storage_dispatcher_host.cc

Estimated timestamp from git blame: 2025-01-31

Description

A potential security vulnerability exists in the Service Worker Static Routing API. When a Service Worker registers a routing rule with source: 'cache', the CacheStorage Mojo interface is incorrectly bound using the Service Worker’s own security policies (COEP and DIP) rather than the policies of the controlled client document.

This allows a cross-origin isolated page (COEP: require-corp) controlled by a Service Worker with a weaker policy (e.g., COEP: unsafe-none) to bypass Cross-Origin Resource Policy (CORP) checks. An attacker can cache an opaque cross-origin response without a CORP header, and then use the static routing API to load it into the cross-origin isolated renderer process, where it could be leaked using Spectre side-channels (leveraging SharedArrayBuffer and high-resolution timers).

Vulnerability Details

  1. When a navigation is committed for a controlled client, ServiceWorkerContainerHostForClient::CreateControllerServiceWorkerInfo() is called. If the Service Worker has a static router evaluator, it calls controller()->GetRemoteCacheStorage() to provide the renderer with direct access to the cache.
  2. In ServiceWorkerVersion::GetRemoteCacheStorage(), the CacheStorage receiver is bound using the Service Worker’s own cross_origin_embedder_policy() and document_isolation_policy(). The Mojo remote is then sent to the client renderer.
  3. When the client renderer initiates a subresource fetch that matches a source: 'cache' rule, ServiceWorkerSubresourceLoader calls Match() on the CacheStorage remote.
  4. In the browser process, CacheStorageDispatcherHost::CacheStorageImpl::Match enforces CORP by calling ResponseBlockedByCrossOriginResourcePolicy(). However, it uses the COEP that was bound to the receiver (the Service Worker’s unsafe-none policy) instead of the client’s require-corp policy.
  5. Consequently, the opaque cross-origin response is returned to the client renderer without being blocked by CORP, violating the security guarantees of the cross-origin isolated page.

Suggested Reproduction Steps

Note: These steps are suggested based on code analysis; a working proof of concept has not yet been executed.

  1. Create a Service Worker script served without a Cross-Origin-Embedder-Policy header (resulting in COEP: unsafe-none).
  2. In the Service Worker’s install event, use the Service Worker Static Routing API to register a route matching a specific cross-origin URL pattern with a source: 'cache' rule (event.addRoutes([{ condition: { urlPattern: '...' }, source: 'cache' }])).
  3. Populate the Service Worker’s cache with an opaque cross-origin response from the target URL (e.g., using cache.put() on a no-cors fetch). Ensure the target response lacks a CORP header.
  4. Host a client page controlled by this Service Worker that is cross-origin isolated (served with COEP: require-corp and Cross-Origin-Opener-Policy: same-origin).
  5. Navigate the browser to the client page.
  6. From the client page, initiate a no-cors fetch for the target cross-origin URL.
  7. The browser returns the opaque response from the cache because the CORP check is incorrectly evaluated against the Service Worker’s unsafe-none policy.
  8. The opaque response enters the cross-origin isolated renderer process, where its contents could be read via Spectre side-channels.

Suggested Fix

Ensure that the CacheStorage Mojo remote provided to the client for static routing is bound using the client’s own COEP and DIP, rather than the Service Worker’s policies. When ServiceWorkerContainerHostForClient prepares the controller info, it should request a cache storage remote specifically configured for the client’s security context.

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker