Medium watch CVSS 6.1 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Omnibox
DescriptionInsufficient validation of untrusted input in Omnibox
ComponentOmnibox
Sub-componentOmnibox
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.
Tracker496379792
Fix commit916f2fabadb2 (chromium/src) +99/-0
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-26 (per vendor advisory)
Fix landed2026-03-26 (public git)
Disclosed2026-05-05
Patch gap40 days — fix public in source before the advisory

Background

Enterprise Search Aggregator
a Chrome Omnibox provider that queries an admin-configured backend (via the EnterpriseSearchAggregatorSettings policy) and renders its JSON results as autocomplete suggestions.
`destinationUri`
the per-suggestion target URL field parsed out of the aggregator’s JSON response and used to build the match’s navigation URL.
`SuggestionType::CONTENT` / `SuggestionType::PEOPLE`
two suggestion categories whose destinationUri is used directly as the navigation destination, rather than being rewritten into a search query.
`javascript:` scheme
a pseudo-scheme whose “navigation” executes script in the context of the current/target document, which is why it must never be reachable from an untrusted suggestion.

Root Cause Analysis

The commit (GetMatchDestinationUrl, per the subject line; the @@ header at line 1041 does not name the enclosing function) returned the raw destination_uri string straight from the aggregator JSON for CONTENT and PEOPLE suggestions without validating its scheme. Because the aggregator response is attacker-influenceable (a malicious or compromised backend, or a man-in-the-middle on the configured endpoint, can return arbitrary JSON), a suggestion could carry "destinationUri": "javascript:alert(1)" and that string would become the match’s destination_url. The invariant violated is that an Omnibox suggestion’s destination must be a navigable web URL, not a script-executing pseudo-scheme.

The fix constructs a GURL from destination_uri and returns the empty string (discarding the match) unless gurl.is_valid() and gurl.SchemeIsHTTPOrHTTPS() both hold, so non-HTTP(S) and malformed URIs can no longer survive into a match. This is a tightening of validation on the early path: the guard runs before the value is returned, so the downstream match is never created.

Key insight
The provider trusted a server-supplied destinationUri as a navigable URL without checking its scheme; the fix adds an explicit is_valid() + SchemeIsHTTPOrHTTPS() gate that discards any javascript: (or otherwise invalid/non-web) destination before it becomes a match.

Attack Path

  1. Control the aggregator response An attacker who controls or can tamper with the configured Enterprise Search Aggregator endpoint returns JSON containing a CONTENT or PEOPLE suggestion whose destinationUri is javascript:alert(1).
  2. Trigger autocomplete The victim types into the Omnibox while the policy is active, causing the provider to parse the response and, pre-patch, accept the javascript: string as the match destination.
  3. Match surfaced The suggestion appears in the Omnibox result set with a destination_url whose scheme is javascript: (what the regression test asserts must no longer happen).
  4. Script execution on selection If the victim selects the suggestion, the javascript: URL is “navigated,” executing attacker script in a page context (inferred; the diff only demonstrates that such a match was previously created, not the downstream execution sink).

Impact Assessment

An attacker controlling the enterprise aggregator backend could cause a javascript: (or other non-web-scheme) URL to be offered as an Omnibox suggestion, which on user selection could lead to script execution in a document context (inferred). Preconditions are significant: the EnterpriseSearchAggregatorSettings policy must be configured, and the attacker must control or tamper with that endpoint, limiting this to managed/enterprise deployments. The vendor advisory rates this medium and does not report exploitation in the wild.

Changed Functions

FunctionChangeNotes
TEST_F
components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
modified

Files Changed

  • chrome/browser/ui/omnibox/omnibox_search_aggregator_browsertest.cc
  • components/omnibox/browser/enterprise_search_aggregator_provider.cc
  • components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc

Audit Directions

  • Server-supplied URLs reaching navigation
    Audit every provider that parses URLs out of a remote/enterprise response (suggest providers, rich-entity/answer providers) and confirm each validates scheme before the string becomes a destination_url.
  • Scheme allowlists vs. denylists
    Prefer positive SchemeIsHTTPOrHTTPS()/allowlist checks over blocking individual dangerous schemes; audit for code that only strips javascript: but still admits data:, blob:, filesystem:, or chrome:.
  • `GURL` validity gaps
    Look for places that use a URL string without first checking is_valid(), since malformed URIs can parse into unexpected schemes or empty-but-truthy values.
From 916f2fabadb2c7ad50a20e06fed1c1a081ff7cb0 Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <apaseltiner@chromium.org>
Date: Thu, 26 Mar 2026 14:01:32 -0700
Subject: [PATCH] [omnibox] Require HTTP(S) scheme for destinationUri in EnterpriseSearchAggregatorProvider

GetMatchDestinationUrl now verifies that destinationUri has an HTTP or
HTTPS scheme for CONTENT and PEOPLE suggestions. Matches with invalid
GURLs or other schemes are discarded.

Fixed: 496379792
Change-Id: I5f9949b35b479036b41e04ccb976a1d0f03b6e4e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7705937
Reviewed-by: Justin Donnelly <jdonnelly@chromium.org>
Reviewed-by: Alex Chen <alexwchen@chromium.org>
Commit-Queue: Andrew Paseltiner <apaseltiner@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1605770}
---

diff --git a/chrome/browser/ui/omnibox/omnibox_search_aggregator_browsertest.cc b/chrome/browser/ui/omnibox/omnibox_search_aggregator_browsertest.cc
index 85dfe102..fd0703c 100644
--- a/chrome/browser/ui/omnibox/omnibox_search_aggregator_browsertest.cc
+++ b/chrome/browser/ui/omnibox/omnibox_search_aggregator_browsertest.cc
@@ -408,6 +408,63 @@
           })));
 }
 
