Medium chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactConfused deputy in Omnibox
DescriptionConfused deputy in Omnibox
ComponentOmnibox
Sub-componentOmnibox
Bug ClassAuthorization Error
Tracker497148613
Fix commit1a2306c48d8d (chromium/src) +85/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
components/omnibox/browser/enterprise_search_aggregator_provider.cc
modified
for
components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
modified

Files Changed

  • components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
  • components/omnibox/browser/enterprise_search_aggregator_provider.cc
From 1a2306c48d8da33ece3393b538ddbf2025850b91 Mon Sep 17 00:00:00 2001
From: Ananya Seelam <[email protected]>
Date: Tue, 15 Sep 2026 13:40:50 -0700
Subject: [PATCH] [omnibox] Enforce HTTPS scheme for aggregator image and icon URLs

EnterpriseSearchAggregatorProvider::ParseResultList now validates that
displayPhoto.url (PEOPLE) and iconUri (CONTENT) values received in
aggregator responses use the HTTPS scheme. Any non-HTTPS URLs are cleared
so the suggestion falls back to the engine favicon.

This aligns image and icon handling with existing destinationUri
validation and policy-side icon_url requirements.

Original patch by Yoshisato Yanagisawa <[email protected]>.

TAG=agy
CONV=24af49ce-fab4-4a2a-ae32-a53265730ae7

Bug: 497148613
Change-Id: I65e086a662646a817c697bec14ca8328a3517e28
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8414305
Commit-Queue: Ananya Seelam <[email protected]>
Reviewed-by: Sky Malice <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1698355}
---
diff --git a/components/omnibox/browser/enterprise_search_aggregator_provider.cc b/components/omnibox/browser/enterprise_search_aggregator_provider.cc
index 3c8c410..c5889dd 100644
--- a/components/omnibox/browser/enterprise_search_aggregator_provider.cc
+++ b/components/omnibox/browser/enterprise_search_aggregator_provider.cc
@@ -51,6 +51,7 @@
 #include "ui/base/l10n/l10n_util.h"
 #include "ui/base/page_transition_types.h"
 #include "url/gurl.h"
+#include "url/url_constants.h"
 
 namespace {
 
@@ -921,7 +922,8 @@
       continue;
     }
 
-    // Some matches are supplied with an associated icon or image URL.
+    // Some matches are supplied with an associated icon or image URL. These are
+    // fetched from the browser process so only HTTPS URLs are accepted.
     std::string image_url;
     std::string icon_url;
     if (suggestion_type == SuggestionType::PEOPLE) {
@@ -930,6 +932,9 @@
       // used for the match icon, falling back to the favicon if not present.
       image_url = ptr_to_string(result.FindStringByDottedPath(
           "document.derivedStructData.displayPhoto.url"));
+      if (!GURL(image_url).SchemeIs(url::kHttpsScheme)) {
+        image_url.clear();
+      }
       // Ensure that image URLs from lh3.googleusercontent.com include an image
       // size parameter.
       if (base::StartsWith(image_url, "https://lh3.googleusercontent.com")) {
@@ -942,6 +947,9 @@
       icon_url = template_url_->favicon_url().spec();
     } else if (suggestion_type == SuggestionType::CONTENT) {
       icon_url = ptr_to_string(result.FindString("iconUri"));
+      if (!GURL(icon_url).SchemeIs(url::kHttpsScheme)) {
+        icon_url.clear();
+      }
     } else if (suggestion_type == SuggestionType::QUERY &&
                !adjusted_input_.in_keyword_mode()) {
       icon_url = template_url_->favicon_url().spec();
diff --git a/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc b/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
index 638e6e3..bd70a13 100644
--- a/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
+++ b/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
@@ -41,6 +41,7 @@
 #include "ui/base/l10n/l10n_util.h"
 #include "ui/base/page_transition_types.h"
 #include "url/gurl.h"
+#include "url/url_constants.h"
 
 namespace {
 using testing::_;
@@ -566,6 +567,63 @@
     ]
   })invalid";
 
+const std::string kNonHttpsImageUrlJsonResponse = R"json({
+    "contentSuggestions": [
+      {
+        "document": {
+          "derivedStructData": {
+            "title": "John's Document 1"
+          }
+        },
+        "destinationUri": "https://www.example.com/1",
+        "iconUri": "javascript:alert(1)",
+        "score": 0.8
+      },
+      {
+        "document": {
+          "derivedStructData": {
+            "title": "John's Document 2"
+          }
+        },
+        "destinationUri": "https://www.example.com/2",
+        "iconUri": "http://www.example.com/icon.png",
+        "score": 0.8
+      }
+    ],
+    "peopleSuggestions": [
+      {
+        "suggestion": "[email protected]",
+        "document": {
+          "derivedStructData": {
+            "name": {
+              "displayName": "John Doe"
+            },
+            "displayPhoto": {
+              "url": "javascript:alert(2)"
+            }
+          }
+        },
+        "destinationUri": "https://example.com/people/jdoe",
+        "score": 0.8
+      },
+      {
+        "suggestion": "[email protected]",
+        "document": {
+          "derivedStructData": {
+            "name": {
+              "displayName": "John Doe2"
+            },
+            "displayPhoto": {
+              "url": "http://example.com/image.png"
+            }
+          }
+        },
+        "destinationUri": "https://example.com/people/jdoe2",
+        "score": 0.8
+      }
+    ]
+  })json";
+
 // Helper methods to dynamically generate valid responses.
 std::string CreateQueryResult(const std::string& query,
                               const float score = 0.0) {
@@ -2019,6 +2077,24 @@
   EXPECT_EQ(matches.size(), 0u);
 }
 
