CVE-2026-87448
Overview
Background
- `DevToolsManagerDelegate`
- The embedder-facing interface that DevTools protocol handlers query for browser contexts (
GetBrowserContexts(),GetDefaultBrowserContext(), and nowGetBrowserContext(context_id)). - `BrowserContext` (OTR profile)
- A browser context created on demand via
Target.createBrowserContext; DevTools can request its automatic teardown viadisposeOnDetach. - `disposeOnDetach` / `dispose_on_detach_context_ids_`
- A set of context ids that must be destroyed when the controlling DevTools session detaches.
- `base::WeakPtr`
- A non-owning pointer that becomes null when its target is destroyed, so a caller can detect a freed object instead of dereferencing a dangling raw pointer.
Root Cause Analysis
GetBrowserContexts() returned a std::vector<content::BrowserContext*> of raw pointers, and the DevTools target_handler.cc detach path (the dispose_on_detach_context_ids_ loop; its enclosing method name is not shown in the @@ hunk) iterated that vector while calling delegate->DisposeBrowserContext(context, ...) on entries inside the loop. Disposing a browser context synchronously destroys the underlying OTR profile, so continuing to iterate and dereference the raw pointers held by the snapshot vector (e.g. context->UniqueId()) touched freed memory — a use-after-free reachable when many disposeOnDetach contexts are torn down together on detach.
The patch removes this pattern: it moves the id set into a local (auto context_ids = std::move(dispose_on_detach_context_ids_)), clears the member, then looks each context up freshly by id with the new GetBrowserContext(id) and only disposes it if the lookup is non-null. In parallel, GetBrowserContexts() was changed to return std::vector<base::WeakPtr<BrowserContext>> so remaining callers must null-check before use, and browser_handler.cc/target_handler.cc enumeration sites were rewritten to skip freed (null) weak pointers or to resolve a single context by id rather than scanning a raw-pointer list. This works because contexts are no longer dereferenced from a stale snapshot across a destroying call; each access is re-validated against live state.
BrowserContext* while destroying those very contexts inside the loop, leaving dangling pointers; the fix replaces per-iteration pointer retention with id-based re-lookup (GetBrowserContext) and makes GetBrowserContexts() hand out WeakPtrs that callers must null-check.Attack Path
- Attach a browser-level DevTools session
Via the DevTools/CDP protocol (e.g.
Target.attachToBrowserTarget, as the regression test does), obtain access to theTargetdomain. - Create many disposeOnDetach contexts
Call
Target.createBrowserContext({disposeOnDetach: true})repeatedly (the test creates 64+), populatingdispose_on_detach_context_ids_. - Trigger mass disposal on detach
Detach the session; the detach handler iterates
GetBrowserContexts()and callsDisposeBrowserContext()on each matching context, destroying OTR profiles mid-iteration. - Dangling dereference
Subsequent loop iterations dereference now-freed
BrowserContext*pointers (context->UniqueId()), producing the use-after-free in the browser process. - Exploitation
Turning this UAF into a controlled read/write or code execution is not demonstrated by the patch (
inferred).
Impact Assessment
disposeOnDetach contexts. The advisory classifies this as a use-after-free in DevTools; no exploited-in-the-wild status is stated. The precondition is access to the DevTools browser target (remote debugging or an in-page context granted the protocol via exposeDevToolsProtocol, as in the test); any escalation of the UAF to memory corruption or code execution is (inferred) and not shown by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
GetBrowserContextschrome/browser/devtools/chrome_devtools_manager_delegate.cc |
modified | |
GetBrowserContextschrome/browser/devtools/devtools_browser_context_manager.cc |
modified | |
ifcontent/browser/devtools/protocol/browser_handler.cc |
modified | |
forcontent/browser/devtools/protocol/target_handler.cc |
modified | |
ifcontent/browser/devtools/protocol/target_handler.cc |
modified | |
GetBrowserContextscontent/public/browser/devtools_manager_delegate.cc |
modified | |
GetBrowserContextsheadless/lib/browser/headless_devtools_manager_delegate.cc |
modified | |
GetDefaultBrowserContextheadless/lib/browser/headless_devtools_manager_delegate.cc |
modified | |
ifheadless/lib/browser/headless_devtools_manager_delegate.cc |
modified |
Files Changed
components/headless/test/data/protocol/shared/get-browser-contexts.jschrome/browser/devtools/chrome_devtools_manager_delegate.hchrome/browser/devtools/devtools_browser_context_manager.ccheadless/lib/browser/headless_devtools_manager_delegate.hheadless/lib/browser/headless_devtools_manager_delegate.ccheadless/test/data/protocol/sanity/context-disposal.jschrome/browser/headless/test/headless_mode_protocol_browsertest.ccheadless/test/headless_protocol_browsertest.ccheadless/test/data/protocol/sanity/context-disposal-expected.txtcontent/browser/devtools/protocol/browser_handler.ccchrome/browser/devtools/devtools_browser_context_manager.hcomponents/headless/test/data/protocol/shared/get-browser-contexts-expected.txtcontent/public/browser/devtools_manager_delegate.hcontent/public/browser/devtools_manager_delegate.ccchrome/browser/devtools/chrome_devtools_manager_delegate.ccfuchsia_web/webengine/browser/web_engine_content_browser_client.cccontent/browser/devtools/protocol/target_handler.cc
Audit Directions
- Destroy-while-iterating over container snapshotsLook for loops that hold raw pointers from a getter and call a destroying method (
Dispose*,Remove*,Close*) on elements inside the loop, where the underlying object is freed before later dereferences. - Raw-pointer getters for lifetime-fragile objectsAudit APIs returning
std::vector<T*>of profile/context/host objects and prefer id-based re-lookup orWeakPtrreturns, checking that all callers null-check before use. - DevTools protocol teardown pathsReview
disposeOnDetach, detach, and shutdown handlers in DevTools/CDP handlers for re-entrancy where disposing one object mutates the collection or invalidates pointers still referenced later in the same operation.
Patch
From 7aafca3e8c40a59540cd2bbc4dc7af446024d0a8 Mon Sep 17 00:00:00 2001 From: Andrey Kosyakov <[email protected]> Date: Tue, 04 Aug 2026 14:36:33 -0700 Subject: [PATCH] Rework browser context getters of DevToolsManagerDelegate - introduce GetBrowserContext(contextId), use it in most places we were iterating list of browser contexts to find a context; - make sure GetBrowserContexts() returns WeakPtrs, not raw ptrs, just to safeguard possible future usages; - add a test for the previously present UaF when iterating browser contexts while destroying them. Fixed: 516965176 Change-Id: I346c0f74b8024218151827d966f140e9f5ee92d6 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8145402 Auto-Submit: Andrey Kosyakov <[email protected]> Reviewed-by: Dmitry Gozman <[email protected]> Commit-Queue: Andrey Kosyakov <[email protected]> Reviewed-by: Sergey Ulanov <[email protected]> Reviewed-by: Alex Rudenko <[email protected]> Cr-Commit-Position: refs/heads/main@{#1673682} --- diff --git a/chrome/browser/devtools/chrome_devtools_manager_delegate.cc b/chrome/browser/devtools/chrome_devtools_manager_delegate.cc index c9f49cd..3e7c8a2 100644 --- a/chrome/browser/devtools/chrome_devtools_manager_delegate.cc +++ b/chrome/browser/devtools/chrome_devtools_manager_delegate.cc @@ -446,7 +446,7 @@ params.navigated_or_inserted_contents); } -std::vector<content::BrowserContext*> +std::vector<base::WeakPtr<content::BrowserContext>> ChromeDevToolsManagerDelegate::GetBrowserContexts() { return DevToolsBrowserContextManager::GetInstance().GetBrowserContexts(); } @@ -457,6 +457,12 @@ .GetDefaultBrowserContext(); } +content::BrowserContext* ChromeDevToolsManagerDelegate::GetBrowserContext( + const std::string& context_id) { + return DevToolsBrowserContextManager::GetInstance().GetProfileById( + context_id); +} + content::BrowserContext* ChromeDevToolsManagerDelegate::CreateBrowserContext() { return DevToolsBrowserContextManager::GetInstance().CreateBrowserContext(); } diff --git a/chrome/browser/devtools/chrome_devtools_manager_delegate.h b/chrome/browser/devtools/chrome_devtools_manager_delegate.h index a48b3a9eb..5a1fb50 100644 --- a/chrome/browser/devtools/chrome_devtools_manager_delegate.h +++ b/chrome/browser/devtools/chrome_devtools_manager_delegate.h @@ -46,8 +46,11 @@ // Resets |device_manager_|. void ResetAndroidDeviceManagerForTesting(); - std::vector<content::BrowserContext*> GetBrowserContexts() override; + std::vector<base::WeakPtr<content::BrowserContext>> GetBrowserContexts() + override; content::BrowserContext* GetDefaultBrowserContext() override; + content::BrowserContext* GetBrowserContext( + const std::string& context_id) override; // Closes browser soon, not in the current task. static void CloseBrowserSoon(); diff --git a/chrome/browser/devtools/devtools_browser_context_manager.cc b/chrome/browser/devtools/devtools_browser_context_manager.cc index fd8128c..7b3a276 100644 --- a/chrome/browser/devtools/devtools_browser_context_manager.cc +++ b/chrome/browser/devtools/devtools_browser_context_manager.cc @@ -68,11 +68,11 @@ return otr_profile; } -std::vector<content::BrowserContext*> +std::vector<base::WeakPtr<content::BrowserContext>> DevToolsBrowserContextManager::GetBrowserContexts() { - std::vector<content::BrowserContext*> result; + std::vector<base::WeakPtr<content::BrowserContext>> result; for (const auto& profile_pair : otr_profiles_) - result.push_back(profile_pair.second); + result.push_back(profile_pair.second->GetWeakPtr()); return result; } diff --git a/chrome/browser/devtools/devtools_browser_context_manager.h b/chrome/browser/devtools/devtools_browser_context_manager.h index cd2383a..1898b7ee 100644 --- a/chrome/browser/devtools/devtools_browser_context_manager.h +++ b/chrome/browser/devtools/devtools_browser_context_manager.h @@ -29,7 +29,7 @@ const DevToolsBrowserContextManager&) = delete; Profile* GetProfileById(const std::string& browser_context_id); - std::vector<content::BrowserContext*> GetBrowserContexts(); + std::vector<base::WeakPtr<content::BrowserContext>> GetBrowserContexts(); content::BrowserContext* GetDefaultBrowserContext(); content::BrowserContext* CreateBrowserContext(); void DisposeBrowserContext( diff --git a/content/browser/devtools/protocol/browser_handler.cc b/content/browser/devtools/protocol/browser_handler.cc index 41e483e..d027adc1 100644 --- a/content/browser/devtools/protocol/browser_handler.cc +++ b/content/browser/devtools/protocol/browser_handler.cc @@ -370,21 +370,18 @@ "Browser context management is not supported."); if (!browser_context_id.has_value()) { *browser_context = delegate->GetDefaultBrowserContext(); - if (*browser_context == nullptr) + if (!*browser_context) { return Response::ServerError( "Browser context management is not supported."); + } return Response::Success(); } - - std::string context_id = browser_context_id.value(); - for (auto* context : delegate->GetBrowserContexts()) { - if (context->UniqueId() == context_id) { - *browser_context = context; - return Response::Success(); - } + *browser_context = delegate->GetBrowserContext(browser_context_id.value()); + if (!*browser_context) { + return Response::InvalidParams("Failed to find browser context for id " + + browser_context_id.value()); } - return Response::InvalidParams("Failed to find browser context for id " + - context_id); + return Response::Success(); } // static diff --git a/content/browser/devtools/protocol/target_handler.cc b/content/browser/devtools/protocol/target_handler.cc index f20a1a8..5d6b074 100644 --- a/content/browser/devtools/protocol/target_handler.cc +++ b/content/browser/devtools/protocol/target_handler.cc @@ -804,14 +804,15 @@ } if (dispose_on_detach_context_ids_.size()) { - for (auto* context : delegate->GetBrowserContexts()) { - if (!dispose_on_detach_context_ids_.contains(context->UniqueId())) { - continue; - } - delegate->DisposeBrowserContext(context, base::DoNothing()); - } + auto context_ids = std::move(dispose_on_detach_context_ids_); dispose_on_detach_context_ids_.clear(); + for (const std::string& id : context_ids) { + if (auto* context = delegate->GetBrowserContext(id)) { + delegate->DisposeBrowserContext(context, base::DoNothing()); + } + } } + contexts_with_overridden_proxy_.clear(); return Response::Success(); } @@ -1611,16 +1612,19 @@ return Response::ServerError( "Browser context management is not supported."); } - std::vector<BrowserContext*> contexts = delegate->GetBrowserContexts(); + std::vector<base::WeakPtr<BrowserContext>> contexts = + delegate->GetBrowserContexts(); *browser_context_ids = std::make_unique<protocol::Array<protocol::String>>(); - for (auto* context : contexts) { - (*browser_context_ids)->emplace_back(context->UniqueId()); + for (const auto& context : contexts) { + if (context) { + (*browser_context_ids)->emplace_back(context->UniqueId()); + } } - BrowserContext* default_context = delegate->GetDefaultBrowserContext(); - if (default_context) { + if (BrowserContext* default_context = delegate->GetDefaultBrowserContext()) { *default_browser_context_id = default_context->UniqueId(); } + return Response::Success(); } @@ -1638,17 +1642,21 @@ Response::ServerError("Browser context management is not supported.")); return; } - std::vector<BrowserContext*> contexts = delegate->GetBrowserContexts(); - auto context_it = - std::ranges::find(contexts, context_id, &BrowserContext::UniqueId); - if (context_it == contexts.end()) { + BrowserContext* context = delegate->GetBrowserContext(context_id); + if (!context) { callback->sendFailure( Response::ServerError("Failed to find context with id " + context_id)); return; } + if (context == delegate->GetDefaultBrowserContext()) { + callback->sendFailure( + Response::ServerError("Cannot dispose default browser context.")); + return; + } dispose_on_detach_context_ids_.erase(context_id); delegate->DisposeBrowserContext( - *context_it, + context, + base::BindOnce( [](std::unique_ptr<DisposeBrowserContextCallback> callback, bool success, const std::string& error) { diff --git a/content/public/browser/devtools_manager_delegate.cc b/content/public/browser/devtools_manager_delegate.cc index 8257dd01..db9be765 100644 --- a/content/public/browser/devtools_manager_delegate.cc +++ b/content/public/browser/devtools_manager_delegate.cc @@ -3,7 +3,9 @@ // found in the LICENSE file. #include "content/public/browser/devtools_manager_delegate.h" + #include "base/values.h" +#include "content/public/browser/browser_context.h" #include "content/public/browser/devtools_agent_host.h" namespace content { @@ -74,14 +76,20 @@ return nullptr; } -std::vector<BrowserContext*> DevToolsManagerDelegate::GetBrowserContexts() { - return std::vector<BrowserContext*>(); +std::vector<base::WeakPtr<BrowserContext>> +DevToolsManagerDelegate::GetBrowserContexts() { + return std::vector<base::WeakPtr<BrowserContext>>(); } BrowserContext* DevToolsManagerDelegate::GetDefaultBrowserContext() { return nullptr; } +BrowserContext* DevToolsManagerDelegate::GetBrowserContext( + const std::string& context_id) { + return nullptr; +} + BrowserContext* DevToolsManagerDelegate::CreateBrowserContext() { return nullptr; } diff --git a/content/public/browser/devtools_manager_delegate.h b/content/public/browser/devtools_manager_delegate.h index f08399e..069a7b4 100644 --- a/content/public/browser/devtools_manager_delegate.h +++ b/content/public/browser/devtools_manager_delegate.h @@ -11,6 +11,7 @@ #include "base/containers/span.h" #include "base/memory/scoped_refptr.h" +#include "base/memory/weak_ptr.h" #include "content/common/content_export.h" #include "content/public/browser/devtools_agent_host.h" #include "url/gurl.h" @@ -112,7 +113,8 @@ enum TargetType { kFrame, kTab }; // Returns all targets embedder would like to report as debuggable remotely. - virtual DevToolsAgentHost::List RemoteDebuggingTargets(TargetType target_type); + virtual DevToolsAgentHost::List RemoteDebuggingTargets( + TargetType target_type); // Creates new inspectable target given the |url|. // |new_window| is currently only used on Android - Desktop platforms handle @@ -122,12 +124,19 @@ virtual scoped_refptr<DevToolsAgentHost> CreateNewTarget(const GURL& url, TargetType target_type, bool new_window); - // Get all live browser contexts created by CreateBrowserContext() method. - virtual std::vector<BrowserContext*> GetBrowserContexts(); + // Returns the list of browser contexts exposed to browser automation. This + // MUST include contexts previously created via CreateBrowserContext(), but + // embedder may choose to omit some contexts that were not created via + // DevTools. In particular, default contexts are not exposed by some + // embedders at the moment, but this may eventually be revisited. + virtual std::vector<base::WeakPtr<BrowserContext>> GetBrowserContexts(); // Get default browser context. May return null if not supported. virtual BrowserContext* GetDefaultBrowserContext(); + // Get browser context by its unique id. May return null if not found. + virtual BrowserContext* GetBrowserContext(const std::string& context_id); + // Create new browser context. May return null if not supported or not // possible. Delegate must take ownership of the created browser context, and // may destroy it at will. diff --git a/fuchsia_web/webengine/browser/web_engine_content_browser_client.cc b/fuchsia_web/webengine/browser/web_engine_content_browser_client.cc index ddd6ade..868d979 100644 --- a/fuchsia_web/webengine/browser/web_engine_content_browser_client.cc +++ b/fuchsia_web/webengine/browser/web_engine_content_browser_client.cc @@ -94,13 +94,31 @@ DevToolsManagerDelegate& operator=(const DevToolsManagerDelegate&) = delete; // content::DevToolsManagerDelegate implementation. - std::vector<content::BrowserContext*> GetBrowserContexts() override { - return main_parts_->browser_contexts(); + std::vector<base::WeakPtr<content::BrowserContext>> GetBrowserContexts() + override { + std::vector<base::WeakPtr<content::BrowserContext>> contexts; + for (auto* context : main_parts_->browser_contexts()) { + contexts.push_back(context->GetWeakPtr()); + } + return contexts; } + content::BrowserContext* GetDefaultBrowserContext() override { - std::vector<content::BrowserContext*> contexts = GetBrowserContexts(); + std::vector<content::BrowserContext*> contexts = + main_parts_->browser_contexts(); return contexts.empty() ? nullptr : contexts.front(); } + + content::BrowserContext* GetBrowserContext( + const std::string& context_id) override { + for (auto* context : main_parts_->browser_contexts()) { + if (context->UniqueId() == context_id) { + return context; + } + } + return nullptr; + } + content::DevToolsAgentHost::List RemoteDebuggingTargets( DevToolsManagerDelegate::TargetType target_type) override { LOG_IF(WARNING, target_type != DevToolsManagerDelegate::kFrame) diff --git a/headless/lib/browser/headless_devtools_manager_delegate.cc b/headless/lib/browser/headless_devtools_manager_delegate.cc index fabd8dfd..271340f6 100644 --- a/headless/lib/browser/headless_devtools_manager_delegate.cc +++ b/headless/lib/browser/headless_devtools_manager_delegate.cc @@ -69,22 +69,35 @@ sessions_.erase(channel); } -std::vector<content::BrowserContext*> +std::vector<base::WeakPtr<content::BrowserContext>> HeadlessDevToolsManagerDelegate::GetBrowserContexts() { - std::vector<content::BrowserContext*> contexts; + std::vector<base::WeakPtr<content::BrowserContext>> contexts; if (!browser_) return contexts; for (auto* context : browser_->GetAllBrowserContexts()) { - if (context != browser_->GetDefaultBrowserContext()) - contexts.push_back(HeadlessBrowserContextImpl::From(context)); + if (context != browser_->GetDefaultBrowserContext()) { + contexts.push_back( + HeadlessBrowserContextImpl::From(context)->GetWeakPtr()); + } } return contexts; } + content::BrowserContext* HeadlessDevToolsManagerDelegate::GetDefaultBrowserContext() { - return browser_ ? HeadlessBrowserContextImpl::From( - browser_->GetDefaultBrowserContext()) - : nullptr; + if (!browser_) { + return nullptr; + } + return HeadlessBrowserContextImpl::From(browser_->GetDefaultBrowserContext()); +} + +content::BrowserContext* HeadlessDevToolsManagerDelegate::GetBrowserContext( + const std::string& context_id) { + if (!browser_) { + return nullptr; + } + return HeadlessBrowserContextImpl::From( + browser_->GetBrowserContextForId(context_id)); } content::BrowserContext* diff --git a/headless/lib/browser/headless_devtools_manager_delegate.h b/headless/lib/browser/headless_devtools_manager_delegate.h index 7b079aa..48dc01bd 100644 --- a/headless/lib/browser/headless_devtools_manager_delegate.h +++ b/headless/lib/browser/headless_devtools_manager_delegate.h @@ -42,9 +42,13 @@ void ClientDetached( content::DevToolsAgentHostClientChannel* channel) override; - std::vector<content::BrowserContext*> GetBrowserContexts() override; + std::vector<base::WeakPtr<content::BrowserContext>> GetBrowserContexts() + override; content::BrowserContext* GetDefaultBrowserContext() override; + content::BrowserContext* GetBrowserContext( + const std::string& context_id) override; content::BrowserContext* CreateBrowserContext() override; + void DisposeBrowserContext(content::BrowserContext* context, DisposeCallback callback) override; diff --git a/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc b/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc index 5b85610..48249af 100644 --- a/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc +++ b/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc @@ -523,4 +523,7 @@ HEADLESS_MODE_PROTOCOL_TEST(NormalWindowHasOpener, "shared/normal-window-has-opener.js") ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc b/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc
index 5b85610..48249af 100644
--- a/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc
+++ b/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc
@@ -523,4 +523,7 @@
HEADLESS_MODE_PROTOCOL_TEST(NormalWindowHasOpener,
"shared/normal-window-has-opener.js")
+HEADLESS_MODE_PROTOCOL_TEST(GetBrowserContexts,
+ "shared/get-browser-contexts.js")
+
} // namespace headless
diff --git a/components/headless/test/data/protocol/shared/get-browser-contexts-expected.txt b/components/headless/test/data/protocol/shared/get-browser-contexts-expected.txt
new file mode 100644
index 0000000..2784d0c
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/get-browser-contexts-expected.txt
@@ -0,0 +1,11 @@
+Tests Target.getBrowserContexts
+Initial browserContextIds count: 0
+Has defaultBrowserContextId: true
+browserContextIds count after create: 1
+Contains created context: true
+browserContextIds count after dispose: 0
+Attempting to dispose default browser context...
+Error disposing default context: {
+ code : -32000
+ message : Cannot dispose default browser context.
+}
\ No newline at end of file
diff --git a/components/headless/test/data/protocol/shared/get-browser-contexts.js b/components/headless/test/data/protocol/shared/get-browser-contexts.js
new file mode 100644
index 0000000..d6e337a4
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/get-browser-contexts.js
@@ -0,0 +1,44 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+(async function(/** @type {import('test_runner').TestRunner} */ testRunner) {
+ await testRunner.startBlank('Tests Target.getBrowserContexts');
+
+ const browserSession = await testRunner.attachFullBrowserSession();
+ const bp = browserSession.protocol;
+
+ const {result: res1} = await bp.Target.getBrowserContexts();
+ testRunner.log(
+ `Initial browserContextIds count: ${res1.browserContextIds.length}`);
+ testRunner.log(`Has defaultBrowserContextId: ${
+ typeof res1.defaultBrowserContextId === 'string' &&
+ res1.defaultBrowserContextId.length > 0}`);
+
+ const {result: {browserContextId}} = await bp.Target.createBrowserContext();
+
+ const {result: res2} = await bp.Target.getBrowserContexts();
+ testRunner.log(
+ `browserContextIds count after create: ${res2.browserContextIds.length}`);
+ testRunner.log(`Contains created context: ${
+ res2.browserContextIds.includes(browserContextId)}`);
+
+ await bp.Target.disposeBrowserContext({browserContextId});
+
+ const {result: res3} = await bp.Target.getBrowserContexts();
+ testRunner.log(`browserContextIds count after dispose: ${
+ res3.browserContextIds.length}`);
+
+ testRunner.log('Attempting to dispose default browser context...');
+ const disposeDefaultRes = await bp.Target.disposeBrowserContext({
+ browserContextId: res1.defaultBrowserContextId,
+ });
+ const error = disposeDefaultRes.error;
+ if (error && error.message) {
+ error.message = error.message.replace(
+ res1.defaultBrowserContextId, '<defaultBrowserContextId>');
+ }
+ testRunner.log(error, 'Error disposing default context:');
+
+ testRunner.completeTest();
+});
diff --git a/headless/test/data/protocol/sanity/context-disposal-expected.txt b/headless/test/data/protocol/sanity/context-disposal-expected.txt
new file mode 100644
index 0000000..11bbc95b
--- /dev/null
+++ b/headless/test/data/protocol/sanity/context-disposal-expected.txt
@@ -0,0 +1,4 @@
+Tests context disposal on detach from page exposing DevTools protocol.
+Browser contexts count before detach: 65
+Detaching browser session...
+Browser contexts count after detach: 0
diff --git a/headless/test/data/protocol/sanity/context-disposal.js b/headless/test/data/protocol/sanity/context-disposal.js
new file mode 100644
index 0000000..b924333
--- /dev/null
+++ b/headless/test/data/protocol/sanity/context-disposal.js
@@ -0,0 +1,62 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+(async function(/** @type {import('test_runner').TestRunner} */ testRunner) {
+ testRunner.log(
+ 'Tests context disposal on detach from page exposing DevTools protocol.');
+
+ // Create extra browser session.
+ const {result: {sessionId: browserSessionId}} =
+ await testRunner.browserP().Target.attachToBrowserTarget({});
+ const browserSession = testRunner.createSessionFor(browserSessionId);
+ const bp = browserSession.protocol;
+
+ const {result: {browserContextId}} =
+ await bp.Target.createBrowserContext({disposeOnDetach: true});
+
+ const {result: {targetId}} = await bp.Target.createTarget({
+ browserContextId,
+ url: 'about:blank',
+ });
+ const {result: {sessionId}} =
+ await bp.Target.attachToTarget({targetId, flatten: true});
+ const session = testRunner.createSessionFor(sessionId);
+
+ await bp.Target.exposeDevToolsProtocol({targetId, bindingName: 'cdp'});
+
+ const testFrameworkURL =
+ new URL(
+ '/resources/inspector-protocol-test-subtarget.html', location.href)
+ .href;
+ await session.navigate(testFrameworkURL);
+
+ await session.evaluateAsync(async () => {
+ const testRunner = new TestRunner(
+ '', '', DevToolsAPI._log, () => {}, DevToolsAPI._fetch, {});
+ const bp = testRunner.browserP();
+ const promises = [];
+ for (let i = 0; i < 64; i++) {
+ promises.push(bp.Target.createBrowserContext({disposeOnDetach: true}));
+ }
+ await Promise.all(promises);
+ });
+
+ const contextsBefore =
+ (await testRunner.browserP().Target.getBrowserContexts())
+ .result.browserContextIds;
+ testRunner.log(
+ `Browser contexts count before detach: ${contextsBefore.length}`);
+
+ testRunner.log('Detaching browser session...');
+ await testRunner.browserP().Target.detachFromTarget(
+ {sessionId: browserSessionId});
+
+ const contextsAfter =
+ (await testRunner.browserP().Target.getBrowserContexts())
+ .result.browserContextIds;
+ testRunner.log(
+ `Browser contexts count after detach: ${contextsAfter.length}\n`);
+
+ testRunner.completeTest();
+});
diff --git a/headless/test/headless_protocol_browsertest.cc b/headless/test/headless_protocol_browsertest.cc
index d99612a..e4b74e3 100644
--- a/headless/test/headless_protocol_browsertest.cc
+++ b/headless/test/headless_protocol_browsertest.cc
@@ -728,6 +728,8 @@
HEADLESS_PROTOCOL_TEST(BlockNewWebContents, "sanity/block-new-web-contents.js")
+HEADLESS_PROTOCOL_TEST(ContextDisposal, "sanity/context-disposal.js")
+
HEADLESS_PROTOCOL_TEST(ScreenRotationSecondaryScreen,
"sanity/screen-rotation-secondary-screen.js")
@@ -848,4 +850,6 @@
HEADLESS_PROTOCOL_TEST(GetCanvasContextWebGL,
"shared/get-canvas-context-webgl.js")
+HEADLESS_PROTOCOL_TEST(GetBrowserContexts, "shared/get-browser-contexts.js")
+
} // namespace headless