+IN_PROC_BROWSER_TEST_F(OmniboxSearchAggregatorSingleRequestTest,
+                       DiscardsInvalidJavascriptUrl) {
+  net::test_server::ControllableHttpResponse search_aggregator_response(
+      embedded_test_server(), kSearchAggregatorPolicySuggestPath);
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  // 1. Start on a benign page (e.g., https://www.google.com).
+  GURL initial_url = embedded_test_server()->GetURL("/title1.html");
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), initial_url));
+
+  base::Value policy_value = CreateEnterpriseSearchAggregatorPolicyValue(
+      embedded_test_server()
+          ->GetURL(kSearchAggregatorPolicySuggestPath)
+          .spec());
+  policy::PolicyMap policies;
+  policies.Set(policy::key::kEnterpriseSearchAggregatorSettings,
+               policy::POLICY_LEVEL_MANDATORY, policy::POLICY_SCOPE_USER,
+               policy::POLICY_SOURCE_CLOUD, std::move(policy_value), nullptr);
+  policy_provider()->UpdateChromePolicy(policies);
+
+  // 2. Trigger autocomplete.
+  AutocompleteInput input(
+      kSearchInput, metrics::OmniboxEventProto::NTP,
+      ChromeAutocompleteSchemeClassifier(browser()->profile()));
+  input.set_keyword_mode_entry_method(metrics::OmniboxEventProto::TAB);
+  controller()->Start(input);
+
+  // 3. Respond with an invalid javascript: URL.
+  search_aggregator_response.WaitForRequest();
+  const std::string invalid_json = R"invalid({
+    "contentSuggestions": [
+      {
+        "suggestion": "Invalid Suggestion",
+        "document": {
+          "derivedStructData": {
+            "title": "Invalid Suggestion"
+          }
+        },
+        "destinationUri": "javascript:alert(1)",
+        "score": 0.8
+      }
+    ]
+  })invalid";
+  search_aggregator_response.Send(net::HTTP_OK, "application/json",
+                                  invalid_json);
+  search_aggregator_response.Done();
+
+  WaitForAutocompleteDone(browser());
+
+  // 4. Verify that no javascript: match exists.
+  const AutocompleteResult& result = controller()->result();
+  auto it = std::find_if(result.begin(), result.end(), [](const auto& match) {
+    return match.destination_url.SchemeIs(url::kJavaScriptScheme);
+  });
+  EXPECT_EQ(it, result.end());
+}
+
 // TODO(crbug.com/425120649) Flaky.
 IN_PROC_BROWSER_TEST_F(OmniboxSearchAggregatorSingleRequestTest,
                        DISABLED_RedirectedResponse) {
diff --git a/components/omnibox/browser/enterprise_search_aggregator_provider.cc b/components/omnibox/browser/enterprise_search_aggregator_provider.cc
index 929369b..b4a3231 100644
--- a/components/omnibox/browser/enterprise_search_aggregator_provider.cc
+++ b/components/omnibox/browser/enterprise_search_aggregator_provider.cc
@@ -1041,6 +1041,10 @@
       ptr_to_string(result.FindString("destinationUri"));
   if (suggestion_type == SuggestionType::CONTENT ||
       suggestion_type == SuggestionType::PEOPLE) {
+    GURL gurl(destination_uri);
+    if (!gurl.is_valid() || !gurl.SchemeIsHTTPOrHTTPS()) {
+      return "";
+    }
     return destination_uri;
   }
 
diff --git a/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc b/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
index 82e773e..f76399e 100644
--- a/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
+++ b/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
@@ -537,6 +537,35 @@
 const std::string kNonDictJsonResponse =
     base::StringPrintf(R"(["test","result1","result2"])");
 
+const std::string kInvalidJsonResponse = R"invalid({
+    "contentSuggestions": [
+      {
+        "suggestion": "Invalid Suggestion",
+        "document": {
+          "derivedStructData": {
+            "title": "Invalid Suggestion"
+          }
+        },
+        "destinationUri": "javascript:alert(1)",
+        "score": 0.8
+      }
+    ],
+    "peopleSuggestions": [
+      {
+        "suggestion": "invalid@example.com",
+        "document": {
+          "derivedStructData": {
+            "name": {
+              "displayName": "Invalid Person"
+            }
+          }
+        },
+        "destinationUri": "javascript:alert(2)",
+        "score": 0.8
+      }
+    ]
+  })invalid";
+
 // Helper methods to dynamically generate valid responses.
 std::string CreateQueryResult(const std::string& query,
                               const float score = 0.0) {
@@ -1983,6 +2012,15 @@
   EXPECT_EQ(matches[2].destination_url, GURL("https://url3/"));
 }
 
+TEST_F(EnterpriseSearchAggregatorProviderTest, DiscardsInvalidJavascriptUrl) {
+  provider_->adjusted_input_ = CreateInput(u"john d", true);
+  StartAndComplete3Requests(200, kInvalidJsonResponse);
+
+  ACMatches matches = provider_->matches_;
+  // After fix, it should have 0 matches because javascript: URLs are discarded.
+  EXPECT_EQ(matches.size(), 0u);
+}
+
 TEST_F(EnterpriseSearchAggregatorProviderSingleRequestTest,
        Logging_SingleRequest) {
   // The code flow is:
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/ui/omnibox/omnibox_search_aggregator_browsertest.cc b/chrome/browser/ui/omnibox/omnibox_search_aggregator_browsertest.cc
index 85dfe102..fd0703c 100644
--- a/chrome/browser/ui/omnibox/omnibox_search_aggregator_browsertest.cc
+++ b/chrome/browser/ui/omnibox/omnibox_search_aggregator_browsertest.cc
@@ -408,6 +408,63 @@
           })));
 }
 
