CVE-2026-7953
Overview
Background
- Enterprise Search Aggregator
- a Chrome Omnibox provider that queries an admin-configured backend (via the
EnterpriseSearchAggregatorSettingspolicy) 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
destinationUriis 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.
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
- Control the aggregator response
An attacker who controls or can tamper with the configured Enterprise Search Aggregator endpoint returns JSON containing a
CONTENTorPEOPLEsuggestion whosedestinationUriisjavascript:alert(1). - 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. - Match surfaced
The suggestion appears in the Omnibox result set with a
destination_urlwhose scheme isjavascript:(what the regression test asserts must no longer happen). - 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
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
| Function | Change | Notes |
|---|---|---|
TEST_Fcomponents/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc |
modified |
Files Changed
chrome/browser/ui/omnibox/omnibox_search_aggregator_browsertest.cccomponents/omnibox/browser/enterprise_search_aggregator_provider.cccomponents/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
Audit Directions
- Server-supplied URLs reaching navigationAudit 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. denylistsPrefer positive
SchemeIsHTTPOrHTTPS()/allowlist checks over blocking individual dangerous schemes; audit for code that only stripsjavascript:but still admitsdata:,blob:,filesystem:, orchrome:. - `GURL` validity gapsLook 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.
Patch
From 916f2fabadb2c7ad50a20e06fed1c1a081ff7cb0 Mon Sep 17 00:00:00 2001 From: Andrew Paseltiner <[email protected]> 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 <[email protected]> Reviewed-by: Alex Chen <[email protected]> Commit-Queue: Andrew Paseltiner <[email protected]> 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": "[email protected]", + "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:
Regression Test / PoC
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": "[email protected]",
+ "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:
Original Bug Report
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.ccthird_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.
- Setup: The victim is in an enterprise environment where the
EnterpriseSearchAggregatorSettingspolicy is configured. An attacker compromises the legitimate enterprise search backend (or a malicious admin configures an attacker-controlled endpoint). - Victim Action: The victim browses to a sensitive target website (e.g.,
https://bank.example.com). - Triggering the Request: The victim focuses the omnibox and types a query, triggering
EnterpriseSearchAggregatorProvider::Startand sending a request to the backend. - 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"}}}]} - Unvalidated Extraction: During parsing,
EnterpriseSearchAggregatorProvider::GetMatchDestinationUrlextracts thedestinationUristring usingptr_to_string(result.FindString("destinationUri")). ForCONTENTsuggestions, it returns this string verbatim without checking if the scheme ishttp/https. - Match Creation:
EnterpriseSearchAggregatorProvider::CreateMatchassigns this unvalidated string directly to the newAutocompleteMatch’sdestination_url. - User Selection: The victim sees the benign-looking “Important HR Document” suggestion in the omnibox dropdown and selects it.
- Navigation and Execution:
OmniboxEditModel::OpenMatchinitiates a navigation to thejavascript:URL.NavigationControllerImpl::NavigateWithoutEntryrecognizes thejavascript: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 callswindow->GetScriptController().ExecuteJavaScriptURL(...)withCSPDisposition::DO_NOT_CHECK.
- 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.