CVE-2026-19169
Overview
Background
- Contextual Tasks
- a Chrome browser feature that intercepts navigations to Google AI pages and shows the conversation inside the
chrome://contextual-tasksWebUI. That WebUI embeds the AI page in a webview and is managed byContextualTasksUiServiceandContextualTasksUI. - `chrome_host` (`kChromeHostParam`)
- a query parameter intended for developer and testing host overrides. It lets the AI page be served from an alternate host, and only hosts accepted by
ContextualTasksUiService::IsTrustedHostshould be honoured. Those are loopback addresses andcorp.google.com/googlers.comsubdomains. - `loadTimeData`
- strings the browser process injects into a WebUI page when it loads (here via
source->AddString). It is a browser-validated channel, unlike the page’s ownwindow.locationquery string.
Root Cause Analysis
Several places read chrome_host with net::GetValueForKeyInQuery but did not always pass the value through IsTrustedHost. In contextual_tasks_ui_service.cc, ContextualTasksUiService::GetHostForTask (not named in the hunk, but it is the enclosing function in the source; it reads task_id_to_creation_url_) returned whatever chrome_host appeared in the intercepted creation URL. Its result feeds the chrome://contextual-tasks?chrome_host=... URL built for the task by GetContextualTaskUrlForTask, which is not changed in the diff. ContextualTasksUiService::GetAiUrlFromWebUIUrl likewise applied any non-empty chrome_host found in the merged URL with replacements.SetHostStr(host_value), without a trust check. On the renderer side, app.ts took chrome_host straight from window.location.href and preferred it over the browser-validated loadTimeData value. That made the C++ trust check that already existed in contextual_tasks_ui.cc ineffective.
The fix adds ContextualTasksUiService::GetHostFromUrl, which returns a host only if it is present and IsTrustedHost accepts it, and routes every consumer through that helper. It also makes the WebUI take the host only from loadTimeData, so an untrusted host is never used to rewrite AI-page URLs.
chrome_host parameter existed but was applied inconsistently. Some C++ readers skipped it, and the WebUI renderer ignored the browser’s validated value in favour of its own URL. The fix puts the validation in a single helper (GetHostFromUrl) and makes loadTimeData the only source the WebUI trusts.Attack Path
- Craft an AI-page link
The attacker gets the user to open a Google AI search URL that carries a
chrome_hostparameter, as in the regression test’shttps://google.com/search?udm=50&q=test&chrome_host=malicious.example.com. - Interception records the URL
OnNavigationToAiPageInterceptedcreates a task and stores the full URL as the task’s creation URL,chrome_hostincluded. - Unvalidated host propagates
Before the patch,
GetHostForTaskreturnedmalicious.example.comunchecked. The regression testGetContextualTaskUrlForTask_WithUntrustedHostshows that this value then appeared aschrome_hostin the URL returned byGetContextualTaskUrlForTask, thechrome://contextual-tasksURL for the task. - WebUI adopts the host
Pre-patch
app.tsreadchrome_hostfromwindow.location, so the WebUI used the attacker’s host for the embedded AI page. In the app’s source (outside the diff),host_is used to rewrite the host of the embedded thread’s requests, so the AI thread loads from the attacker’s origin instead of Google (inferred).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/contextual_tasks/contextual_tasks_ui_service.cc |
modified | |
TEST_Fchrome/browser/contextual_tasks/contextual_tasks_ui_service_unittest.cc |
modified |
Files Changed
chrome/browser/contextual_tasks/contextual_tasks_ui.ccchrome/browser/contextual_tasks/contextual_tasks_ui_service.ccchrome/browser/contextual_tasks/contextual_tasks_ui_service_unittest.ccchrome/browser/resources/contextual_tasks/app.tschrome/test/data/webui/contextual_tasks/app_test.tschrome/browser/contextual_tasks/contextual_tasks_ui_service.h
Audit Directions
- Debug/override query parametersSearch
chrome/browserand WebUI resources for parameters that override hosts, endpoints, or origins (*_host,*_url,server=) read withGetValueForKeyInQueryorURLSearchParams. Check that every reader, not just one, applies the allowlist. - WebUI trusting `window.location` over `loadTimeData`Look for WebUI TypeScript that parses its own URL for security-relevant values (hosts, task IDs, modes). Those values can be set by whoever constructs the
chrome://URL and should come from browser-validatedloadTimeDataor Mojo instead. - Stored untrusted URLs reused laterMaps such as
task_id_to_creation_url_keep URLs that came from web navigations. Check that parameters extracted from them later are validated at the point of use, not assumed to be safe because they were stored by the browser.
Patch
From 22ceabf10d935d525c5daceb850131b64ab28f2b Mon Sep 17 00:00:00 2001 From: Duncan Mercer <[email protected]> Date: Wed, 22 Jul 2026 16:59:11 -0700 Subject: [PATCH] [Contextual Tasks] Fix unvalidated chrome_host param Validate the chrome_host query parameter using ContextualTasksUiService::IsTrustedHost before setting or using it. Also, update the WebUI renderer to rely on loadTimeData populated by C++ instead of directly parsing the WebUI window.location URL. Fixed: 537390933 Change-Id: Ic8d4cf5db06bf8a08f5d050efb0eecf9eefcc15c Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8135340 Commit-Queue: Duncan Mercer <[email protected]> Auto-Submit: Duncan Mercer <[email protected]> Reviewed-by: Bryan Nguyen <[email protected]> Commit-Queue: Bryan Nguyen <[email protected]> Cr-Commit-Position: refs/heads/main@{#1666711} --- diff --git a/chrome/browser/contextual_tasks/contextual_tasks_ui.cc b/chrome/browser/contextual_tasks/contextual_tasks_ui.cc index e118b27..803d16e 100644 --- a/chrome/browser/contextual_tasks/contextual_tasks_ui.cc +++ b/chrome/browser/contextual_tasks/contextual_tasks_ui.cc @@ -1291,12 +1291,10 @@ task_id = base::Uuid::ParseLowercase(task_id_str); } - std::string host_value; - if (net::GetValueForKeyInQuery(url, contextual_tasks::kChromeHostParam, - &host_value)) { - if (contextual_tasks::ContextualTasksUiService::IsTrustedHost(host_value)) { - source->AddString(contextual_tasks::kChromeHostParam, host_value); - } + std::optional<std::string> host_value = + contextual_tasks::ContextualTasksUiService::GetHostFromUrl(url); + if (host_value.has_value()) { + source->AddString(contextual_tasks::kChromeHostParam, *host_value); } std::optional<GURL> task_creation_url = diff --git a/chrome/browser/contextual_tasks/contextual_tasks_ui_service.cc b/chrome/browser/contextual_tasks/contextual_tasks_ui_service.cc index 60a39ca..d1e021bd 100644 --- a/chrome/browser/contextual_tasks/contextual_tasks_ui_service.cc +++ b/chrome/browser/contextual_tasks/contextual_tasks_ui_service.cc @@ -2191,9 +2191,9 @@ const base::Uuid& task_id) { auto it = task_id_to_creation_url_.find(task_id); if (it != task_id_to_creation_url_.end()) { - std::string host; - if (net::GetValueForKeyInQuery(it->second, kChromeHostParam, &host)) { - return host; + std::optional<std::string> host = GetHostFromUrl(it->second); + if (host.has_value()) { + return *host; } std::string_view creation_host = it->second.host(); @@ -2270,6 +2270,16 @@ return false; } +std::optional<std::string> ContextualTasksUiService::GetHostFromUrl( + const GURL& url) { + std::string host; + if (net::GetValueForKeyInQuery(url, kChromeHostParam, &host) && + IsTrustedHost(host)) { + return host; + } + return std::nullopt; +} + void ContextualTasksUiService::SetInitialEntryPointForTask( const base::Uuid& task_id, omnibox::ChromeAimEntryPoint entry_point) { @@ -2787,30 +2797,28 @@ GURL ContextualTasksUiService::CopyParamsFromWebUIUrl(const GURL& base_url, const GURL& webui_url) { - std::string host_value; + std::optional<std::string> host_value = GetHostFromUrl(webui_url); GURL aim_url(base_url); // Extract host if present in WebUI URL and prepend it to make it // first. - if (net::GetValueForKeyInQuery(webui_url, kChromeHostParam, &host_value)) { - if (IsTrustedHost(host_value)) { - GURL::Replacements replacements; - std::string new_query = base::StrCat({kChromeHostParam, "=", host_value}); - replacements.SetQueryStr(new_query); - aim_url = base_url.ReplaceComponents(replacements); + if (host_value.has_value()) { + GURL::Replacements replacements; + std::string new_query = base::StrCat({kChromeHostParam, "=", *host_value}); + replacements.SetQueryStr(new_query); + aim_url = base_url.ReplaceComponents(replacements); - // The QueryIterator correctly iterates over duplicate keys, and - // GetUnescapedValue preserves their values. This ensures that duplicate - // parameters on base_url are not lost during the transfer. - net::QueryIterator base_it(base_url); - while (!base_it.IsAtEnd()) { - std::string key(base_it.GetKey()); - if (key != kChromeHostParam) { - aim_url = net::AppendQueryParameter(aim_url, key, - base_it.GetUnescapedValue()); - } - base_it.Advance(); + // The QueryIterator correctly iterates over duplicate keys, and + // GetUnescapedValue preserves their values. This ensures that duplicate + // parameters on base_url are not lost during the transfer. + net::QueryIterator base_it(base_url); + while (!base_it.IsAtEnd()) { + std::string key(base_it.GetKey()); + if (key != kChromeHostParam) { + aim_url = net::AppendQueryParameter(aim_url, key, + base_it.GetUnescapedValue()); } + base_it.Advance(); } } // Now add all other params from the WebUI URL. @@ -2840,13 +2848,10 @@ const GURL& webui_url) { GURL url = CopyParamsFromWebUIUrl(base_url, webui_url); - std::string host_value; - // If there is the chrome_host param in the URL, use it to set the host of - // the AI url. - if (net::GetValueForKeyInQuery(url, kChromeHostParam, &host_value) && - !host_value.empty()) { + std::optional<std::string> host_value = GetHostFromUrl(url); + if (host_value.has_value()) { GURL::Replacements replacements; - replacements.SetHostStr(host_value); + replacements.SetHostStr(*host_value); url = url.ReplaceComponents(replacements); } diff --git a/chrome/browser/contextual_tasks/contextual_tasks_ui_service.h b/chrome/browser/contextual_tasks/contextual_tasks_ui_service.h index 9dec7c8..7fe95f89 100644 --- a/chrome/browser/contextual_tasks/contextual_tasks_ui_service.h +++ b/chrome/browser/contextual_tasks/contextual_tasks_ui_service.h @@ -336,6 +336,9 @@ // Returns whether the provided host is trusted for overrides. static bool IsTrustedHost(const std::string& host); + // Returns the host parameter from the URL if present and trusted. + static std::optional<std::string> GetHostFromUrl(const GURL& url); + // Called when the Lens overlay is shown/hidden. No-op if the active UI is not // in the side panel since the Lens button is always hidden in a tab. virtual void OnLensOverlayStateChanged( diff --git a/chrome/browser/resources/contextual_tasks/app.ts b/chrome/browser/resources/contextual_tasks/app.ts index ff68f7e..d911bb0 100644 --- a/chrome/browser/resources/contextual_tasks/app.ts +++ b/chrome/browser/resources/contextual_tasks/app.ts @@ -461,8 +461,9 @@ // Record the WebUI URL in case one of the events below fires and changes // it. const webUiUrlOnLoad = new URL(window.location.href); - this.host_ = webUiUrlOnLoad.searchParams.get(CHROME_HOST_PARAM_KEY); - if (!this.host_ && loadTimeData.valueExists('chrome_host')) { + // The browser validates this value before exposing it through + // loadTimeData. Do not trust query parameters from the WebUI URL. + if (loadTimeData.valueExists('chrome_host')) { this.host_ = loadTimeData.getString('chrome_host'); } // Relying on C++ to provide the correct host via getUrlForTask diff --git a/chrome/browser/contextual_tasks/contextual_tasks_ui_service_unittest.cc b/chrome/browser/contextual_tasks/contextual_tasks_ui_service_unittest.cc index c04f84ce..f9dc5318 100644 --- a/chrome/browser/contextual_tasks/contextual_tasks_ui_service_unittest.cc +++ b/chrome/browser/contextual_tasks/contextual_tasks_ui_service_unittest.cc @@ -1881,6 +1881,42 @@ EXPECT_FALSE(net::GetValueForKeyInQuery(url, kChromeHostParam, &host_value)); } +TEST_F(ContextualTasksUiServiceTest, + GetContextualTaskUrlForTask_WithUntrustedHost) { + ContextualTasksUiService service( + profile_.get(), /*delegate=*/nullptr, contextual_tasks_service_.get(), + /*identity_manager=*/nullptr, aim_eligibility_service_.get(), + std::make_unique<ContextualTasksEligibilityManager>( + profile_->GetPrefs(), /*identity_manager=*/nullptr, + aim_eligibility_service_.get()), + /*cookie_synchronizer=*/nullptr); + base::Uuid task_id = base::Uuid::GenerateRandomV4(); + GURL intercepted_url( + "https://google.com/" + "search?udm=50&q=test&chrome_host=malicious.example.com"); + + auto web_contents = content::WebContentsTester::CreateTestWebContents( + profile_.get(), content::SiteInstance::Create(profile_.get())); + tabs::MockTabInterface tab; + ON_CALL(tab, GetContents).WillByDefault(Return(web_contents.get())); + base::WeakPtrFactory weak_factory(&tab); + + ContextualTask task(task_id); + EXPECT_CALL(*contextual_tasks_service_, CreateTaskFromUrl(intercepted_url)) + .WillOnce(Return(task)); + EXPECT_CALL(*contextual_tasks_service_, AssociateTabWithTask(_, _)) + .Times(testing::AnyNumber()); + + // Simulate the interception to populate the map. + service.OnNavigationToAiPageIntercepted(intercepted_url, + weak_factory.GetWeakPtr(), false); + + // Get the URL and verify it does NOT contain the untrusted host parameter. + GURL url = service.GetContextualTaskUrlForTask(task_id); + std::string host_value; + EXPECT_FALSE(net::GetValueForKeyInQuery(url, kChromeHostParam, &host_value)); +} + TEST_F(ContextualTasksUiServiceTest, SrpHomepage_Intercepted) { base::test::ScopedFeatureList scoped_feature_list; scoped_feature_list.InitAndDisableFeature(kAimTriggeredThreadLinks); @@ -2241,6 +2277,31 @@ ContextualTasksUiService::GetAiUrlFromWebUIUrl(base_url, webui_url)); } +TEST_F(ContextualTasksUiServiceTest, + GetAiUrlFromWebUIUrl_UntrustedHostOverride) { + GURL base_url("https://google.com/search"); + GURL webui_url( + "chrome://" + "contextual-tasks?param1=1&chrome_host=malicious.example.com"); + + EXPECT_EQ( + GURL("https://google.com/search?param1=1"), + ContextualTasksUiService::GetAiUrlFromWebUIUrl(base_url, webui_url)); +} + +TEST_F(ContextualTasksUiServiceTest, GetHostFromUrl) { + EXPECT_EQ("gws-prod.corp.google.com", + ContextualTasksUiService::GetHostFromUrl(GURL( + "https://google.com?chrome_host=gws-prod.corp.google.com"))); + EXPECT_EQ("127.0.0.1", ContextualTasksUiService::GetHostFromUrl( + GURL("https://google.com?chrome_host=127.0.0.1"))); + EXPECT_EQ(std::nullopt, + ContextualTasksUiService::GetHostFromUrl( + GURL("https://google.com?chrome_host=malicious.example.com"))); + EXPECT_EQ(std::nullopt, ContextualTasksUiService::GetHostFromUrl( + GURL("https://google.com?other_param=test"))); +} + // If the navigation is to sign the user out, ensure it opens outside the // webview to ensure the user is signed out of the main storage partition. TEST_F(ContextualTasksUiServiceTest, SignOutNavigation_OpenedInTab) { diff --git a/chrome/test/data/webui/contextual_tasks/app_test.ts b/chrome/test/data/webui/contextual_tasks/app_test.ts index 54ddda5..923edd8 100644 --- a/chrome/test/data/webui/contextual_tasks/app_test.ts +++ b/chrome/test/data/webui/contextual_tasks/app_test.ts @@ -134,27 +134,20 @@ assertFalse(appElement.hasAttribute('is-zero-state_')); }); - test('host initialized from URL parameter', async () => { - const forcedHost = 'test.host.com'; - window.history.replaceState({}, '', `?chrome_host=${forcedHost}`); + test( + 'host initialized from loadTimeData and URL parameter is ignored', + async () => { + const urlParamHost = 'untrusted.host.com'; + const loadTimeDataHost = 'trusted.host.com'; + window.history.replaceState({}, '', `?chrome_host=${urlParamHost}`); + loadTimeData.overrideValues({chrome_host: loadTimeDataHost}); - const appElement = document.createElement('contextual-tasks-app'); - document.body.appendChild(appElement); - await microtasksFinished(); + const appElement = document.createElement('contextual-tasks-app'); + document.body.appendChild(appElement); + await microtasksFinished(); - assertEquals(forcedHost, (appElement as any).host_); - }); - - test('host initialized from loadTimeData when URL param absent', async () => { - const forcedHost = 'default.host.com'; - loadTimeData.overrideValues({chrome_host: forcedHost}); - - const appElement = document.createElement('contextual-tasks-app'); - document.body.appendChild(appElement); - await microtasksFinished(); - - assertEquals(forcedHost, (appElement as any).host_); - }); + assertEquals(loadTimeDataHost, (appElement as any).host_); + }); test('restores thread if task param set', async () => {
Regression Test / PoC
diff --git a/chrome/browser/contextual_tasks/contextual_tasks_ui_service_unittest.cc b/chrome/browser/contextual_tasks/contextual_tasks_ui_service_unittest.cc
index c04f84ce..f9dc5318 100644
--- a/chrome/browser/contextual_tasks/contextual_tasks_ui_service_unittest.cc
+++ b/chrome/browser/contextual_tasks/contextual_tasks_ui_service_unittest.cc
@@ -1881,6 +1881,42 @@
EXPECT_FALSE(net::GetValueForKeyInQuery(url, kChromeHostParam, &host_value));
}
+TEST_F(ContextualTasksUiServiceTest,
+ GetContextualTaskUrlForTask_WithUntrustedHost) {
+ ContextualTasksUiService service(
+ profile_.get(), /*delegate=*/nullptr, contextual_tasks_service_.get(),
+ /*identity_manager=*/nullptr, aim_eligibility_service_.get(),
+ std::make_unique<ContextualTasksEligibilityManager>(
+ profile_->GetPrefs(), /*identity_manager=*/nullptr,
+ aim_eligibility_service_.get()),
+ /*cookie_synchronizer=*/nullptr);
+ base::Uuid task_id = base::Uuid::GenerateRandomV4();
+ GURL intercepted_url(
+ "https://google.com/"
+ "search?udm=50&q=test&chrome_host=malicious.example.com");
+
+ auto web_contents = content::WebContentsTester::CreateTestWebContents(
+ profile_.get(), content::SiteInstance::Create(profile_.get()));
+ tabs::MockTabInterface tab;
+ ON_CALL(tab, GetContents).WillByDefault(Return(web_contents.get()));
+ base::WeakPtrFactory weak_factory(&tab);
+
+ ContextualTask task(task_id);
+ EXPECT_CALL(*contextual_tasks_service_, CreateTaskFromUrl(intercepted_url))
+ .WillOnce(Return(task));
+ EXPECT_CALL(*contextual_tasks_service_, AssociateTabWithTask(_, _))
+ .Times(testing::AnyNumber());
+
+ // Simulate the interception to populate the map.
+ service.OnNavigationToAiPageIntercepted(intercepted_url,
+ weak_factory.GetWeakPtr(), false);
+
+ // Get the URL and verify it does NOT contain the untrusted host parameter.
+ GURL url = service.GetContextualTaskUrlForTask(task_id);
+ std::string host_value;
+ EXPECT_FALSE(net::GetValueForKeyInQuery(url, kChromeHostParam, &host_value));
+}
+
TEST_F(ContextualTasksUiServiceTest, SrpHomepage_Intercepted) {
base::test::ScopedFeatureList scoped_feature_list;
scoped_feature_list.InitAndDisableFeature(kAimTriggeredThreadLinks);
@@ -2241,6 +2277,31 @@
ContextualTasksUiService::GetAiUrlFromWebUIUrl(base_url, webui_url));
}
+TEST_F(ContextualTasksUiServiceTest,
+ GetAiUrlFromWebUIUrl_UntrustedHostOverride) {
+ GURL base_url("https://google.com/search");
+ GURL webui_url(
+ "chrome://"
+ "contextual-tasks?param1=1&chrome_host=malicious.example.com");
+
+ EXPECT_EQ(
+ GURL("https://google.com/search?param1=1"),
+ ContextualTasksUiService::GetAiUrlFromWebUIUrl(base_url, webui_url));
+}
+
+TEST_F(ContextualTasksUiServiceTest, GetHostFromUrl) {
+ EXPECT_EQ("gws-prod.corp.google.com",
+ ContextualTasksUiService::GetHostFromUrl(GURL(
+ "https://google.com?chrome_host=gws-prod.corp.google.com")));
+ EXPECT_EQ("127.0.0.1", ContextualTasksUiService::GetHostFromUrl(
+ GURL("https://google.com?chrome_host=127.0.0.1")));
+ EXPECT_EQ(std::nullopt,
+ ContextualTasksUiService::GetHostFromUrl(
+ GURL("https://google.com?chrome_host=malicious.example.com")));
+ EXPECT_EQ(std::nullopt, ContextualTasksUiService::GetHostFromUrl(
+ GURL("https://google.com?other_param=test")));
+}
+
// If the navigation is to sign the user out, ensure it opens outside the
// webview to ensure the user is signed out of the main storage partition.
TEST_F(ContextualTasksUiServiceTest, SignOutNavigation_OpenedInTab) {
diff --git a/chrome/test/data/webui/contextual_tasks/app_test.ts b/chrome/test/data/webui/contextual_tasks/app_test.ts
index 54ddda5..923edd8 100644
--- a/chrome/test/data/webui/contextual_tasks/app_test.ts
+++ b/chrome/test/data/webui/contextual_tasks/app_test.ts
@@ -134,27 +134,20 @@
assertFalse(appElement.hasAttribute('is-zero-state_'));
});
- test('host initialized from URL parameter', async () => {
- const forcedHost = 'test.host.com';
- window.history.replaceState({}, '', `?chrome_host=${forcedHost}`);
+ test(
+ 'host initialized from loadTimeData and URL parameter is ignored',
+ async () => {
+ const urlParamHost = 'untrusted.host.com';
+ const loadTimeDataHost = 'trusted.host.com';
+ window.history.replaceState({}, '', `?chrome_host=${urlParamHost}`);
+ loadTimeData.overrideValues({chrome_host: loadTimeDataHost});
- const appElement = document.createElement('contextual-tasks-app');
- document.body.appendChild(appElement);
- await microtasksFinished();
+ const appElement = document.createElement('contextual-tasks-app');
+ document.body.appendChild(appElement);
+ await microtasksFinished();
- assertEquals(forcedHost, (appElement as any).host_);
- });
-
- test('host initialized from loadTimeData when URL param absent', async () => {
- const forcedHost = 'default.host.com';
- loadTimeData.overrideValues({chrome_host: forcedHost});
-
- const appElement = document.createElement('contextual-tasks-app');
- document.body.appendChild(appElement);
- await microtasksFinished();
-
- assertEquals(forcedHost, (appElement as any).host_);
- });
+ assertEquals(loadTimeDataHost, (appElement as any).host_);
+ });
test('restores thread if task param set', async () => {
Original Bug Report
Unvalidated chrome_host param allows attacker-controlled webview inside chrome://contextual-tasks
Steps to reproduce the problem
Affected version
Chrome: 152.0.7956.0 (or head)
Local build arguments:
is_debug = false
is_component_build = true
symbol_level = 1
blink_symbol_level = 0
v8_symbol_level = 0
Steps to reproduce
Place opener.html, poc.html, server.py, and localhost_chrome_host.diff together in a folder.
-
Apply the attached patch and build Chromium:
git apply localhost_chrome_host.diff autoninja -C out\Default chromeNote: The patch only maps our local server value to
chrome_host—chrome_host=127.0.0.1:8000. A public attacker controlled HTTPS origin does not require this patch e.g.malicious.example.com. -
Start the server:
python server.py --host 127.0.0.1 --port 8000Launch the patched browser:
out\Default\chrome.exe --enable-features=ContextualTasks "http://127.0.0.1:8000/opener.html" -
Sign in or be signed into a Google account.
-
In your Google account, create:
- A Google Doc containing any unique test text.
- A Google Sheet containing any unique test text.
Record both IDs of the docs and or sheets documents.
-
In opener.html click Open evidence suite.
-
Under 2. Pre-existing private known-ID reads, Enter the private Doc and or Sheet IDs
-
Observe HTTP 200 CORS-readable responses containing the contents of the files.
Note: In a real world drive-by attack, everything would be done automatically without the need for the clicking and entering in values.
Problem Description
Security impact
A Google Search/AI Mode URL can supply an attacker-controlled chrome_host. Chromium preserves that value in the chrome://contextual-tasks URL without applying the existing IsTrustedHost() check, and the WebUI then rewrites its managed <webview> to the an attacker’s origin.
After the attacker page commits as the guest:
- attacker controlled origin is accepted by the AIM
postMessagebridge; - binary
AimToClientMessagedata reaches browser-process C++; - eligible Google requests from the guest receive the browser-managed Contextual Tasks bearer token and
- CORS-readable Google Workspace exports can be returned to attacker JavaScript.
- Full spoof of WebUI which leads to a very convincing spoof. Click the Open Gemini visual spoof button on opener.html for the spoof.
- Exposed the signed-in user’s email address.
Root cause
1. The intercepted URL is retained as task state
chrome/browser/contextual_tasks/contextual_tasks_ui_service.cc
ContextualTask task = contextual_tasks_service_->CreateTaskFromUrl(url);
task_id_to_creation_url_[task.GetTaskId()] = url;
GURL ui_url = GetContextualTaskUrlForTask(task.GetTaskId());
The complete attacker-controlled URL, including chrome_host, is preserved.
2. GetHostForTask() returns chrome_host without validation
GetHostForTask() extracts chrome_host from the stored creation URL and appends it to the privileged WebUI URL. That path does not call the existing IsTrustedHost() allowlist.
An input such as chrome_host=example.com therefore becomes part of:
chrome://contextual-tasks/...&chrome_host=example.com
3. The renderer reads the raw WebUI query parameter
chrome/browser/resources/contextual_tasks/app.ts
const webUiUrlOnLoad = new URL(window.location.href);
this.host_ = webUiUrlOnLoad.searchParams.get(CHROME_HOST_PARAM_KEY);
if (!this.host_ && loadTimeData.valueExists('chrome_host')) {
this.host_ = loadTimeData.getString('chrome_host');
}
C++ validates the value before exposing it through loadTimeData, but the raw window.location value is read first, so the validated fallback is bypassed.
4. The raw host rewrites managed-webview requests
ContextualTasksAppElement.onBeforeRequest() rewrites non-sign-in requests:
if (this.host_ && !isSigninDomain) {
newUrl.host = this.host_;
}
A value such as chrome_host=attacker.example therefore redirects the managed guest to https://attacker.example/....
5. The committed attacker origin becomes the accepted AIM origin
post_message_handler.ts updates targetOrigin_ to the final committed guest origin. Messages from that origin are then accepted, and binary data is forwarded to the Mojo page handler.
ContextualTasksPageHandler::OnWebviewMessage() parses those bytes as privileged lens::AimToClientMessage commands. There is no second trusted-origin check in the C++ handler.
6. Eligible guest requests receive Contextual Tasks authorization
contextual_tasks_url_loader_factory_interceptor.cc installs its proxy factory for a WebView guest owned by chrome://contextual-tasks. For eligible Google destinations, it obtains the Contextual Tasks token and adds:
Authorization: Bearer <token>
Bisect
Introducing commit: d641edfa583c8d647e12f4d5ad5f433ea71260a5
Before this commit, the renderer used only a C++-provided loadTimeData host derived from GetForcedEmbeddedPageHost(), which restricted overrides to approved Google/Googlers domains. The introducing commit added URL persistence, direct window.location parsing, and newUrl.host = this.host_.
Limitations
-
Requires user to be signed-in Google account.
-
The private read requires a known Doc or Sheet ID. However, this is not uncommon. For example, many Chromium design docs are invite only, as they contain sensitive information, but expose the ID in the share URL e.g. a drive-by attack could expose this internal Googler’s design document: https://issues.chromium.org/issues/524671711 — https://docs.google.com/document/d/1Q6LFdVyA-Ug8IAySVixEzsKYRe6JqOWmgkc4pV0ksJ4/edit?tab=t.0
Summary
Unvalidated chrome_host param allows attacker-controlled webview inside chrome://contextual-tasks
Custom Questions
Type of crash:
N/A
Crash state:
N/A
Reporter credit:
Sven Dysthe @svn_dy
Additional Data
Category: Security
Chrome Channel: Canary
Regression: Yes \