+TEST_F(EnterpriseSearchAggregatorProviderTest,
+       DiscardsNonHttpsImageAndIconUrls) {
+  provider_->adjusted_input_ = CreateInput(u"john d", true);
+  StartAndComplete3Requests(200, kNonHttpsImageUrlJsonResponse);
+
+  ACMatches matches = provider_->matches_;
+  ASSERT_EQ(matches.size(), 4u);
+  // Matches are kept but `image_url` and `icon_url` are cleared because the
+  // supplied `displayPhoto.url` and `iconUri` values do not use the HTTPS
+  // scheme. People suggestions still use the engine favicon as `icon_url`.
+  for (const auto& match : matches) {
+    EXPECT_EQ(match.image_url, GURL()) << match.destination_url;
+    EXPECT_TRUE(match.icon_url.is_empty() ||
+                match.icon_url.SchemeIs(url::kHttpsScheme))
+        << match.icon_url;
+  }
+}
+
 TEST_F(EnterpriseSearchAggregatorProviderSingleRequestTest,
        Logging_SingleRequest) {
   // The code flow is:
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc b/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
index 638e6e3..bd70a13 100644
--- a/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
+++ b/components/omnibox/browser/enterprise_search_aggregator_provider_unittest.cc
@@ -41,6 +41,7 @@
 #include "ui/base/l10n/l10n_util.h"
 #include "ui/base/page_transition_types.h"
 #include "url/gurl.h"
+#include "url/url_constants.h"
 
 namespace {
 using testing::_;
@@ -566,6 +567,63 @@
     ]
   })invalid";
 
+const std::string kNonHttpsImageUrlJsonResponse = R"json({
+    "contentSuggestions": [
+      {
+        "document": {
+          "derivedStructData": {
+            "title": "John's Document 1"
+          }
+        },
+        "destinationUri": "https://www.example.com/1",
+        "iconUri": "javascript:alert(1)",
+        "score": 0.8
+      },
+      {
+        "document": {
+          "derivedStructData": {
+            "title": "John's Document 2"
+          }
+        },
+        "destinationUri": "https://www.example.com/2",
+        "iconUri": "http://www.example.com/icon.png",
+        "score": 0.8
+      }
+    ],
+    "peopleSuggestions": [
+      {
+        "suggestion": "[email protected]",
+        "document": {
+          "derivedStructData": {
+            "name": {
+              "displayName": "John Doe"
+            },
+            "displayPhoto": {
+              "url": "javascript:alert(2)"
+            }
+          }
+        },
+        "destinationUri": "https://example.com/people/jdoe",
+        "score": 0.8
+      },
+      {
+        "suggestion": "[email protected]",
+        "document": {
+          "derivedStructData": {
+            "name": {
+              "displayName": "John Doe2"
+            },
+            "displayPhoto": {
+              "url": "http://example.com/image.png"
+            }
+          }
+        },
+        "destinationUri": "https://example.com/people/jdoe2",
+        "score": 0.8
+      }
+    ]
+  })json";
+
 // Helper methods to dynamically generate valid responses.
 std::string CreateQueryResult(const std::string& query,
                               const float score = 0.0) {
@@ -2019,6 +2077,24 @@
   EXPECT_EQ(matches.size(), 0u);
 }
 
