Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Contextual Tasks
DescriptionInsufficient validation of untrusted input in Contextual Tasks
ComponentContextual Tasks
Bug ClassLogic Error
Tracker537390933
Fix commit22ceabf10d93 (chromium/src) +115/-54
CISA KEVNot listed
CreditedSven Dysthe (@svn-dys)
Disclosed2026-08-06

Changed Functions

FunctionChangeNotes
if
chrome/browser/contextual_tasks/contextual_tasks_ui_service.cc
modified
TEST_F
chrome/browser/contextual_tasks/contextual_tasks_ui_service_unittest.cc
modified

Files Changed

  • chrome/browser/contextual_tasks/contextual_tasks_ui.cc
  • chrome/browser/contextual_tasks/contextual_tasks_ui_service.cc
  • chrome/browser/contextual_tasks/contextual_tasks_ui_service.h
  • chrome/browser/contextual_tasks/contextual_tasks_ui_service_unittest.cc
From 22ceabf10d935d525c5daceb850131b64ab28f2b Mon Sep 17 00:00:00 2001
From: Duncan Mercer <mercerd@google.com>
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 <mercerd@google.com>
Auto-Submit: Duncan Mercer <mercerd@google.com>
Reviewed-by: Bryan Nguyen <nguyenbryan@google.com>
Commit-Queue: Bryan Nguyen <nguyenbryan@google.com>
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/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);
Loading diff…

Regression Test / PoC

shipped with the fix
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 () => {
Loading diff…

Original Bug Report

reported by xp...@gmail.com

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.

  1. Apply the attached patch and build Chromium:

    git apply localhost_chrome_host.diff
    autoninja -C out\Default chrome
    

    Note: The patch only maps our local server value to chrome_hostchrome_host=127.0.0.1:8000. A public attacker controlled HTTPS origin does not require this patch e.g. malicious.example.com.

  2. Start the server:

    python server.py --host 127.0.0.1 --port 8000
    

    Launch the patched browser:

    out\Default\chrome.exe --enable-features=ContextualTasks "http://127.0.0.1:8000/opener.html"
    
  3. Sign in or be signed into a Google account.

  4. 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.

  5. In opener.html click Open evidence suite.

  6. Under 2. Pre-existing private known-ID reads, Enter the private Doc and or Sheet IDs

  7. 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 postMessage bridge;
  • binary AimToClientMessage data 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

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 \

View on issue tracker