+IN_PROC_BROWSER_TEST_F(OmniboxSearchAggregatorSingleRequestTest,
+                       DiscardsInvalidJavascriptUrl) {
+  net::test_server::ControllableHttpResponse search_aggregator_response(
+      embedded_test_server(), kSearchAggregatorPolicySuggestPath);
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  // 1. Start on a benign page (e.g., https://www.google.com).
+  GURL initial_url = embedded_test_server()->GetURL("/title1.html");
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), initial_url));
+
+  base::Value policy_value = CreateEnterpriseSearchAggregatorPolicyValue(
+      embedded_test_server()
+          ->GetURL(kSearchAggregatorPolicySuggestPath)
+          .spec());
+  policy::PolicyMap policies;
+  policies.Set(policy::key::kEnterpriseSearchAggregatorSettings,
+               policy::POLICY_LEVEL_MANDATORY, policy::POLICY_SCOPE_USER,
+               policy::POLICY_SOURCE_CLOUD, std::move(policy_value), nullptr);
+  policy_provider()->UpdateChromePolicy(policies);
+
+  // 2. Trigger autocomplete.
+  AutocompleteInput input(
+      kSearchInput, metrics::OmniboxEventProto::NTP,
+      ChromeAutocompleteSchemeClassifier(browser()->profile()));
+  input.set_keyword_mode_entry_method(metrics::OmniboxEventProto::TAB);
+  controller()->Start(input);
+
+  // 3. Respond with an invalid javascript: URL.
+  search_aggregator_response.WaitForRequest();
+  const std::string invalid_json = R"invalid({
+    "contentSuggestions": [
+      {
+        "suggestion": "Invalid Suggestion",
+        "document": {
+          "derivedStructData": {
+            "title": "Invalid Suggestion"
+          }
+        },
+        "destinationUri": "javascript:alert(1)",
+        "score": 0.8
+      }
+    ]
+  })invalid";
+  search_aggregator_response.Send(net::HTTP_OK, "application/json",
+                                  invalid_json);
+  search_aggregator_response.Done();
+
+  WaitForAutocompleteDone(browser());
+
+  // 4. Verify that no javascript: match exists.
+  const AutocompleteResult& result = controller()->result();
+  auto it = std::find_if(result.begin(), result.end(), [](const auto& match) {
+    return match.destination_url.SchemeIs(url::kJavaScriptScheme);
+  });
+  EXPECT_EQ(it, result.end());
+}
+
 // TODO(crbug.com/425120649) Flaky.
 IN_PROC_BROWSER_TEST_F(OmniboxSearchAggregatorSingleRequestTest,
                        DISABLED_RedirectedResponse) {
diff --git a/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc b/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
index 82e773e..f76399e 100644
--- a/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
+++ b/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
@@ -537,6 +537,35 @@
 const std::string kNonDictJsonResponse =
     base::StringPrintf(R"(["test","result1","result2"])");
 
+const std::string kInvalidJsonResponse = R"invalid({
+    "contentSuggestions": [
+      {
+        "suggestion": "Invalid Suggestion",
+        "document": {
+          "derivedStructData": {
+            "title": "Invalid Suggestion"
+          }
+        },
+        "destinationUri": "javascript:alert(1)",
+        "score": 0.8
+      }
+    ],
+    "peopleSuggestions": [
+      {
+        "suggestion": "invalid@example.com",
+        "document": {
+          "derivedStructData": {
+            "name": {
+              "displayName": "Invalid Person"
+            }
+          }
+        },
+        "destinationUri": "javascript:alert(2)",
+        "score": 0.8
+      }
+    ]
+  })invalid";
+
 // Helper methods to dynamically generate valid responses.
 std::string CreateQueryResult(const std::string& query,
                               const float score = 0.0) {
@@ -1983,6 +2012,15 @@
   EXPECT_EQ(matches[2].destination_url, GURL("https://url3/"));
 }
 
