High watch CVSS 8.8 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
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
Sub-componentContextual Tasks
Bug ClassInput Validation
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker537390933
Fix commit22ceabf10d93 (chromium/src) +115/-54
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedSven Dysthe (@svn-dys)
Reported2026-07-21 · bounty $5000 (per vendor advisory)
Fix landed2026-07-22 (public git)
Disclosed2026-08-06
Patch gap15 days — fix public in source before the advisory

Background

Contextual Tasks
a Chrome browser feature that intercepts navigations to Google AI pages and shows the conversation inside the chrome://contextual-tasks WebUI. That WebUI embeds the AI page in a webview and is managed by ContextualTasksUiService and ContextualTasksUI.
`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::IsTrustedHost should be honoured. Those are loopback addresses and corp.google.com/googlers.com subdomains.
`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 own window.location query 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.

Key insight
The trust check on the attacker-supplied 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

  1. Craft an AI-page link The attacker gets the user to open a Google AI search URL that carries a chrome_host parameter, as in the regression test’s https://google.com/search?udm=50&q=test&chrome_host=malicious.example.com.
  2. Interception records the URL OnNavigationToAiPageIntercepted creates a task and stores the full URL as the task’s creation URL, chrome_host included.
  3. Unvalidated host propagates Before the patch, GetHostForTask returned malicious.example.com unchecked. The regression test GetContextualTaskUrlForTask_WithUntrustedHost shows that this value then appeared as chrome_host in the URL returned by GetContextualTaskUrlForTask, the chrome://contextual-tasks URL for the task.
  4. WebUI adopts the host Pre-patch app.ts read chrome_host from window.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

An attacker can make Chrome’s Contextual Tasks UI load attacker-controlled content where it expects a trusted Google AI page. This happens in the browser-managed WebUI and its embedded webview, and needs no renderer compromise. Exposure of any data or privileged hooks that the WebUI hands to the embedded AI page, such as shared tab context, is (inferred) and not shown in the patch. Preconditions are that the Contextual Tasks feature is enabled in the build or profile (it may be gated or rolling out) and that the user navigates to a crafted AI-page URL that Chrome intercepts. The advisory rates this only as “Insufficient validation of untrusted input” and states no in-the-wild exploitation.

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_unittest.cc
  • chrome/browser/resources/contextual_tasks/app.ts
  • chrome/test/data/webui/contextual_tasks/app_test.ts
  • chrome/browser/contextual_tasks/contextual_tasks_ui_service.h

Audit Directions

  • Debug/override query parameters
    Search chrome/browser and WebUI resources for parameters that override hosts, endpoints, or origins (*_host, *_url, server=) read with GetValueForKeyInQuery or URLSearchParams. 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-validated loadTimeData or Mojo instead.
  • Stored untrusted URLs reused later
    Maps 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.
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/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 () => {
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_host—chrome_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