CVE-2026-87471
Overview
Background
- `ServiceWorkerMainResourceHandle`
- A browser-side object (
service_worker_handle_) that reserves aServiceWorkerClientfor a main-resource navigation so the committed document can be controlled by a service worker. - `ServiceWorkerContainerHost`
- The browser-process endpoint that backs a document’s service worker container and ships controller info to the renderer during
CommitNavigation. - CSP `sandbox` directive
- A
Content-Security-Policy: sandboxresponse header that applies sandbox flags to the document it delivers, forcing an opaque origin unlessallow-same-originis present. - `network::mojom::WebSandboxFlags::kOrigin`
- The sandbox flag that, when set, forces the document to commit with an opaque origin and thus makes it ineligible to use service workers.
Root Cause Analysis
NavigationRequest decided whether to allocate service_worker_handle_ (a ServiceWorkerMainResourceHandle) before sending the request, using only the sandbox flags inherited from the frame owner element via pending_frame_policy(). When the response itself carried a Content-Security-Policy: sandbox header without allow-same-origin, the document committed with an opaque origin — which is not eligible for service worker control — yet the previously created handle was never re-evaluated. As a result a ServiceWorkerContainerHost and controller info were still created and sent to the renderer in CommitNavigation, with the renderer merely discarding them later in ServiceWorkerNetworkProviderForFrame::DidCreateNewDocument(). The invariant violated is that an opaque-origin (CSP-sandboxed) document must never be provisioned with a service worker container in the browser.
The fix re-checks the final policies at commit time — after CSP-delivered sandbox flags are known — and if WebSandboxFlags::kOrigin is set it calls service_worker_handle_.reset(), destroying the reserved ServiceWorkerClient before any container host is built.
kOrigin-sandboxed document has its handle dropped in the browser rather than relying on the renderer to clean up.Attack Path
- Register a controlling service worker An attacker or victim registration owns a scope covering the target URL, so navigations to it are matched against the registration.
- Serve a CSP-sandboxed response
The in-scope resource returns
Content-Security-Policy: sandbox(withoutallow-same-origin), causing the document to commit with an opaque origin. - Navigate in scope
Loading the resource as a subframe (no
sandboxiframe attribute) or as a main frame matches the request against the registration before response headers are known. - Container host is provisioned anyway
The stale
service_worker_handle_survives to commit, so the browser builds aServiceWorkerContainerHostand controller info for a document that should have none. - Renderer-only cleanup Eligibility is enforced only later in the renderer, leaving the browser having granted service-worker provisioning to an opaque-origin document.
Impact Assessment
Content-Security-Policy: sandbox without allow-same-origin; the CVE is classified as a medium-severity logic/authorization error.Changed Functions
| Function | Change | Notes |
|---|---|---|
BindLambdaForTestingcontent/browser/service_worker/service_worker_browsertest.cc |
modified |
Files Changed
content/browser/renderer_host/navigation_request.cccontent/browser/service_worker/service_worker_browsertest.cc
Audit Directions
- Pre-request vs. commit-time policy decisionsAudit any navigation decision made from
pending_frame_policy()or frame-owner sandbox flags and confirm it is re-validated againstpolicy_container_builder_->FinalPolicies()once response-delivered CSP is known. - Opaque-origin eligibility invariantsSearch for browser-side capabilities (storage, service workers, permissions) provisioned before origin computation and verify
WebSandboxFlags::kOrigin/ opaque-origin cases are excluded in the browser, not only in the renderer. - Renderer-side cleanup as a smellTreat any state the renderer silently drops (e.g. in
DidCreateNewDocument()) as a signal that the browser granted something it should have withheld, and push the check upstream to the trust boundary.
Patch
From c7e9116783f4036c2ccef5e4d992053cfc55efef Mon Sep 17 00:00:00 2001
From: Jiacheng Guo <gjc@google.com>
Date: Wed, 01 Jul 2026 21:05:36 -0700
Subject: [PATCH] Drop ServiceWorkerMainResourceHandle for CSP-sandboxed documents
NavigationRequest decides whether to create a
ServiceWorkerMainResourceHandle before sending the request, based only
on the sandbox flags inherited from the frame owner element
(pending_frame_policy()). When the response carries a
`Content-Security-Policy: sandbox` header without `allow-same-origin`,
the document commits with an opaque origin and is not eligible to use
service workers, but the handle was kept and a
ServiceWorkerContainerHost (and controller info) was created and sent to
the renderer in CommitNavigation. The renderer then dropped them in
ServiceWorkerNetworkProviderForFrame::DidCreateNewDocument().
Perform the same check in the browser at commit time, once the final
policies (which include CSP-delivered sandbox flags) are known: if the
kOrigin sandbox flag is set, reset the handle so the reserved
ServiceWorkerClient is destroyed without creating a container host and
nothing service-worker related is sent to the renderer.
Add a content_browsertest that registers a service worker, loads a page
in scope whose response is CSP-sandboxed (both as a subframe with no
`sandbox` iframe attribute and as a main frame), and verifies that the
reserved ServiceWorkerClient is destroyed without ever transitioning to
the response-committed state.
Bug: 498869663
Change-Id: Idb36e05924a003e3f7363431db58415fcd8617cd
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8018942
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Commit-Queue: Jiacheng Guo <gjc@google.com>
Cr-Commit-Position: refs/heads/main@{#1655887}
---
diff --git a/content/browser/renderer_host/navigation_request.cc b/content/browser/renderer_host/navigation_request.cc
index b12de8e..7d56d368 100644
--- a/content/browser/renderer_host/navigation_request.cc
+++ b/content/browser/renderer_host/navigation_request.cc
@@ -7128,6 +7128,19 @@
service_worker_container_info;
blink::mojom::ControllerServiceWorkerInfoPtr controller;
+ // The decision to create `service_worker_handle_` was made before the
+ // request was sent, based only on the sandbox flags inherited from the frame
+ // owner element. If the response delivered a `Content-Security-Policy:
+ // sandbox` header without `allow-same-origin`, the document will commit with
+ // an opaque origin and is not eligible to use service workers, so drop the
+ // handle now instead of creating a container host for the committed document.
+ if (service_worker_handle_ &&
+ (policy_container_builder_->FinalPolicies().sandbox_flags &
+ network::mojom::WebSandboxFlags::kOrigin) ==
+ network::mojom::WebSandboxFlags::kOrigin) {
+ service_worker_handle_.reset();
+ }
+
// Notify the service worker navigation handle that navigation commit is
// about to go.
if (service_worker_handle_ &&
diff --git a/content/browser/service_worker/service_worker_browsertest.cc b/content/browser/service_worker/service_worker_browsertest.cc
index 940049f..d355bcba 100644
--- a/content/browser/service_worker/service_worker_browsertest.cc
+++ b/content/browser/service_worker/service_worker_browsertest.cc
@@ -1045,6 +1045,162 @@
running_info.render_process_id);
}
+// A document that commits with an opaque origin because its response carries a
+// `Content-Security-Policy: sandbox` header (without `allow-same-origin`) must
+// not be given a service worker container in the browser process, even if its
+// URL is in the scope of an active registration. The sandbox flags from the
+// response are only known once the final policies have been computed, so this
+// must be enforced at commit time in addition to the pre-request check based
+// on frame-owner sandbox flags.
+IN_PROC_BROWSER_TEST_F(ServiceWorkerBrowserTest,
+ NoContainerHostForCSPSandboxedDocument) {
+ StartServerAndNavigateToSetup();
+
+ // Register a service worker that controls /service_worker/.
+ EXPECT_TRUE(NavigateToURL(shell(),
+ embedded_test_server()->GetURL(
+ "/service_worker/create_service_worker.html")));
+ EXPECT_EQ("DONE",
+ EvalJs(shell(), "register('fetch_event_pass_through.js');"));
+
+ // Navigate the main frame to a controlled page so it can host a subframe.
+ EXPECT_TRUE(NavigateToURL(
+ shell(), embedded_test_server()->GetURL("/service_worker/empty.html")));
+ RenderFrameHostImpl* main_frame = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ ASSERT_TRUE(main_frame->GetLastCommittedServiceWorkerClient());
+ EXPECT_TRUE(main_frame->GetLastCommittedServiceWorkerClient()->controller());
+
+ // Create a same-site iframe with no `sandbox` attribute on the <iframe>
+ // element. The response carries `Content-Security-Policy: sandbox`, so the
+ // subframe document commits with an opaque origin. While the navigation
+ // request itself may still be matched against the registration (the response
+ // headers are not yet known), the resulting reserved client must be dropped
+ // without creating a container host once the final sandbox flags are known.
+ GURL subframe_url =
+ embedded_test_server()->GetURL("/service_worker/csp_sandboxed.html");
+ blink::StorageKey key =
+ blink::StorageKey::CreateFirstParty(url::Origin::Create(subframe_url));
+
+ auto verify_client_destroyed = [&](TestNavigationManager& nav_manager) {
+ ASSERT_TRUE(nav_manager.WaitForResponse());
+
+ ServiceWorkerClient* reserved_client = nullptr;
+ for (auto it = wrapper()
+ ->context()
+ ->service_worker_client_owner()
+ .GetServiceWorkerClients(
+ key, /*include_reserved_clients=*/true,
+ /*include_back_forward_cached_clients=*/false);
+ !it.IsAtEnd(); ++it) {
+ if (it->url() == subframe_url) {
+ reserved_client = &(*it);
+ break;
+ }
+ }
+ ASSERT_TRUE(reserved_client);
+ EXPECT_FALSE(reserved_client->is_response_committed());
+
+ bool client_destroyed = false;
+ bool was_response_committed = false;
+ reserved_client->SetDestructionCallbackForTesting(
+ base::BindLambdaForTesting([&]() {
+ client_destroyed = true;
+ was_response_committed = reserved_client->is_response_committed();
+ }));
+
+ nav_manager.ResumeNavigation();
+ EXPECT_TRUE(nav_manager.WaitForNavigationFinished());
+ EXPECT_TRUE(client_destroyed);
+ EXPECT_FALSE(was_response_committed);
+ };
+
+ {
+ TestNavigationManager nav_manager(shell()->web_contents(), subframe_url);
+ EXPECT_TRUE(
+ ExecJs(main_frame, JsReplace("let f = document.createElement('iframe');"
+ "f.src = $1;"
+ "document.body.appendChild(f);",
+ subframe_url)));
+ verify_client_destroyed(nav_manager);
+ }
+
+ ASSERT_EQ(1u, main_frame->child_count());
+ RenderFrameHostImpl* child_frame =
+ main_frame->child_at(0)->current_frame_host();
+ EXPECT_EQ(subframe_url, child_frame->GetLastCommittedURL());
+ EXPECT_TRUE(child_frame->GetLastCommittedOrigin().opaque());
+ EXPECT_FALSE(child_frame->GetLastCommittedServiceWorkerClient());
+
+ // The same applies when the main frame itself is CSP-sandboxed.
+ {
+ TestNavigationManager nav_manager(shell()->web_contents(), subframe_url);
+ shell()->LoadURL(subframe_url);
+ verify_client_destroyed(nav_manager);
+ }
+ main_frame = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ EXPECT_TRUE(main_frame->GetLastCommittedOrigin().opaque());
+ EXPECT_FALSE(main_frame->GetLastCommittedServiceWorkerClient());
+}
+
+// A document that commits with a non-opaque origin because its response carries
+// a `Content-Security-Policy: sandbox allow-same-origin` header must keep its
+// service worker container in the browser process and remain controlled by the
+// service worker.
+IN_PROC_BROWSER_TEST_F(ServiceWorkerBrowserTest,
+ ContainerHostForCSPSandboxedAllowSameOriginDocument) {
+ StartServerAndNavigateToSetup();
+
+ // Register a service worker that controls /service_worker/.
+ EXPECT_TRUE(NavigateToURL(shell(),
+ embedded_test_server()->GetURL(
+ "/service_worker/create_service_worker.html")));
+ EXPECT_EQ("DONE",
+ EvalJs(shell(), "register('fetch_event_pass_through.js');"));
+
+ // Navigate the main frame to a controlled page so it can host a subframe.
+ EXPECT_TRUE(NavigateToURL(
+ shell(), embedded_test_server()->GetURL("/service_worker/empty.html")));
+ RenderFrameHostImpl* main_frame = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ ASSERT_TRUE(main_frame->GetLastCommittedServiceWorkerClient());
+ EXPECT_TRUE(main_frame->GetLastCommittedServiceWorkerClient()->controller());
+
+ // Create a same-site iframe with no `sandbox` attribute on the <iframe>
+ // element. The response carries `Content-Security-Policy: sandbox
+ // allow-same-origin`, so the subframe document commits with a non-opaque
+ // origin and remains controlled by the service worker.
+ GURL subframe_url = embedded_test_server()->GetURL(
+ "/service_worker/csp_sandboxed_allow_same_origin.html");
+ {
+ EXPECT_TRUE(
+ ExecJs(main_frame, JsReplace("let f = document.createElement('iframe');"
+ "f.src = $1;"
Regression Test / PoC
diff --git a/content/browser/service_worker/service_worker_browsertest.cc b/content/browser/service_worker/service_worker_browsertest.cc
index 940049f..d355bcba 100644
--- a/content/browser/service_worker/service_worker_browsertest.cc
+++ b/content/browser/service_worker/service_worker_browsertest.cc
@@ -1045,6 +1045,162 @@
running_info.render_process_id);
}
+// A document that commits with an opaque origin because its response carries a
+// `Content-Security-Policy: sandbox` header (without `allow-same-origin`) must
+// not be given a service worker container in the browser process, even if its
+// URL is in the scope of an active registration. The sandbox flags from the
+// response are only known once the final policies have been computed, so this
+// must be enforced at commit time in addition to the pre-request check based
+// on frame-owner sandbox flags.
+IN_PROC_BROWSER_TEST_F(ServiceWorkerBrowserTest,
+ NoContainerHostForCSPSandboxedDocument) {
+ StartServerAndNavigateToSetup();
+
+ // Register a service worker that controls /service_worker/.
+ EXPECT_TRUE(NavigateToURL(shell(),
+ embedded_test_server()->GetURL(
+ "/service_worker/create_service_worker.html")));
+ EXPECT_EQ("DONE",
+ EvalJs(shell(), "register('fetch_event_pass_through.js');"));
+
+ // Navigate the main frame to a controlled page so it can host a subframe.
+ EXPECT_TRUE(NavigateToURL(
+ shell(), embedded_test_server()->GetURL("/service_worker/empty.html")));
+ RenderFrameHostImpl* main_frame = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ ASSERT_TRUE(main_frame->GetLastCommittedServiceWorkerClient());
+ EXPECT_TRUE(main_frame->GetLastCommittedServiceWorkerClient()->controller());
+
+ // Create a same-site iframe with no `sandbox` attribute on the <iframe>
+ // element. The response carries `Content-Security-Policy: sandbox`, so the
+ // subframe document commits with an opaque origin. While the navigation
+ // request itself may still be matched against the registration (the response
+ // headers are not yet known), the resulting reserved client must be dropped
+ // without creating a container host once the final sandbox flags are known.
+ GURL subframe_url =
+ embedded_test_server()->GetURL("/service_worker/csp_sandboxed.html");
+ blink::StorageKey key =
+ blink::StorageKey::CreateFirstParty(url::Origin::Create(subframe_url));
+
+ auto verify_client_destroyed = [&](TestNavigationManager& nav_manager) {
+ ASSERT_TRUE(nav_manager.WaitForResponse());
+
+ ServiceWorkerClient* reserved_client = nullptr;
+ for (auto it = wrapper()
+ ->context()
+ ->service_worker_client_owner()
+ .GetServiceWorkerClients(
+ key, /*include_reserved_clients=*/true,
+ /*include_back_forward_cached_clients=*/false);
+ !it.IsAtEnd(); ++it) {
+ if (it->url() == subframe_url) {
+ reserved_client = &(*it);
+ break;
+ }
+ }
+ ASSERT_TRUE(reserved_client);
+ EXPECT_FALSE(reserved_client->is_response_committed());
+
+ bool client_destroyed = false;
+ bool was_response_committed = false;
+ reserved_client->SetDestructionCallbackForTesting(
+ base::BindLambdaForTesting([&]() {
+ client_destroyed = true;
+ was_response_committed = reserved_client->is_response_committed();
+ }));
+
+ nav_manager.ResumeNavigation();
+ EXPECT_TRUE(nav_manager.WaitForNavigationFinished());
+ EXPECT_TRUE(client_destroyed);
+ EXPECT_FALSE(was_response_committed);
+ };
+
+ {
+ TestNavigationManager nav_manager(shell()->web_contents(), subframe_url);
+ EXPECT_TRUE(
+ ExecJs(main_frame, JsReplace("let f = document.createElement('iframe');"
+ "f.src = $1;"
+ "document.body.appendChild(f);",
+ subframe_url)));
+ verify_client_destroyed(nav_manager);
+ }
+
+ ASSERT_EQ(1u, main_frame->child_count());
+ RenderFrameHostImpl* child_frame =
+ main_frame->child_at(0)->current_frame_host();
+ EXPECT_EQ(subframe_url, child_frame->GetLastCommittedURL());
+ EXPECT_TRUE(child_frame->GetLastCommittedOrigin().opaque());
+ EXPECT_FALSE(child_frame->GetLastCommittedServiceWorkerClient());
+
+ // The same applies when the main frame itself is CSP-sandboxed.
+ {
+ TestNavigationManager nav_manager(shell()->web_contents(), subframe_url);
+ shell()->LoadURL(subframe_url);
+ verify_client_destroyed(nav_manager);
+ }
+ main_frame = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ EXPECT_TRUE(main_frame->GetLastCommittedOrigin().opaque());
+ EXPECT_FALSE(main_frame->GetLastCommittedServiceWorkerClient());
+}
+
+// A document that commits with a non-opaque origin because its response carries
+// a `Content-Security-Policy: sandbox allow-same-origin` header must keep its
+// service worker container in the browser process and remain controlled by the
+// service worker.
+IN_PROC_BROWSER_TEST_F(ServiceWorkerBrowserTest,
+ ContainerHostForCSPSandboxedAllowSameOriginDocument) {
+ StartServerAndNavigateToSetup();
+
+ // Register a service worker that controls /service_worker/.
+ EXPECT_TRUE(NavigateToURL(shell(),
+ embedded_test_server()->GetURL(
+ "/service_worker/create_service_worker.html")));
+ EXPECT_EQ("DONE",
+ EvalJs(shell(), "register('fetch_event_pass_through.js');"));
+
+ // Navigate the main frame to a controlled page so it can host a subframe.
+ EXPECT_TRUE(NavigateToURL(
+ shell(), embedded_test_server()->GetURL("/service_worker/empty.html")));
+ RenderFrameHostImpl* main_frame = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ ASSERT_TRUE(main_frame->GetLastCommittedServiceWorkerClient());
+ EXPECT_TRUE(main_frame->GetLastCommittedServiceWorkerClient()->controller());
+
+ // Create a same-site iframe with no `sandbox` attribute on the <iframe>
+ // element. The response carries `Content-Security-Policy: sandbox
+ // allow-same-origin`, so the subframe document commits with a non-opaque
+ // origin and remains controlled by the service worker.
+ GURL subframe_url = embedded_test_server()->GetURL(
+ "/service_worker/csp_sandboxed_allow_same_origin.html");
+ {
+ EXPECT_TRUE(
+ ExecJs(main_frame, JsReplace("let f = document.createElement('iframe');"
+ "f.src = $1;"
+ "document.body.appendChild(f);",
+ subframe_url)));
+ EXPECT_TRUE(WaitForLoadStop(shell()->web_contents()));
+ }
+
+ ASSERT_EQ(1u, main_frame->child_count());
+ RenderFrameHostImpl* child_frame =
+ main_frame->child_at(0)->current_frame_host();
+ EXPECT_EQ(subframe_url, child_frame->GetLastCommittedURL());
+ EXPECT_FALSE(child_frame->GetLastCommittedOrigin().opaque());
+ ASSERT_TRUE(child_frame->GetLastCommittedServiceWorkerClient());
+ EXPECT_TRUE(child_frame->GetLastCommittedServiceWorkerClient()->controller());
+
+ // The same applies when the main frame itself is sandboxed with
+ // allow-same-origin.
+ shell()->LoadURL(subframe_url);
+ EXPECT_TRUE(WaitForLoadStop(shell()->web_contents()));
+ main_frame = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ EXPECT_FALSE(main_frame->GetLastCommittedOrigin().opaque());
+ ASSERT_TRUE(main_frame->GetLastCommittedServiceWorkerClient());
+ EXPECT_TRUE(main_frame->GetLastCommittedServiceWorkerClient()->controller());
+}
+
IN_PROC_BROWSER_TEST_F(ServiceWorkerBrowserTest, StartWorkerWhileInstalling) {
StartServerAndNavigateToSetup();
const char kWorkerUrl[] = "/service_worker/while_true_in_install_worker.js";
diff --git a/content/test/content_test_bundle_data.filelist b/content/test/content_test_bundle_data.filelist
index 8f98feaa..4a62856 100644
--- a/content/test/content_test_bundle_data.filelist
+++ b/content/test/content_test_bundle_data.filelist
@@ -8920,6 +8920,10 @@
data/service_worker/create_service_worker_from_isolated.html.mock-http-headers
data/service_worker/cross_site_xfer.js
data/service_worker/cross_site_xfer.js.mock-http-headers
+data/service_worker/csp_sandboxed.html
+data/service_worker/csp_sandboxed.html.mock-http-headers
+data/service_worker/csp_sandboxed_allow_same_origin.html
+data/service_worker/csp_sandboxed_allow_same_origin.html.mock-http-headers
data/service_worker/disable_web_security_cross_origin.html
data/service_worker/disable_web_security_get_registration.html
data/service_worker/disable_web_security_register.html
diff --git a/content/test/data/service_worker/csp_sandboxed.html b/content/test/data/service_worker/csp_sandboxed.html
new file mode 100644
index 0000000..081ad86
--- /dev/null
+++ b/content/test/data/service_worker/csp_sandboxed.html
@@ -0,0 +1,5 @@
+<html>
+ <head>
+ <title>ServiceWorker test - CSP sandboxed page</title>
+ </head>
+</html>
diff --git a/content/test/data/service_worker/csp_sandboxed.html.mock-http-headers b/content/test/data/service_worker/csp_sandboxed.html.mock-http-headers
new file mode 100644
index 0000000..4f8228a
--- /dev/null
+++ b/content/test/data/service_worker/csp_sandboxed.html.mock-http-headers
@@ -0,0 +1,3 @@
+HTTP/1.1 200 OK
+Content-Type: text/html
+Content-Security-Policy: sandbox allow-scripts
diff --git a/content/test/data/service_worker/csp_sandboxed_allow_same_origin.html b/content/test/data/service_worker/csp_sandboxed_allow_same_origin.html
new file mode 100644
index 0000000..9dd05839
--- /dev/null
+++ b/content/test/data/service_worker/csp_sandboxed_allow_same_origin.html
@@ -0,0 +1,5 @@
+<html>
+ <head>
+ <title>ServiceWorker test - CSP sandboxed page with allow-same-origin</title>
+ </head>
+</html>
diff --git a/content/test/data/service_worker/csp_sandboxed_allow_same_origin.html.mock-http-headers b/content/test/data/service_worker/csp_sandboxed_allow_same_origin.html.mock-http-headers
new file mode 100644
index 0000000..23ec234b
--- /dev/null
+++ b/content/test/data/service_worker/csp_sandboxed_allow_same_origin.html.mock-http-headers
@@ -0,0 +1,4 @@
+HTTP/1.1 200 OK
+Content-Type: text/html
+Content-Security-Policy: sandbox allow-scripts allow-same-origin
+