+TEST_F(EnterpriseSearchAggregatorProviderTest, DiscardsInvalidJavascriptUrl) {
+  provider_->adjusted_input_ = CreateInput(u"john d", true);
+  StartAndComplete3Requests(200, kInvalidJsonResponse);
+
+  ACMatches matches = provider_->matches_;
+  // After fix, it should have 0 matches because javascript: URLs are discarded.
+  EXPECT_EQ(matches.size(), 0u);
+}
+
 TEST_F(EnterpriseSearchAggregatorProviderSingleRequestTest,
        Logging_SingleRequest) {
   // The code flow is:
Loading diff…

Original Bug Report

reported by vm...@google.com

UXSS via unvalidated javascript: URI in Enterprise Search Aggregator

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: The EnterpriseSearchAggregatorProvider fails to validate the scheme of URLs returned by the enterprise search backend for certain suggestion types. This allows a compromised or malicious backend to inject javascript: URLs into omnibox suggestions. If a user selects one of these suggestions, the script executes in the context of the currently active tab, potentially leading to Universal Cross-Site Scripting (UXSS).

Affected files:

  • components/omnibox/browser/enterprise_search_aggregator_provider.cc
  • third_party/blink/renderer/core/frame/local_frame.cc

Estimated timestamp from git blame: 2026-01-25

Summary

A vulnerability exists in EnterpriseSearchAggregatorProvider where it blindly trusts the destinationUri provided by an enterprise search backend for CONTENT and PEOPLE suggestion types. By returning a javascript: URL, a compromised or malicious enterprise backend can execute arbitrary JavaScript in the context of the user’s currently active tab (Universal XSS).

While Chrome proactively strips javascript: schemas from URLs that users paste into the omnibox to prevent Self-XSS, this sanitization does not apply to URLs provided asynchronously by search providers via JSON responses.

Potential Execution Steps

Note: These are suggested steps for exploitation, as our setup does not have the ability to run code to verify with a working proof of concept.

  1. Setup: The victim is in an enterprise environment where the EnterpriseSearchAggregatorSettings policy is configured. An attacker compromises the legitimate enterprise search backend (or a malicious admin configures an attacker-controlled endpoint).
  2. Victim Action: The victim browses to a sensitive target website (e.g., https://bank.example.com).
  3. Triggering the Request: The victim focuses the omnibox and types a query, triggering EnterpriseSearchAggregatorProvider::Start and sending a request to the backend.
  4. Malicious Response: The backend responds with a JSON payload containing a suggestion with a malicious destinationUri: {"contentSuggestions": [{"destinationUri": "javascript:alert(document.domain)", "document": {"derivedStructData": {"title": "Important HR Document"}}}]}
  5. Unvalidated Extraction: During parsing, EnterpriseSearchAggregatorProvider::GetMatchDestinationUrl extracts the destinationUri string using ptr_to_string(result.FindString("destinationUri")). For CONTENT suggestions, it returns this string verbatim without checking if the scheme is http/https.
  6. Match Creation: EnterpriseSearchAggregatorProvider::CreateMatch assigns this unvalidated string directly to the new AutocompleteMatch’s destination_url.
  7. User Selection: The victim sees the benign-looking “Important HR Document” suggestion in the omnibox dropdown and selects it.
  8. Navigation and Execution:
    • OmniboxEditModel::OpenMatch initiates a navigation to the javascript: URL.
    • NavigationControllerImpl::NavigateWithoutEntry recognizes the javascript: scheme as a renderer debug URL (blink::IsRendererDebugURL).
    • Instead of a standard navigation, it routes the URL directly to the current tab’s renderer process via RenderFrameHostImpl::HandleRendererDebugURL.
    • The renderer invokes LocalFrame::LoadJavaScriptURL, which calls window->GetScriptController().ExecuteJavaScriptURL(...) with CSPDisposition::DO_NOT_CHECK.
  9. UXSS Realized: The JavaScript payload executes synchronously in the Main World of the currently active document (https://bank.example.com), allowing the attacker to steal cookies, hijack the session, or manipulate the DOM.

Suggested Fix

The EnterpriseSearchAggregatorProvider must validate the scheme of the destinationUri before creating an AutocompleteMatch.

In EnterpriseSearchAggregatorProvider::GetMatchDestinationUrl (or right before CreateMatch is called), verify that the parsed GURL has an allowed scheme (e.g., SchemeIsHTTPOrHTTPS()). If the URL uses a dangerous scheme like javascript:, file:, or chrome:, the suggestion should be discarded.

Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker
Links in the report