+TEST_F(EnterpriseSearchAggregatorProviderTest,
+       DiscardsNonHttpsImageAndIconUrls) {
+  provider_->adjusted_input_ = CreateInput(u"john d", true);
+  StartAndComplete3Requests(200, kNonHttpsImageUrlJsonResponse);
+
+  ACMatches matches = provider_->matches_;
+  ASSERT_EQ(matches.size(), 4u);
+  // Matches are kept but `image_url` and `icon_url` are cleared because the
+  // supplied `displayPhoto.url` and `iconUri` values do not use the HTTPS
+  // scheme. People suggestions still use the engine favicon as `icon_url`.
+  for (const auto& match : matches) {
+    EXPECT_EQ(match.image_url, GURL()) << match.destination_url;
+    EXPECT_TRUE(match.icon_url.is_empty() ||
+                match.icon_url.SchemeIs(url::kHttpsScheme))
+        << match.icon_url;
+  }
+}
+
 TEST_F(EnterpriseSearchAggregatorProviderSingleRequestTest,
        Logging_SingleRequest) {
   // The code flow is:
Loading diff…

Original Bug Report

reported by [email protected]

Credentialed SSRF via EnterpriseSearchAggregatorProvider unvalidated icon URLs

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

Overview: The EnterpriseSearchAggregatorProvider does not validate image and icon URLs returned by an enterprise search server. These URLs are fetched by the browser process without Local Network Access (LNA) protections and with SameSite cookies included, potentially allowing a compromised search server to force a victim’s browser to send credentialed GET requests to arbitrary internal endpoints.

Affected files:

  • components/omnibox/browser/enterprise_search_aggregator_provider.cc
  • chrome/browser/bitmap_fetcher/bitmap_fetcher_service.cc
  • chrome/browser/bitmap_fetcher/bitmap_fetcher.cc
  • chrome/browser/ui/omnibox/chrome_omnibox_client.cc

Estimated timestamp from git blame: 2026-01-23

Vulnerability Details

When the EnterpriseSearchAggregatorSettings policy is configured, Chrome can fetch search suggestions from an enterprise search server. The EnterpriseSearchAggregatorProvider parses the JSON response from this server.

While the destinationUri field is validated to ensure it has an HTTP/HTTPS scheme, the displayPhoto.url (for PEOPLE suggestions) and iconUri (for CONTENT suggestions) fields are extracted as strings and passed directly to CreateMatch without any scheme, host, or IP address validation:

// components/omnibox/browser/enterprise_search_aggregator_provider.cc
    // ... in ParseResultList ...
    std::string image_url;
    std::string icon_url;
    if (suggestion_type == SuggestionType::PEOPLE) {
      image_url = ptr_to_string(result.FindStringByDottedPath(
          "document.derivedStructData.displayPhoto.url"));
      // ... appends size param if lh3.googleusercontent.com ...
    } else if (suggestion_type == SuggestionType::CONTENT) {
      icon_url = ptr_to_string(result.FindString("iconUri"));
    }
    // ... passed to CreateMatch, which just does GURL(url) ...

The Omnibox UI then attempts to fetch these images. In ChromeOmniboxClient::OnResultChanged, it calls BitmapFetcherService::RequestImage for these URLs.

BitmapFetcherService::CreateFetcher initializes the network request with highly privileged settings:

// chrome/browser/bitmap_fetcher/bitmap_fetcher_service.cc
  new_fetcher->Init(
      net::ReferrerPolicy::REDUCE_GRANULARITY_ON_TRANSITION_CROSS_ORIGIN,
      network::mojom::CredentialsMode::kInclude, /*additional_headers=*/{},
      /*initiator=*/url::Origin(), /*is_same_site_request=*/true);
  new_fetcher->Start(context_->GetDefaultStoragePartition()
                         ->GetURLLoaderFactoryForBrowserProcess()
                         .get());

Because it uses GetURLLoaderFactoryForBrowserProcess(), the request lacks a web-origin client_security_state, completely bypassing Local Network Access (LNA) preflight protections that normally prevent public sites from requesting intranet resources.

Furthermore, because is_same_site_request=true is passed to BitmapFetcher::Init, the network stack sets site_for_cookies to the destination URL itself. Combined with CredentialsMode::kInclude, this causes all of the victim’s cookies (including SameSite=Strict and SameSite=Lax) for the target host to be attached to the request.

Potential Impact

An attacker who can control the search results returned by the enterprise search server (e.g., by modifying their profile picture URL in the corporate directory, or by creating a document with a malicious icon URL) can exploit this.

When a victim types in the Omnibox and receives the attacker’s suggestion, the victim’s browser will silently send a credentialed GET request to the attacker-specified URL. Because LNA is bypassed, this URL can be an internal corporate tool (e.g., http://intranet.local/admin/delete_all) or localhost (e.g., http://127.0.0.1:8080/sensitive_action). This results in a blind SSRF/CSRF attack leveraging the victim’s session.

Suggested Attack Steps

(Note: These are potential steps based on code analysis; we do not have a working PoC)

  1. An organization configures the EnterpriseSearchAggregatorSettings policy.
  2. An attacker modifies a document or profile in the enterprise corpus such that the search index returns a malicious iconUri or displayPhoto.url (e.g., http://169.254.169.254/computeMetadata/v1/... or an intranet action URL).
  3. A victim user types a query in the Omnibox that matches the attacker’s modified record.
  4. The victim’s browser parses the unvalidated URL and uses the browser-process URLLoaderFactory to fetch it.
  5. The browser sends a GET request to the internal endpoint, including the victim’s ambient cookies for that endpoint, bypassing LNA.

Suggested Fix

In EnterpriseSearchAggregatorProvider::ParseResultList, validate that image_url and icon_url have a secure scheme (HTTPS) before creating the AutocompleteMatch. Ensure they are not navigating to restricted IP spaces or localhost if that is not intended behavior for this feature.

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from 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. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker