CVE-2026-113001
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/worker_host/shared_worker_host.cc |
modified | |
TEST_Fcontent/browser/worker_host/shared_worker_host_unittest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fcontent/browser/worker_host/worker_browsertest.cc |
modified |
Files Changed
content/browser/worker_host/worker_browsertest.cccontent/browser/worker_host/shared_worker_host.cccontent/browser/worker_host/shared_worker_host_unittest.cccontent/browser/worker_host/shared_worker_host.h
Patch
From ada1d28f4da4fe9d63dd1274c5ecb43eb5c0577e Mon Sep 17 00:00:00 2001 From: Chris Fredrickson <[email protected]> Date: Tue, 22 Sep 2026 15:09:12 -0700 Subject: [PATCH] [SAA] Make SharedWorker subresource fetches use x-site SiteForCookies Subresource fetches from SharedWorkers that were created in a cross-site context via StorageAccessHandle.SharedWorker() currently include 1P partitioned cookies (i.e. CHIPS cookies whose AncestorChainBit is kSameSite, meaning the cookie was set in a context where every ancestor frame was same-site to the embedded frame). This is not a privacy issue, since by assumption, the SharedWorker was created via handle.SharedWorker, which means it has access to unpartitioned storage/cookies. However, this is inconsistent with how Chrome handles _WebSocket_ requests from these SharedWorkers: those requests only send SameSite=None cookies and 3P CHIPS cookies. Additionally, there is no other way for a cross-site context to cause 1P CHIPS cookies to be sent, so depending on how sites use those cookies, this may have security implications. We should fix this so that subresources fetches are consistent with WebSocket requests, and only send cross-site data. Fixed: 553144392 Change-Id: I452769b0731c9cd9b49b67e747c56ecdd7345f3f Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8444577 Auto-Submit: Chris Fredrickson <[email protected]> Reviewed-by: Dave Tapuska <[email protected]> Commit-Queue: Chris Fredrickson <[email protected]> Cr-Commit-Position: refs/heads/main@{#1702985} --- diff --git a/content/browser/worker_host/shared_worker_host.cc b/content/browser/worker_host/shared_worker_host.cc index aec63a1b9..a11849b1 100644 --- a/content/browser/worker_host/shared_worker_host.cc +++ b/content/browser/worker_host/shared_worker_host.cc @@ -535,7 +535,7 @@ url_loader_factory::ContentClientParams( GetProcessHost()->GetBrowserContext(), /*frame=*/nullptr, GetProcessHost()->GetDeprecatedID(), origin, - GetWorkerStorageKey().ToPartialNetIsolationInfo(), + ComputeIsolationInfoForNetworkRequest(/*is_websocket_request=*/false), ukm::SourceIdObj::FromInt64(ukm_source_id_), bypass_redirect_checks), devtools_instrumentation::WillCreateURLLoaderFactoryParams:: ForSharedWorker(this)); @@ -561,7 +561,7 @@ network::mojom::URLLoaderFactoryParamsPtr factory_params = URLLoaderFactoryParamsHelper::CreateForWorker( GetProcessHost(), origin_lock, - GetWorkerStorageKey().ToPartialNetIsolationInfo(), + ComputeIsolationInfoForNetworkRequest(/*is_websocket_request=*/false), std::move(coep_reporter), std::move(dip_reporter), GetStoragePartitionImpl() ->CreateURLLoaderNetworkObserverForServiceOrSharedWorker( @@ -719,35 +719,37 @@ GlobalRenderFrameHostId(GetProcessHost()->GetID(), IPC::mojom::kRoutingIdNone), WeakDocumentPtr(), storage_key.origin(), - ComputeIsolationInfoForWebSocket(), + ComputeIsolationInfoForNetworkRequest(/*is_websocket_request=*/true), worker_client_security_state_->Clone(), net::StorageAccessApiStatus::kNone, network_restrictions_id_, GetDevToolsToken()), std::move(receiver)); } -net::IsolationInfo SharedWorkerHost::ComputeIsolationInfoForWebSocket() const { +net::IsolationInfo SharedWorkerHost::ComputeIsolationInfoForNetworkRequest( + bool is_websocket_request) const { const blink::StorageKey& storage_key = GetWorkerStorageKey(); net::IsolationInfo isolation_info = storage_key.ToPartialNetIsolationInfo(); - base::UmaHistogramBoolean( - "Content.SharedWorker.WebSocket.DoesRequireCrossSiteRequestForCookies", - instance_.DoesRequireCrossSiteRequestForCookies()); + if (is_websocket_request) { + base::UmaHistogramBoolean( + "Content.SharedWorker.WebSocket.DoesRequireCrossSiteRequestForCookies", + instance_.DoesRequireCrossSiteRequestForCookies()); + } - if (instance_.DoesRequireCrossSiteRequestForCookies()) { - if (base::FeatureList::IsEnabled( - features::kRestrictSharedWorkerWebSocketCrossSiteCookies)) { - // If the worker requires cross-site cookie semantics (e.g. a worker in a - // third-party context or created via the Storage Access API), we must - // ensure that the SiteForCookies is null. This prevents the network - // service from incorrectly attaching SameSite=Strict/Lax cookies to the - // WebSocket handshake. - CHECK(!isolation_info.IsEmpty()); - isolation_info = net::IsolationInfo::Create( - isolation_info.request_type(), *isolation_info.top_frame_origin(), - *isolation_info.frame_origin(), net::SiteForCookies(), - isolation_info.nonce()); - } + if (instance_.DoesRequireCrossSiteRequestForCookies() && + base::FeatureList::IsEnabled( + features::kRestrictSharedWorkerWebSocketCrossSiteCookies)) { + // If the worker requires cross-site cookie semantics (e.g. a worker in a + // third-party context or created via the Storage Access API), we must + // ensure that the SiteForCookies is null. This prevents the network service + // from incorrectly attaching SameSite=Strict/Lax or first-party CHIPS + // cookies to the request. + CHECK(!isolation_info.IsEmpty()); + isolation_info = net::IsolationInfo::Create( + isolation_info.request_type(), *isolation_info.top_frame_origin(), + *isolation_info.frame_origin(), net::SiteForCookies(), + isolation_info.nonce()); } return isolation_info; } diff --git a/content/browser/worker_host/shared_worker_host.h b/content/browser/worker_host/shared_worker_host.h index 3e8ef4a..7ddb670f 100644 --- a/content/browser/worker_host/shared_worker_host.h +++ b/content/browser/worker_host/shared_worker_host.h @@ -236,8 +236,12 @@ worker_client_security_state_ = std::move(client_security_state); } - // Computes the IsolationInfo used for WebSocket connections from this worker. - net::IsolationInfo ComputeIsolationInfoForWebSocket() const; + // Computes the IsolationInfo used for network requests from this worker. The + // SiteForCookies is nulled out when the worker requires cross-site request + // semantics for cookies (e.g. a worker in a third-party context, or one + // created with a first-party storage key via the Storage Access API). + net::IsolationInfo ComputeIsolationInfoForNetworkRequest( + bool is_websocket_request) const; base::WeakPtr<SharedWorkerHost> AsWeakPtr(); diff --git a/content/browser/worker_host/shared_worker_host_unittest.cc b/content/browser/worker_host/shared_worker_host_unittest.cc index 193b9ba..daab9f1 100644 --- a/content/browser/worker_host/shared_worker_host_unittest.cc +++ b/content/browser/worker_host/shared_worker_host_unittest.cc @@ -36,6 +36,9 @@ #include "content/public/test/test_utils.h" #include "mojo/public/cpp/bindings/self_owned_receiver.h" #include "mojo/public/cpp/test_support/test_utils.h" +#include "net/base/schemeful_site.h" +#include "net/cookies/cookie_partition_key.h" +#include "net/cookies/site_for_cookies.h" #include "services/network/public/cpp/cross_origin_embedder_policy.h" #include "services/network/public/cpp/features.h" #include "services/network/public/cpp/not_implemented_url_loader_factory.h" @@ -419,6 +422,49 @@ EXPECT_EQ(host->GetWorkerStorageKey().origin(), params->isolation_info.frame_origin()); EXPECT_FALSE(params->isolation_info.nonce().has_value()); + EXPECT_TRUE( + params->isolation_info.site_for_cookies().IsFirstParty(kWorkerUrl)); + EXPECT_EQ(net::CookiePartitionKey::FromNetworkIsolationKey( + params->isolation_info.network_isolation_key(), + params->isolation_info.site_for_cookies(), + net::SchemefulSite(kWorkerUrl), + /*main_frame_navigation=*/false), + net::CookiePartitionKey::FromWire( + net::SchemefulSite(kWorkerUrl), + net::CookiePartitionKey::AncestorChainBit::kSameSite)); +} + +TEST_F(SharedWorkerHostTest, + CreateNetworkFactoryParamsForSubresources_CrossSiteCookieRestrictions) { + // Create a SharedWorkerHost with a first-party storage key and + // SharedWorkerSameSiteCookies::kNone (e.g. created in a third-party context + // via the Storage Access API or with `sameSiteCookies: 'none'`). + base::WeakPtr<SharedWorkerHost> host = CreateHostWithSameSiteCookies( + blink::mojom::SharedWorkerSameSiteCookies::kNone); + + // Start the worker. + mojo::PendingRemote<blink::mojom::SharedWorkerFactory> factory; + MockSharedWorkerFactory factory_impl( + factory.InitWithNewPipeAndPassReceiver()); + StartWorker(host.get(), std::move(factory)); + + network::mojom::URLLoaderFactoryParamsPtr params = + host->CreateNetworkFactoryParamsForSubresources(); + EXPECT_EQ(host->GetWorkerStorageKey().origin(), + params->isolation_info.top_frame_origin()); + EXPECT_EQ(host->GetWorkerStorageKey().origin(), + params->isolation_info.frame_origin()); + // SiteForCookies must be null so that subresource requests do not derive a + // first-party (kSameSite) CHIPS cookie partition key. + EXPECT_TRUE(params->isolation_info.site_for_cookies().IsNull()); + EXPECT_EQ(net::CookiePartitionKey::FromNetworkIsolationKey( + params->isolation_info.network_isolation_key(), + params->isolation_info.site_for_cookies(), + net::SchemefulSite(kWorkerUrl), + /*main_frame_navigation=*/false), + net::CookiePartitionKey::FromWire( + net::SchemefulSite(kWorkerUrl), + net::CookiePartitionKey::AncestorChainBit::kCrossSite)); } TEST_F(SharedWorkerHostTest, CreateBlobUrlStoreProvider) { @@ -640,7 +686,9 @@ network::mojom::ClientSecurityState::New()); base::HistogramTester histogram_tester; - net::IsolationInfo isolation_info = host->ComputeIsolationInfoForWebSocket(); + net::IsolationInfo isolation_info = + host->ComputeIsolationInfoForNetworkRequest( + /*is_websocket_request=*/true); // SiteForCookies should NOT be null for same-origin workers. EXPECT_FALSE(isolation_info.site_for_cookies().IsNull()); @@ -686,7 +734,8 @@ base::HistogramTester histogram_tester; net::IsolationInfo isolation_info = - host->ComputeIsolationInfoForWebSocket(); + host->ComputeIsolationInfoForNetworkRequest( + /*is_websocket_request=*/true); EXPECT_FALSE(isolation_info.site_for_cookies().IsNull()); histogram_tester.ExpectUniqueSample( @@ -703,7 +752,8 @@ base::HistogramTester histogram_tester; net::IsolationInfo isolation_info = - host->ComputeIsolationInfoForWebSocket(); + host->ComputeIsolationInfoForNetworkRequest( + /*is_websocket_request=*/true); EXPECT_TRUE(isolation_info.site_for_cookies().IsNull()); histogram_tester.ExpectUniqueSample( diff --git a/content/browser/worker_host/worker_browsertest.cc b/content/browser/worker_host/worker_browsertest.cc index 80906dc6..39c16cc 100644 --- a/content/browser/worker_host/worker_browsertest.cc +++ b/content/browser/worker_host/worker_browsertest.cc @@ -17,11 +17,13 @@ #include "base/system/sys_info.h" #include "base/test/bind.h" #include "base/test/scoped_feature_list.h" +#include "base/test/test_future.h" #include "base/test/test_timeouts.h" #include "base/thread_annotations.h" #include "base/threading/thread_restrictions.h" #include "build/build_config.h" #include "content/browser/bad_message.h" +#include "content/browser/permissions/permission_controller_impl.h" #include "content/browser/process_lock.h" #include "content/browser/renderer_host/render_frame_host_impl.h" #include "content/browser/security/cpsp/child_process_security_policy_impl.h" @@ -51,8 +53,10 @@ #include "content/test/content_browser_test_utils_internal.h" #include "net/base/features.h" #include "net/base/filename_util.h" +#include "net/base/schemeful_site.h" #include "net/cookies/canonical_cookie.h" #include "net/cookies/cookie_access_result.h" +#include "net/cookies/cookie_partition_key.h" #include "net/dns/mock_host_resolver.h" #include "net/http/http_response_headers.h" #include "net/ssl/client_cert_identity.h" @@ -74,6 +78,7 @@ #include "services/network/public/mojom/parsed_headers.mojom.h" #include "services/network/public/mojom/url_response_head.mojom.h" #include "third_party/blink/public/common/features.h" +#include "third_party/blink/public/common/permissions/permission_utils.h" #include "third_party/blink/public/common/storage_key/storage_key.h" #include "url/gurl.h" @@ -81,7 +86,16 @@ namespace { -const char kSameSiteCookie[] = "same-site-cookie=same-site-cookie-value"; +const char kSameSiteLaxCookie[] = + "same-site-lax-cookie=same-site-lax-cookie-value"; +const char kSameSiteStrictCookie[] = + "same-site-strict-cookie=same-site-strict-cookie-value"; +const char kSameSiteNoneCookie[] = + "same-site-none-cookie=same-site-none-cookie-value"; +const char kFirstPartyPartitionedCookie[] = + "first-party-partitioned=partitioned-cookie-value"; +const char kCrossSitePartitionedCookie[] = + "cross-site-partitioned=partitioned-cookie-value"; // Used by both the embedded test server when a header specified by // "/echoheader" is missing, and by the test fixture when there's no cookie @@ -245,33 +259,26 @@ runner->Run(); } - void SetSameSiteCookie(const std::string& host) { - StoragePartition* partition = shell() - ->web_contents() - ->GetBrowserContext() - ->GetDefaultStoragePartition(); - mojo::Remote<network::mojom::CookieManager> cookie_manager; - partition->GetNetworkContext()->GetCookieManager( - cookie_manager.BindNewPipeAndPassReceiver()); - net::CookieOptions options; - options.set_same_site_cookie_context( - net::CookieOptions::SameSiteCookieContext( - net::CookieOptions::SameSiteCookieContext::ContextType:: - SAME_SITE_LAX)); + void SetSameSiteLaxCookie(std::string_view host) { + ASSERT_TRUE(SetCookie( + shell()->web_contents()->GetBrowserContext(), + ssl_server_.GetURL(host, "/"), + base::StrCat({kSameSiteLaxCookie, "; SameSite=Lax; Secure"}))); + } + + void SetPartitionedCookie( + std::string_view host, + std::string_view cookie_name_value, + net::CookiePartitionKey::AncestorChainBit ancestor_chain_bit) { GURL cookie_url = ssl_server_.GetURL(host, "/"); - std::unique_ptr<net::CanonicalCookie> cookie = - net::CanonicalCookie::CreateForTesting( - cookie_url, std::string(kSameSiteCookie) + "; SameSite=Lax; Secure", - base::Time::Now(), net::CookieSourceType::kOther); - base::RunLoop run_loop; - cookie_manager->SetCanonicalCookie( - *cookie, cookie_url, options, - base::BindLambdaForTesting( - [&](net::CookieAccessResult set_cookie_result) { - EXPECT_TRUE(set_cookie_result.status.IsInclude()); - run_loop.Quit(); - })); - run_loop.Run(); + net::CookiePartitionKey partition_key = net::CookiePartitionKey::FromWire( + net::SchemefulSite(cookie_url), ancestor_chain_bit); + ASSERT_TRUE( + SetCookie(shell()->web_contents()->GetBrowserContext(), cookie_url, + base::StrCat({cookie_name_value, + "; SameSite=None; Secure; Partitioned"}), + net::CookieOptions::SameSiteCookieContext::MakeInclusive(), + partition_key)); } // Returns the cookie received with the request for the specified path. If the @@ -981,8 +988,8 @@ const GURL resource_url( ssl_server()->GetURL(cross_site_domain, "/workers/empty.html")); - // Set a cookie for verfifying which requests send SameSite cookies. - SetSameSiteCookie(cross_site_domain); + // Set a cookie for verifying which requests send SameSite cookies. + SetSameSiteLaxCookie(cross_site_domain); std::set<GURL> expected_request_urls = {worker_url, script_url, resource_url}; const url::Origin expected_origin = @@ -1020,28 +1027,28 @@ // Test that an "a.test" worker sends "a.test" SameSite cookies, both when // requesting the worker script and when fetching other resources. IN_PROC_BROWSER_TEST_F(WorkerTest, WorkerSameSiteCookies1) { - SetSameSiteCookie("a.test"); + SetSameSiteLaxCookie("a.test"); ASSERT_TRUE(NavigateToURL( shell(), ssl_server()->GetURL( "a.test", "/workers/create_worker.html?worker_url=fetch_from_worker.js"))); - EXPECT_EQ(kSameSiteCookie, + EXPECT_EQ(kSameSiteLaxCookie, EvalJs(shell()->web_contents(), "worker.postMessage({url: '/echoheader?Cookie'}); " "waitForMessage();")); - EXPECT_EQ(kSameSiteCookie, + EXPECT_EQ(kSameSiteLaxCookie, GetReceivedCookie( "/workers/create_worker.html?worker_url=fetch_from_worker.js")); - EXPECT_EQ(kSameSiteCookie, + EXPECT_EQ(kSameSiteLaxCookie, GetReceivedCookie("/workers/fetch_from_worker.js")); - EXPECT_EQ(kSameSiteCookie, GetReceivedCookie("/echoheader?Cookie")); + EXPECT_EQ(kSameSiteLaxCookie, GetReceivedCookie("/echoheader?Cookie")); } // Test that a "b.test" worker does not send "a.test" SameSite cookies when // fetching resources. IN_PROC_BROWSER_TEST_F(WorkerTest, WorkerSameSiteCookies2) { - SetSameSiteCookie("a.test"); + SetSameSiteLaxCookie("a.test"); ASSERT_TRUE(NavigateToURL( shell(), ssl_server()->GetURL( @@ -1060,26 +1067,26 @@ // Test that an "a.test" nested worker sends "a.test" SameSite cookies, both // when requesting the worker script and when fetching other resources. IN_PROC_BROWSER_TEST_F(WorkerTest, NestedWorkerSameSiteCookies) { - SetSameSiteCookie("a.test"); + SetSameSiteLaxCookie("a.test"); ASSERT_TRUE(NavigateToURL( shell(), ssl_server()->GetURL( "a.test", "/workers/" "create_worker.html?worker_url=fetch_from_nested_worker.js"))); - EXPECT_EQ(kSameSiteCookie, + EXPECT_EQ(kSameSiteLaxCookie, EvalJs(shell()->web_contents(), "worker.postMessage({url: '/echoheader?Cookie'}); " "waitForMessage();")); - EXPECT_EQ(kSameSiteCookie, + EXPECT_EQ(kSameSiteLaxCookie, GetReceivedCookie( "/workers/" "create_worker.html?worker_url=fetch_from_nested_worker.js")); - EXPECT_EQ(kSameSiteCookie, ... (file diff truncated)
Regression Test / PoC
diff --git a/content/browser/worker_host/shared_worker_host_unittest.cc b/content/browser/worker_host/shared_worker_host_unittest.cc
index 193b9ba..daab9f1 100644
--- a/content/browser/worker_host/shared_worker_host_unittest.cc
+++ b/content/browser/worker_host/shared_worker_host_unittest.cc
@@ -36,6 +36,9 @@
#include "content/public/test/test_utils.h"
#include "mojo/public/cpp/bindings/self_owned_receiver.h"
#include "mojo/public/cpp/test_support/test_utils.h"
+#include "net/base/schemeful_site.h"
+#include "net/cookies/cookie_partition_key.h"
+#include "net/cookies/site_for_cookies.h"
#include "services/network/public/cpp/cross_origin_embedder_policy.h"
#include "services/network/public/cpp/features.h"
#include "services/network/public/cpp/not_implemented_url_loader_factory.h"
@@ -419,6 +422,49 @@
EXPECT_EQ(host->GetWorkerStorageKey().origin(),
params->isolation_info.frame_origin());
EXPECT_FALSE(params->isolation_info.nonce().has_value());
+ EXPECT_TRUE(
+ params->isolation_info.site_for_cookies().IsFirstParty(kWorkerUrl));
+ EXPECT_EQ(net::CookiePartitionKey::FromNetworkIsolationKey(
+ params->isolation_info.network_isolation_key(),
+ params->isolation_info.site_for_cookies(),
+ net::SchemefulSite(kWorkerUrl),
+ /*main_frame_navigation=*/false),
+ net::CookiePartitionKey::FromWire(
+ net::SchemefulSite(kWorkerUrl),
+ net::CookiePartitionKey::AncestorChainBit::kSameSite));
+}
+
+TEST_F(SharedWorkerHostTest,
+ CreateNetworkFactoryParamsForSubresources_CrossSiteCookieRestrictions) {
+ // Create a SharedWorkerHost with a first-party storage key and
+ // SharedWorkerSameSiteCookies::kNone (e.g. created in a third-party context
+ // via the Storage Access API or with `sameSiteCookies: 'none'`).
+ base::WeakPtr<SharedWorkerHost> host = CreateHostWithSameSiteCookies(
+ blink::mojom::SharedWorkerSameSiteCookies::kNone);
+
+ // Start the worker.
+ mojo::PendingRemote<blink::mojom::SharedWorkerFactory> factory;
+ MockSharedWorkerFactory factory_impl(
+ factory.InitWithNewPipeAndPassReceiver());
+ StartWorker(host.get(), std::move(factory));
+
+ network::mojom::URLLoaderFactoryParamsPtr params =
+ host->CreateNetworkFactoryParamsForSubresources();
+ EXPECT_EQ(host->GetWorkerStorageKey().origin(),
+ params->isolation_info.top_frame_origin());
+ EXPECT_EQ(host->GetWorkerStorageKey().origin(),
+ params->isolation_info.frame_origin());
+ // SiteForCookies must be null so that subresource requests do not derive a
+ // first-party (kSameSite) CHIPS cookie partition key.
+ EXPECT_TRUE(params->isolation_info.site_for_cookies().IsNull());
+ EXPECT_EQ(net::CookiePartitionKey::FromNetworkIsolationKey(
+ params->isolation_info.network_isolation_key(),
+ params->isolation_info.site_for_cookies(),
+ net::SchemefulSite(kWorkerUrl),
+ /*main_frame_navigation=*/false),
+ net::CookiePartitionKey::FromWire(
+ net::SchemefulSite(kWorkerUrl),
+ net::CookiePartitionKey::AncestorChainBit::kCrossSite));
}
TEST_F(SharedWorkerHostTest, CreateBlobUrlStoreProvider) {
@@ -640,7 +686,9 @@
network::mojom::ClientSecurityState::New());
base::HistogramTester histogram_tester;
- net::IsolationInfo isolation_info = host->ComputeIsolationInfoForWebSocket();
+ net::IsolationInfo isolation_info =
+ host->ComputeIsolationInfoForNetworkRequest(
+ /*is_websocket_request=*/true);
// SiteForCookies should NOT be null for same-origin workers.
EXPECT_FALSE(isolation_info.site_for_cookies().IsNull());
@@ -686,7 +734,8 @@
base::HistogramTester histogram_tester;
net::IsolationInfo isolation_info =
- host->ComputeIsolationInfoForWebSocket();
+ host->ComputeIsolationInfoForNetworkRequest(
+ /*is_websocket_request=*/true);
EXPECT_FALSE(isolation_info.site_for_cookies().IsNull());
histogram_tester.ExpectUniqueSample(
@@ -703,7 +752,8 @@
base::HistogramTester histogram_tester;
net::IsolationInfo isolation_info =
- host->ComputeIsolationInfoForWebSocket();
+ host->ComputeIsolationInfoForNetworkRequest(
+ /*is_websocket_request=*/true);
EXPECT_TRUE(isolation_info.site_for_cookies().IsNull());
histogram_tester.ExpectUniqueSample(
diff --git a/content/browser/worker_host/worker_browsertest.cc b/content/browser/worker_host/worker_browsertest.cc
index 80906dc6..39c16cc 100644
--- a/content/browser/worker_host/worker_browsertest.cc
+++ b/content/browser/worker_host/worker_browsertest.cc
@@ -17,11 +17,13 @@
#include "base/system/sys_info.h"
#include "base/test/bind.h"
#include "base/test/scoped_feature_list.h"
+#include "base/test/test_future.h"
#include "base/test/test_timeouts.h"
#include "base/thread_annotations.h"
#include "base/threading/thread_restrictions.h"
#include "build/build_config.h"
#include "content/browser/bad_message.h"
+#include "content/browser/permissions/permission_controller_impl.h"
#include "content/browser/process_lock.h"
#include "content/browser/renderer_host/render_frame_host_impl.h"
#include "content/browser/security/cpsp/child_process_security_policy_impl.h"
@@ -51,8 +53,10 @@
#include "content/test/content_browser_test_utils_internal.h"
#include "net/base/features.h"
#include "net/base/filename_util.h"
+#include "net/base/schemeful_site.h"
#include "net/cookies/canonical_cookie.h"
#include "net/cookies/cookie_access_result.h"
+#include "net/cookies/cookie_partition_key.h"
#include "net/dns/mock_host_resolver.h"
#include "net/http/http_response_headers.h"
#include "net/ssl/client_cert_identity.h"
@@ -74,6 +78,7 @@
#include "services/network/public/mojom/parsed_headers.mojom.h"
#include "services/network/public/mojom/url_response_head.mojom.h"
#include "third_party/blink/public/common/features.h"
+#include "third_party/blink/public/common/permissions/permission_utils.h"
#include "third_party/blink/public/common/storage_key/storage_key.h"
#include "url/gurl.h"
@@ -81,7 +86,16 @@
namespace {
-const char kSameSiteCookie[] = "same-site-cookie=same-site-cookie-value";
+const char kSameSiteLaxCookie[] =
+ "same-site-lax-cookie=same-site-lax-cookie-value";
+const char kSameSiteStrictCookie[] =
+ "same-site-strict-cookie=same-site-strict-cookie-value";
+const char kSameSiteNoneCookie[] =
+ "same-site-none-cookie=same-site-none-cookie-value";
+const char kFirstPartyPartitionedCookie[] =
+ "first-party-partitioned=partitioned-cookie-value";
+const char kCrossSitePartitionedCookie[] =
+ "cross-site-partitioned=partitioned-cookie-value";
// Used by both the embedded test server when a header specified by
// "/echoheader" is missing, and by the test fixture when there's no cookie
@@ -245,33 +259,26 @@
runner->Run();
}
- void SetSameSiteCookie(const std::string& host) {
- StoragePartition* partition = shell()
- ->web_contents()
- ->GetBrowserContext()
- ->GetDefaultStoragePartition();
- mojo::Remote<network::mojom::CookieManager> cookie_manager;
- partition->GetNetworkContext()->GetCookieManager(
- cookie_manager.BindNewPipeAndPassReceiver());
- net::CookieOptions options;
- options.set_same_site_cookie_context(
- net::CookieOptions::SameSiteCookieContext(
- net::CookieOptions::SameSiteCookieContext::ContextType::
- SAME_SITE_LAX));
+ void SetSameSiteLaxCookie(std::string_view host) {
+ ASSERT_TRUE(SetCookie(
+ shell()->web_contents()->GetBrowserContext(),
+ ssl_server_.GetURL(host, "/"),
+ base::StrCat({kSameSiteLaxCookie, "; SameSite=Lax; Secure"})));
+ }
+
+ void SetPartitionedCookie(
+ std::string_view host,
+ std::string_view cookie_name_value,
+ net::CookiePartitionKey::AncestorChainBit ancestor_chain_bit) {
GURL cookie_url = ssl_server_.GetURL(host, "/");
- std::unique_ptr<net::CanonicalCookie> cookie =
- net::CanonicalCookie::CreateForTesting(
- cookie_url, std::string(kSameSiteCookie) + "; SameSite=Lax; Secure",
- base::Time::Now(), net::CookieSourceType::kOther);
- base::RunLoop run_loop;
- cookie_manager->SetCanonicalCookie(
- *cookie, cookie_url, options,
- base::BindLambdaForTesting(
- [&](net::CookieAccessResult set_cookie_result) {
- EXPECT_TRUE(set_cookie_result.status.IsInclude());
- run_loop.Quit();
- }));
- run_loop.Run();
+ net::CookiePartitionKey partition_key = net::CookiePartitionKey::FromWire(
+ net::SchemefulSite(cookie_url), ancestor_chain_bit);
+ ASSERT_TRUE(
+ SetCookie(shell()->web_contents()->GetBrowserContext(), cookie_url,
+ base::StrCat({cookie_name_value,
+ "; SameSite=None; Secure; Partitioned"}),
+ net::CookieOptions::SameSiteCookieContext::MakeInclusive(),
+ partition_key));
}
// Returns the cookie received with the request for the specified path. If the
@@ -981,8 +988,8 @@
const GURL resource_url(
ssl_server()->GetURL(cross_site_domain, "/workers/empty.html"));
- // Set a cookie for verfifying which requests send SameSite cookies.
- SetSameSiteCookie(cross_site_domain);
+ // Set a cookie for verifying which requests send SameSite cookies.
+ SetSameSiteLaxCookie(cross_site_domain);
std::set<GURL> expected_request_urls = {worker_url, script_url, resource_url};
const url::Origin expected_origin =
@@ -1020,28 +1027,28 @@
// Test that an "a.test" worker sends "a.test" SameSite cookies, both when
// requesting the worker script and when fetching other resources.
IN_PROC_BROWSER_TEST_F(WorkerTest, WorkerSameSiteCookies1) {
- SetSameSiteCookie("a.test");
+ SetSameSiteLaxCookie("a.test");
ASSERT_TRUE(NavigateToURL(
shell(),
ssl_server()->GetURL(
"a.test",
"/workers/create_worker.html?worker_url=fetch_from_worker.js")));
- EXPECT_EQ(kSameSiteCookie,
+ EXPECT_EQ(kSameSiteLaxCookie,
EvalJs(shell()->web_contents(),
"worker.postMessage({url: '/echoheader?Cookie'}); "
"waitForMessage();"));
- EXPECT_EQ(kSameSiteCookie,
+ EXPECT_EQ(kSameSiteLaxCookie,
GetReceivedCookie(
"/workers/create_worker.html?worker_url=fetch_from_worker.js"));
- EXPECT_EQ(kSameSiteCookie,
+ EXPECT_EQ(kSameSiteLaxCookie,
GetReceivedCookie("/workers/fetch_from_worker.js"));
- EXPECT_EQ(kSameSiteCookie, GetReceivedCookie("/echoheader?Cookie"));
+ EXPECT_EQ(kSameSiteLaxCookie, GetReceivedCookie("/echoheader?Cookie"));
}
// Test that a "b.test" worker does not send "a.test" SameSite cookies when
// fetching resources.
IN_PROC_BROWSER_TEST_F(WorkerTest, WorkerSameSiteCookies2) {
- SetSameSiteCookie("a.test");
+ SetSameSiteLaxCookie("a.test");
ASSERT_TRUE(NavigateToURL(
shell(),
ssl_server()->GetURL(
@@ -1060,26 +1067,26 @@
// Test that an "a.test" nested worker sends "a.test" SameSite cookies, both
// when requesting the worker script and when fetching other resources.
IN_PROC_BROWSER_TEST_F(WorkerTest, NestedWorkerSameSiteCookies) {
- SetSameSiteCookie("a.test");
+ SetSameSiteLaxCookie("a.test");
ASSERT_TRUE(NavigateToURL(
shell(),
ssl_server()->GetURL(
"a.test",
"/workers/"
"create_worker.html?worker_url=fetch_from_nested_worker.js")));
- EXPECT_EQ(kSameSiteCookie,
+ EXPECT_EQ(kSameSiteLaxCookie,
EvalJs(shell()->web_contents(),
"worker.postMessage({url: '/echoheader?Cookie'}); "
"waitForMessage();"));
- EXPECT_EQ(kSameSiteCookie,
+ EXPECT_EQ(kSameSiteLaxCookie,
GetReceivedCookie(
"/workers/"
"create_worker.html?worker_url=fetch_from_nested_worker.js"));
- EXPECT_EQ(kSameSiteCookie,
+ EXPECT_EQ(kSameSiteLaxCookie,
GetReceivedCookie("/workers/fetch_from_nested_worker.js"));
- EXPECT_EQ(kSameSiteCookie,
+ EXPECT_EQ(kSameSiteLaxCookie,
GetReceivedCookie("/workers/fetch_from_worker.js"));
- EXPECT_EQ(kSameSiteCookie, GetReceivedCookie("/echoheader?Cookie"));
+ EXPECT_EQ(kSameSiteLaxCookie, GetReceivedCookie("/echoheader?Cookie"));
}
// Test that an "a.test" iframe in a "b.test" frame does not send same-site
@@ -1087,7 +1094,7 @@
// "a.test" resources.
IN_PROC_BROWSER_TEST_F(WorkerTest,
CrossOriginIframeWorkerDoesNotSendSameSiteCookies1) {
- SetSameSiteCookie("a.test");
+ SetSameSiteLaxCookie("a.test");
ASSERT_TRUE(NavigateToURL(
shell(), ssl_server()->GetURL("b.test", "/workers/frame_factory.html")));
@@ -1129,7 +1136,7 @@
// cookies when its "b.test" worker requests "a.test" resources.
IN_PROC_BROWSER_TEST_F(WorkerTest,
CrossOriginIframeWorkerDoesNotSendSameSiteCookies2) {
- SetSameSiteCookie("a.test");
+ SetSameSiteLaxCookie("a.test");
ASSERT_TRUE(NavigateToURL(
shell(), ssl_server()->GetURL("a.test", "/workers/frame_factory.html")));
... (truncated)
Original Bug Report
First-party CHIPS cookie partition leak in SAA SharedWorker subresource factory
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: When a cross-site iframe creates a SharedWorker via the Storage Access API, the worker receives a first-party StorageKey while requiring cross-site cookie restrictions. However, SharedWorkerHost::CreateNetworkFactoryParamsForSubresources constructs the subresource URLLoaderFactory IsolationInfo without clearing the first-party SiteForCookies. As a result, subresource requests derive a CookiePartitionKey with AncestorChainBit::kSameSite, potentially allowing third-party embedded contexts to read and write first-party partitioned (CHIPS) cookies.
Affected files:
content/browser/worker_host/shared_worker_host.cc
Estimated timestamp from git blame: 2024-02-14
Summary
When a cross-site iframe creates a SharedWorker through the Storage Access API (document.requestStorageAccess({sharedWorker: true})), the browser grants the worker a first-party StorageKey (blink::StorageKey::CreateFirstParty(...)) to share storage with the origin. Because the creator frame is embedded cross-site, the worker is marked with DoesRequireCrossSiteRequestForCookies() == true.
However, in SharedWorkerHost::CreateNetworkFactoryParamsForSubresources(), the subresource URLLoaderFactoryParams’ IsolationInfo is initialized directly from GetWorkerStorageKey().ToPartialNetIsolationInfo(), which contains a non-null first-party SiteForCookies. When subresource requests are dispatched in the Network Service, URLRequest::set_isolation_info() derives cookie_partition_key_ using this factory IsolationInfo. Because the factory SiteForCookies is non-null and matches the request site, CookiePartitionKey::FromNetworkIsolationKey() assigns AncestorChainBit::kSameSite instead of AncestorChainBit::kCrossSite.
This derives a partition key identical to the target origin’s first-party CHIPS partition (origin, AncestorChainBit::kSameSite), allowing a third-party embedded context to potentially read and overwrite (fixate) first-party SameSite=None; Partitioned cookies.
Root Cause Analysis
-
StorageKey Override in SAA: In
content/browser/storage_access/storage_access_handle.cc:213-220, creating a SharedWorker overrides the storage key to a first-party key:void StorageAccessHandle::BindSharedWorker( mojo::PendingReceiver<blink::mojom::SharedWorkerConnector> receiver) { SharedWorkerConnectorImpl::Create( PassKey(), render_frame_host().GetGlobalId(), blink::StorageKey::CreateFirstParty( render_frame_host().GetStorageKey().origin()), std::move(receiver)); } -
Cross-Site Cookie Enforcement: In
content/browser/worker_host/shared_worker_service_impl.cc:162-168andthird_party/blink/renderer/core/workers/shared_worker.cc:180-183, third-party frames forcesame_site_cookies = mojom::SharedWorkerSameSiteCookies::kNone. Consequently,SharedWorkerInstance::DoesRequireCrossSiteRequestForCookies()(content/public/browser/shared_worker_instance.h:83-87) returnstrue. -
Factory IsolationInfo Retains First-Party SiteForCookies: In
content/browser/worker_host/shared_worker_host.cc:544-558:network::mojom::URLLoaderFactoryParamsPtr factory_params = URLLoaderFactoryParamsHelper::CreateForWorker( GetProcessHost(), origin_lock, GetWorkerStorageKey().ToPartialNetIsolationInfo(), // <-- Non-null 1P SiteForCookies std::move(coep_reporter), std::move(dip_reporter), ... instance_.DoesRequireCrossSiteRequestForCookies(), // Sets factory flag only /*is_for_service_worker=*/false);For a first-party
StorageKey,ToPartialNetIsolationInfo()embedsnet::SiteForCookies(top_level_site_)(third_party/blink/common/storage_key/storage_key.cc:781-800). Therequire_cross_site_request_for_cookiesflag is only checked inCorsURLLoaderFactory::IsValidRequestagainstrequest.site_for_cookies(which Blink nulls atfetch_manager.cc:1204-1208), but does not sanitizefactory_params->isolation_info. -
CookiePartitionKey Derivation in Network Service: In
services/network/url_loader_util.cc:497-503,url_request.set_isolation_info()receives the factory’sIsolationInfo. Innet/url_request/url_request.cc:531-544andnet/cookies/cookie_partition_key.cc:174-177:ancestor_chain_bit = BoolToAncestorChainBit( !site_for_cookies.IsFirstParty(request_site.GetURL()));Because
site_for_cookiesisSiteForCookies(origin),IsFirstParty()evaluates totrue, settingancestor_chain_bittokSameSite. The resulting partition key is(origin, AncestorChainBit::kSameSite). -
Contrast with WebSocket Implementation: The WebSocket path in
SharedWorkerHost::ComputeIsolationInfoForWebSocket(content/browser/worker_host/shared_worker_host.cc:719-733) correctly nullsSiteForCookieswheninstance_.DoesRequireCrossSiteRequestForCookies()is true, but this was omitted for the subresource loader factory.
Potential Trigger Steps
(Note: These are suggested/potential steps derived from static code analysis, as our current tooling environment cannot execute live browser code.)
- Setup: Victim visits
https://tracker.exampledirectly. The server setsSet-Cookie: sid=SECRET; Secure; SameSite=None; Partitioned; Path=/, which is stored under the partition(https://tracker.example, AncestorChainBit::kSameSite). - Embedding: Victim visits
https://news.example, which embeds<iframe src="https://tracker.example/embed.html">. - SAA Request: The iframe requests storage access:
const handle = await document.requestStorageAccess({sharedWorker: true});. - Worker Launch: The iframe launches a SharedWorker via
const worker = handle.SharedWorker('/worker.js');. - Subresource Fetch (Read): Inside
/worker.js, the script issuesfetch('https://tracker.example/api/session', {credentials: 'include'}). The subresource request usesCookiePartitionKey(tracker.example, kSameSite), attaching the victim’s first-party partitioned cookie (sid=SECRET), which the worker can read and exfiltrate to the third-party iframe. - Cookie Fixation (Write): A response to a worker subresource fetch with
Set-Cookie: sid=ATTACKER; Secure; SameSite=None; Partitioned; Path=/writes directly into the first-party partition(https://tracker.example, kSameSite), fixating the cookie for subsequent top-level visits.
Suggested Fix
When constructing the subresource loader factory and ContentClientParams in SharedWorkerHost, if instance_.DoesRequireCrossSiteRequestForCookies() is true, recreate the IsolationInfo with an empty net::SiteForCookies(), matching the logic used in SharedWorkerHost::ComputeIsolationInfoForWebSocket():
net::IsolationInfo isolation_info = GetWorkerStorageKey().ToPartialNetIsolationInfo();
if (instance_.DoesRequireCrossSiteRequestForCookies()) {
isolation_info = net::IsolationInfo::Create(
isolation_info.request_type(), *isolation_info.top_frame_origin(),
*isolation_info.frame_origin(), net::SiteForCookies(),
isolation_info.nonce());
}
Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.