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

Overview

High
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Omnibox
DescriptionImproper input validation in Omnibox
ComponentOmnibox
Sub-componentUI>Browser>Omnibox
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.
Tracker523208474
Fix commit83357115666f (chromium/src) +245/-44
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-06-12 (per vendor advisory)
Fix landed2026-08-26 (public git)
Disclosed2026-09-01
Patch gap6 days — fix public in source before the advisory

Background

`DocumentProvider`
the Omnibox autocomplete provider that turns Google Drive/Docs search results returned by a remote server into AutocompleteMatch suggestions.
`ParseDocumentSearchResults`
the DocumentProvider method (exercised directly by the added test) that iterates the server’s JSON results array and builds one AutocompleteMatch per entry.
`AutocompleteMatch::destination_url`
the GURL an omnibox suggestion navigates to when the user selects it; it is populated from the server-supplied url field.
`GURL::SchemeIsHTTPOrHTTPS` / `GURL::is_valid`
GURL predicates that report whether a parsed URL is well-formed and whether its scheme is http/https.

Root Cause Analysis

Before the patch, ParseDocumentSearchResults set match.destination_url = GURL(url) directly from the server response’s url field with no scheme or validity check, so whatever string the Document suggestions backend returned became the navigation target of an omnibox suggestion. The removed code shows the raw assignment happening unconditionally after the AutocompleteMatch was constructed. The invariant that a suggestion’s destination_url must be a real, navigable web URL was therefore never enforced at parse time, allowing entries with schemes such as javascript:, data:, file:, or ftp: — or entirely malformed strings — to be turned into selectable matches.

The fix parses url into a local destination_url, and continues past the entry (dropping it entirely) unless destination_url.is_valid() and destination_url.SchemeIsHTTPOrHTTPS() both hold, only then assigning it to match.destination_url. Because the guard runs before the match is finalized and appended, non-HTTP(S) and invalid URLs never reach the suggestion list, which the added ParseDocumentSearchResultsDiscardNonHttpAndInvalidUrls test confirms by asserting only the three valid HTTP/HTTPS entries survive.

Key insight
The provider trusted a server-controlled url field as a suggestion’s navigation destination without validating its scheme, so a crafted response could inject a javascript: or other dangerous-scheme match; the fix rejects any result whose URL is invalid or not HTTP/HTTPS before it becomes a match.

Attack Path

  1. Control the response An attacker who can influence the Document suggestions response (a compromised/malicious backend or MITM position (inferred)) returns a results entry whose url is a non-HTTP scheme such as javascript:alert(1); or data:text/html,....
  2. Match is built unvalidated ParseDocumentSearchResults assigns GURL(url) straight into match.destination_url with no scheme check, producing a valid-looking DOCUMENT_SUGGESTION in the omnibox dropdown.
  3. User selects the suggestion When the user picks the crafted suggestion, the omnibox navigates to the attacker-chosen destination_url, causing execution of a javascript:/data: URL in the current context or navigation to an otherwise-blocked scheme (inferred).
  4. Fix blocks the entry After the patch the entry hits the !is_valid() || !SchemeIsHTTPOrHTTPS() guard and is continued, so it never appears as a selectable match.

Impact Assessment

The advisory classifies this as improper input validation in the Omnibox; a successful case lets an attacker place a suggestion that navigates to a non-HTTP(S), attacker-controlled URL (e.g. javascript:), which if selected runs in the browser UI/page context. Exploitation requires the user to trigger Document suggestions and select the malicious entry, and requires the attacker to control the suggestions response. Escalation beyond an attacker-controlled navigation/script-scheme execution is not demonstrated by the patch (inferred).

Changed Functions

FunctionChangeNotes
for
components/omnibox/browser/document_provider_unittest.cc
modified

Files Changed

  • components/omnibox/browser/document_provider_unittest.cc
  • components/omnibox/browser/document_provider.cc

Audit Directions

  • Server-controlled URLs becoming navigation targets
    Audit every autocomplete/suggestion provider that maps a network-supplied field into AutocompleteMatch::destination_url or fill_into_edit, and confirm each validates scheme and validity before use.
  • `GURL` from untrusted strings without scheme checks
    Search for GURL(...) constructed from remote/JSON data and assigned to a navigable sink without a SchemeIsHTTPOrHTTPS/allowlist check.
  • Parse loops that build UI entries
    Review result-parsing loops that append UI-selectable items to ensure invalid entries are skipped (via continue) rather than silently accepted.
From 83357115666f96501a50d1e7db25a811077da06d Mon Sep 17 00:00:00 2001
From: Justin Donnelly <[email protected]>
Date: Wed, 26 Aug 2026 09:04:53 -0700
Subject: [PATCH] Ignore invalid and non-HTTP destination URLs in `DocumentProvider`.

Bug: b:523208474
Change-Id: I3e977b67ae6aecba23d30df386b5a7e18d64868c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8290168
Reviewed-by: Ananya Seelam <[email protected]>
Commit-Queue: Justin Donnelly <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1686444}
---
diff --git a/components/omnibox/browser/document_provider.cc b/components/omnibox/browser/document_provider.cc
index 7fc9eef..1e49dd1 100644
--- a/components/omnibox/browser/document_provider.cc
+++ b/components/omnibox/browser/document_provider.cc
@@ -778,12 +778,20 @@
 
     AutocompleteMatch match(this, score, false,
                             AutocompleteMatchType::DOCUMENT_SUGGESTION);
+    // Only allow valid HTTP or HTTPS URLs.
+    GURL destination_url = GURL(url);
+    if (!destination_url.is_valid() ||
+        !destination_url.SchemeIsHTTPOrHTTPS()) {
+      continue;
+    }
+    match.destination_url = destination_url;
+
     // Use full URL for navigation. If present, use "originalUrl" for display &
     // deduping, as it's shorter.
     const std::string short_url =
         FindStringKeyOrFallback(result, "originalUrl", url);
     match.fill_into_edit = base::UTF8ToUTF16(short_url);
-    match.destination_url = GURL(url);
+
     // `AutocompleteMatch::GURLToStrippedGURL()` will try to use
     // `GetURLForDeduping()` to extract a doc ID and generate a canonical doc
     // URL; this is ideal as it handles different URL formats pointing to the
diff --git a/components/omnibox/browser/document_provider_unittest.cc b/components/omnibox/browser/document_provider_unittest.cc
index a23f32c3..75fa23a 100644
--- a/components/omnibox/browser/document_provider_unittest.cc
+++ b/components/omnibox/browser/document_provider_unittest.cc
@@ -35,6 +35,7 @@
 #include "components/omnibox/common/omnibox_features.h"
 #include "components/search_engines/search_engines_test_environment.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "url/gurl.h"
 
 namespace {
 
@@ -98,7 +99,7 @@
   static std::string MakeTestResponse(const std::vector<std::string>& doc_ids,
                                       int scores) {
     std::string results = "";
-    for (auto doc_id : doc_ids)
+    for (auto doc_id : doc_ids) {
       results += base::StringPrintf(
           R"({
               "title": "Document %s longer title",
@@ -107,6 +108,7 @@
               "originalUrl": "https://drive.google.com/open?id=%s",
             },)",
           doc_id.c_str(), scores, doc_id.c_str(), doc_id.c_str());
+    }
     return base::StringPrintf(R"({"results": [%s]})", results.c_str());
   }
 
@@ -356,6 +358,82 @@
             u"http://sites.google.com/google.com/abc/def");
 }
 
+TEST_F(DocumentProviderTest,
+       ParseDocumentSearchResultsDiscardNonHttpAndInvalidUrls) {
+  const std::string kJSONResponse = R"({
+    "results": [
+      {
+        "title": "Valid HTTPS Document",
+        "url": "https://documentprovider.tld/doc?id=1",
+        "score": 1000
+      },
+      {
+        "title": "JavaScript URL",
+        "url": "javascript:alert(1);",
+        "score": 900
+      },
+      {
+        "title": "Valid HTTP Document",
+        "url": "http://documentprovider.tld/doc?id=2",
+        "score": 800
+      },
+      {
+        "title": "Invalid URL Not A URL",
+        "url": "not a valid url",
+        "score": 700
+      },
+      {
+        "title": "Data URL",
+        "url": "data:text/html,Hello",
+        "score": 600
+      },
+      {
+        "title": "FTP URL",
+        "url": "ftp://example.com/file",
+        "score": 500
+      },
+      {
+        "title": "File URL",
+        "url": "file:///path/to/file",
+        "score": 400
+      },
+      {
+        "title": "Invalid URL Bad Scheme",
+        "url": "http:://google.com",
+        "score": 300
+      },
+      {
+        "title": "Another Valid HTTPS Document",
+        "url": "https://documentprovider.tld/doc?id=3",
+        "score": 200
+      }
+    ]
+  })";
+
+  std::optional<base::Value> response = base::JSONReader::Read(
+      kJSONResponse, base::JSON_PARSE_CHROMIUM_EXTENSIONS);
+  ASSERT_TRUE(response);
+  ASSERT_TRUE(response->is_dict());
+
+  provider_->input_.UpdateText(u"Document", 0, {});
+  ACMatches matches = provider_->ParseDocumentSearchResults(*response);
+
+  // Only the 3 valid HTTP/HTTPS URLs should produce matches.
+  // All non-HTTP and invalid URLs should be discarded.
+  ASSERT_EQ(matches.size(), 3u);
+  EXPECT_EQ(matches[0].destination_url,
+            GURL("https://documentprovider.tld/doc?id=1"));
+  EXPECT_EQ(matches[0].contents, u"Valid HTTPS Document");
+
+  EXPECT_EQ(matches[1].destination_url,
+            GURL("http://documentprovider.tld/doc?id=2"));
+  EXPECT_EQ(matches[1].contents, u"Valid HTTP Document");
+
+  EXPECT_EQ(matches[2].destination_url,
+            GURL("https://documentprovider.tld/doc?id=3"));
+  EXPECT_EQ(matches[2].contents, u"Another Valid HTTPS Document");
+}
+
 #if BUILDFLAG(IS_IOS) && BUILDFLAG(USE_BLINK)
 #define MAYBE_ProductDescriptionStringsAndAccessibleLabels \
   DISABLED_ProductDescriptionStringsAndAccessibleLabels
@@ -1098,65 +1176,180 @@
     ACMatches matches = provider_->ParseDocumentSearchResults(*response);
 
     ASSERT_EQ(matches.size(), expected_scores.size());
-    for (size_t i = 0; i < matches.size(); i++)
+    for (size_t i = 0; i < matches.size(); i++) {
       EXPECT_EQ(matches[i].relevance, expected_scores[i]) << "Match " << i;
+    }
   };
 
   {
     SCOPED_TRACE(
         "Unowned and non-title matching docs are limited. Title matching docs "
         "are not limited.");
-    test(R"({"results": [
-          {"title": "bad title1 title2",  "score": 1000, "url": "good url isn't sufficient"},
-          {"title": "bad title1 title2",  "score": 999,  "url": "url"},
-          {"title": "bad title1 title2",  "score": 998,  "url": "url"},
-          {"title": "goOd tItLE1 title2", "score": 997,  "url": "url"},
-          {"title": "good title1 title2", "score": 996,  "url": "url"},
-          {"title": "good title1 title2", "score": 995,  "url": "url"},
-          {"title": "good title1 title2", "score": 994,  "url": "url"}
-        ]})",
-         // - 'goo': prefix matches are ok.
-         // - 'title1': all input terms must be in the title or owner, but not
-         //   all title terms must be in the input (e.g. 'title2').
-         // - "goOd tItLE1 title2": Case insensitive.
-         "gOo Title1", {1000, 0, 0, 997, 996, 995, 994});
+    test(
+        R"({
+      "results": [
+        {
+          "title": "bad title1 title2",
+          "score": 1000,
+          "url": "https://documentprovider.tld/doc?id=1"
+        },
+        {
+          "title": "bad title1 title2",
+          "score": 999,
+          "url": "https://documentprovider.tld/doc?id=2"
+        },
+        {
+          "title": "bad title1 title2",
+          "score": 998,
+          "url": "https://documentprovider.tld/doc?id=3"
+        },
+        {
+          "title": "goOd tItLE1 title2",
+          "score": 997,
+          "url": "https://documentprovider.tld/doc?id=4"
+        },
+        {
+          "title": "good title1 title2",
+          "score": 996,
+          "url": "https://documentprovider.tld/doc?id=5"
+        },
+        {
+          "title": "good title1 title2",
+          "score": 995,
+          "url": "https://documentprovider.tld/doc?id=6"
+        },
+        {
+          "title": "good title1 title2",
+          "score": 994,
+          "url": "https://documentprovider.tld/doc?id=7"
+        }
+      ]
+    })",
+        // - 'goo': prefix matches are ok.
+        // - 'title1': all input terms must be in the title or owner, but not
+        //   all title terms must be in the input (e.g. 'title2').
+        // - "goOd tItLE1 title2": Case insensitive.
+        "gOo Title1", {1000, 0, 0, 997, 996, 995, 994});
   }
 
   {
     SCOPED_TRACE("Owned docs are not limited.");
     test(
-        R"({"results": [
-          {"title": "bad title1 title2",  "score": 1000, "url": "good url isn't sufficient"},
-          {"title": "bad title1 title2",  "score": 999,  "url": "url"},
-          {"title": "bad title1 title2",  "score": 998,  "url": "url", "metadata": {"owner": {"emailAddresses": [{"emailAddress": "[email protected]"}, {"emailAddress": "[email protected]"}]}}},
-          {"title": "bad title1 title2",  "score": 997,  "url": "url", "metadata": {"owner": {"emailAddresses": [{"emailAddress": "[email protected]"}]}}},
-          {"title": "good title1 title2", "score": 996,  "url": "url"},
-          {"title": "good title1 title2", "score": 995,  "url": "url"},
-          {"title": "good title1 title2", "score": 994,  "url": "url"}
-        ]})",
+        R"({
+      "results": [
+        {
+          "title": "bad title1 title2",
+          "score": 1000,
+          "url": "https://documentprovider.tld/doc?id=1"
+        },
+        {
+          "title": "bad title1 title2",
+          "score": 999,
+          "url": "https://documentprovider.tld/doc?id=2"
+        },
+        {
+          "title": "bad title1 title2",
+          "score": 998,
+          "url": "https://documentprovider.tld/doc?id=3",
+          "metadata": {
+            "owner": {
+              "emailAddresses": [
+                {"emailAddress": "[email protected]"},
+                {"emailAddress": "[email protected]"}
+              ]
+            }
+          }
+        },
+        {
+          "title": "bad title1 title2",
+          "score": 997,
+          "url": "https://documentprovider.tld/doc?id=4",
+          "metadata": {
+            "owner": {
+              "emailAddresses": [
+                {"emailAddress": "[email protected]"}
+              ]
+            }
+          }
+        },
+        {
+          "title": "good title1 title2",
+          "score": 996,
+          "url": "https://documentprovider.tld/doc?id=5"
+        },
+        {
+          "title": "good title1 title2",
+          "score": 995,
+          "url": "https://documentprovider.tld/doc?id=6"
+        },
+        {
+          "title": "good title1 title2",
+          "score": 994,
+          "url": "https://documentprovider.tld/doc?id=7"
+        }
+      ]
+    })",
         "goo title1", {1000, 0, 998, 0, 996, 995, 994});
   }
 
   {
     SCOPED_TRACE("Responses with missing owner don't crash and are limited.");
-    test(R"({"results": [
-            {"title": "title", "score": 1000,  "url": "url", "metadata":
-              { "owner": { "emailAddresses": [{}] } }
-            },
-            {"title": "title", "score": 999,  "url": "url", "metadata":
-              { "owner": { "emailAddresses": [{}] } }
-            },
-            {"title": "title", "score": 998,  "url": "url", "metadata":
-              { "owner": { "emailAddresses": [] } }
-            },
-            {"title": "title", "score": 997,  "url": "url", "metadata":
-              { "owner": {} }
-            },
-            {"title": "title", "score": 996,  "url": "url", "metadata": {}},
-            {"title": "title", "score": 995,  "url": "url"},
-            {}
-          ]})",
-         "input", {1000, 0, 0, 0, 0, 0});
+    test(
+        R"({
+      "results": [
+        {
+          "title": "title",
+          "score": 1000,
+          "url": "https://documentprovider.tld/doc?id=1",
+          "metadata": {
+            "owner": {
+              "emailAddresses": [{}]
+            }
+          }
+        },
+        {
+          "title": "title",
+          "score": 999,
+          "url": "https://documentprovider.tld/doc?id=2",
+          "metadata": {
+            "owner": {
+              "emailAddresses": [{}]
+            }
+          }
+        },
+        {
+          "title": "title",
+          "score": 998,
+          "url": "https://documentprovider.tld/doc?id=3",
+          "metadata": {
+            "owner": {
+              "emailAddresses": []
+            }
+          }
+        },
+        {
+          "title": "title",
+          "score": 997,
+          "url": "https://documentprovider.tld/doc?id=4",
+          "metadata": {
+            "owner": {}
+          }
+        },
+        {
+          "title": "title",
+          "score": 996,
+          "url": "https://documentprovider.tld/doc?id=5",
+          "metadata": {}
+        },
+        {
+          "title": "title",
+          "score": 995,
+          "url": "https://documentprovider.tld/doc?id=6"
+        },
+        {}
+      ]
+    })",
+        "input", {1000, 0, 0, 0, 0, 0});
   }
 }
 
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/omnibox/browser/document_provider_unittest.cc b/components/omnibox/browser/document_provider_unittest.cc
index a23f32c3..75fa23a 100644
--- a/components/omnibox/browser/document_provider_unittest.cc
+++ b/components/omnibox/browser/document_provider_unittest.cc
@@ -35,6 +35,7 @@
 #include "components/omnibox/common/omnibox_features.h"
 #include "components/search_engines/search_engines_test_environment.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "url/gurl.h"
 
 namespace {
 
@@ -98,7 +99,7 @@
   static std::string MakeTestResponse(const std::vector<std::string>& doc_ids,
                                       int scores) {
     std::string results = "";
-    for (auto doc_id : doc_ids)
+    for (auto doc_id : doc_ids) {
       results += base::StringPrintf(
           R"({
               "title": "Document %s longer title",
@@ -107,6 +108,7 @@
               "originalUrl": "https://drive.google.com/open?id=%s",
             },)",
           doc_id.c_str(), scores, doc_id.c_str(), doc_id.c_str());
+    }
     return base::StringPrintf(R"({"results": [%s]})", results.c_str());
   }
 
@@ -356,6 +358,82 @@
             u"http://sites.google.com/google.com/abc/def");
 }
 
+TEST_F(DocumentProviderTest,
+       ParseDocumentSearchResultsDiscardNonHttpAndInvalidUrls) {
+  const std::string kJSONResponse = R"({
+    "results": [
+      {
+        "title": "Valid HTTPS Document",
+        "url": "https://documentprovider.tld/doc?id=1",
+        "score": 1000
+      },
+      {
+        "title": "JavaScript URL",
+        "url": "javascript:alert(1);",
+        "score": 900
+      },
+      {
+        "title": "Valid HTTP Document",
+        "url": "http://documentprovider.tld/doc?id=2",
+        "score": 800
+      },
+      {
+        "title": "Invalid URL Not A URL",
+        "url": "not a valid url",
+        "score": 700
+      },
+      {
+        "title": "Data URL",
+        "url": "data:text/html,Hello",
+        "score": 600
+      },
+      {
+        "title": "FTP URL",
+        "url": "ftp://example.com/file",
+        "score": 500
+      },
+      {
+        "title": "File URL",
+        "url": "file:///path/to/file",
+        "score": 400
+      },
+      {
+        "title": "Invalid URL Bad Scheme",
+        "url": "http:://google.com",
+        "score": 300
+      },
+      {
+        "title": "Another Valid HTTPS Document",
+        "url": "https://documentprovider.tld/doc?id=3",
+        "score": 200
+      }
+    ]
+  })";
+
+  std::optional<base::Value> response = base::JSONReader::Read(
+      kJSONResponse, base::JSON_PARSE_CHROMIUM_EXTENSIONS);
+  ASSERT_TRUE(response);
+  ASSERT_TRUE(response->is_dict());
+
+  provider_->input_.UpdateText(u"Document", 0, {});
+  ACMatches matches = provider_->ParseDocumentSearchResults(*response);
+
+  // Only the 3 valid HTTP/HTTPS URLs should produce matches.
+  // All non-HTTP and invalid URLs should be discarded.
+  ASSERT_EQ(matches.size(), 3u);
+  EXPECT_EQ(matches[0].destination_url,
+            GURL("https://documentprovider.tld/doc?id=1"));
+  EXPECT_EQ(matches[0].contents, u"Valid HTTPS Document");
+
+  EXPECT_EQ(matches[1].destination_url,
+            GURL("http://documentprovider.tld/doc?id=2"));
+  EXPECT_EQ(matches[1].contents, u"Valid HTTP Document");
+
+  EXPECT_EQ(matches[2].destination_url,
+            GURL("https://documentprovider.tld/doc?id=3"));
+  EXPECT_EQ(matches[2].contents, u"Another Valid HTTPS Document");
+}
+
 #if BUILDFLAG(IS_IOS) && BUILDFLAG(USE_BLINK)
 #define MAYBE_ProductDescriptionStringsAndAccessibleLabels \
   DISABLED_ProductDescriptionStringsAndAccessibleLabels
@@ -1098,65 +1176,180 @@
     ACMatches matches = provider_->ParseDocumentSearchResults(*response);
 
     ASSERT_EQ(matches.size(), expected_scores.size());
-    for (size_t i = 0; i < matches.size(); i++)
+    for (size_t i = 0; i < matches.size(); i++) {
       EXPECT_EQ(matches[i].relevance, expected_scores[i]) << "Match " << i;
+    }
   };
 
   {
     SCOPED_TRACE(
         "Unowned and non-title matching docs are limited. Title matching docs "
         "are not limited.");
-    test(R"({"results": [
-          {"title": "bad title1 title2",  "score": 1000, "url": "good url isn't sufficient"},
-          {"title": "bad title1 title2",  "score": 999,  "url": "url"},
-          {"title": "bad title1 title2",  "score": 998,  "url": "url"},
-          {"title": "goOd tItLE1 title2", "score": 997,  "url": "url"},
-          {"title": "good title1 title2", "score": 996,  "url": "url"},
-          {"title": "good title1 title2", "score": 995,  "url": "url"},
-          {"title": "good title1 title2", "score": 994,  "url": "url"}
-        ]})",
-         // - 'goo': prefix matches are ok.
-         // - 'title1': all input terms must be in the title or owner, but not
-         //   all title terms must be in the input (e.g. 'title2').
-         // - "goOd tItLE1 title2": Case insensitive.
-         "gOo Title1", {1000, 0, 0, 997, 996, 995, 994});
+    test(
+        R"({
+      "results": [
+        {
+          "title": "bad title1 title2",
+          "score": 1000,
+          "url": "https://documentprovider.tld/doc?id=1"
+        },
+        {
+          "title": "bad title1 title2",
+          "score": 999,
+          "url": "https://documentprovider.tld/doc?id=2"
+        },
+        {
+          "title": "bad title1 title2",
+          "score": 998,
+          "url": "https://documentprovider.tld/doc?id=3"
+        },
+        {
+          "title": "goOd tItLE1 title2",
+          "score": 997,
+          "url": "https://documentprovider.tld/doc?id=4"
+        },
+        {
+          "title": "good title1 title2",
+          "score": 996,
+          "url": "https://documentprovider.tld/doc?id=5"
+        },
+        {
+          "title": "good title1 title2",
+          "score": 995,
+          "url": "https://documentprovider.tld/doc?id=6"
+        },
+        {
+          "title": "good title1 title2",
+          "score": 994,
+          "url": "https://documentprovider.tld/doc?id=7"
+        }
+      ]
+    })",
+        // - 'goo': prefix matches are ok.
+        // - 'title1': all input terms must be in the title or owner, but not
+        //   all title terms must be in the input (e.g. 'title2').
+        // - "goOd tItLE1 title2": Case insensitive.
+        "gOo Title1", {1000, 0, 0, 997, 996, 995, 994});
   }
 
   {
     SCOPED_TRACE("Owned docs are not limited.");
     test(
-        R"({"results": [
-          {"title": "bad title1 title2",  "score": 1000, "url": "good url isn't sufficient"},
-          {"title": "bad title1 title2",  "score": 999,  "url": "url"},
-          {"title": "bad title1 title2",  "score": 998,  "url": "url", "metadata": {"owner": {"emailAddresses": [{"emailAddress": "[email protected]"}, {"emailAddress": "[email protected]"}]}}},
-          {"title": "bad title1 title2",  "score": 997,  "url": "url", "metadata": {"owner": {"emailAddresses": [{"emailAddress": "[email protected]"}]}}},
-          {"title": "good title1 title2", "score": 996,  "url": "url"},
-          {"title": "good title1 title2", "score": 995,  "url": "url"},
-          {"title": "good title1 title2", "score": 994,  "url": "url"}
-        ]})",
+        R"({
+      "results": [
+        {
+          "title": "bad title1 title2",
+          "score": 1000,
+          "url": "https://documentprovider.tld/doc?id=1"
+        },
+        {
+          "title": "bad title1 title2",
+          "score": 999,
+          "url": "https://documentprovider.tld/doc?id=2"
+        },
+        {
+          "title": "bad title1 title2",
+          "score": 998,
+          "url": "https://documentprovider.tld/doc?id=3",
+          "metadata": {
+            "owner": {
+              "emailAddresses": [
+                {"emailAddress": "[email protected]"},
+                {"emailAddress": "[email protected]"}
+              ]
+            }
+          }
+        },
+        {
+          "title": "bad title1 title2",
+          "score": 997,
+          "url": "https://documentprovider.tld/doc?id=4",
+          "metadata": {
+            "owner": {
+              "emailAddresses": [
+                {"emailAddress": "[email protected]"}
+              ]
+            }
+          }
+        },
+        {
+          "title": "good title1 title2",
+          "score": 996,
+          "url": "https://documentprovider.tld/doc?id=5"
+        },
+        {
+          "title": "good title1 title2",
+          "score": 995,
+          "url": "https://documentprovider.tld/doc?id=6"
+        },
+        {
+          "title": "good title1 title2",
+          "score": 994,
+          "url": "https://documentprovider.tld/doc?id=7"
+        }
+      ]
+    })",
         "goo title1", {1000, 0, 998, 0, 996, 995, 994});
   }
 
   {
     SCOPED_TRACE("Responses with missing owner don't crash and are limited.");
-    test(R"({"results": [
-            {"title": "title", "score": 1000,  "url": "url", "metadata":
-              { "owner": { "emailAddresses": [{}] } }
-            },
-            {"title": "title", "score": 999,  "url": "url", "metadata":
-              { "owner": { "emailAddresses": [{}] } }
-            },
-            {"title": "title", "score": 998,  "url": "url", "metadata":
-              { "owner": { "emailAddresses": [] } }
-            },
-            {"title": "title", "score": 997,  "url": "url", "metadata":
-              { "owner": {} }
-            },
-            {"title": "title", "score": 996,  "url": "url", "metadata": {}},
-            {"title": "title", "score": 995,  "url": "url"},
-            {}
-          ]})",
-         "input", {1000, 0, 0, 0, 0, 0});
+    test(
+        R"({
+      "results": [
+        {
+          "title": "title",
+          "score": 1000,
+          "url": "https://documentprovider.tld/doc?id=1",
+          "metadata": {
+            "owner": {
+              "emailAddresses": [{}]
+            }
+          }
+        },
+        {
+          "title": "title",
+          "score": 999,
+          "url": "https://documentprovider.tld/doc?id=2",
+          "metadata": {
+            "owner": {
+              "emailAddresses": [{}]
+            }
+          }
+        },
+        {
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Potential UXSS via unvalidated javascript: schemes in Omnibox DocumentProvider

Flapjack, 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The Omnibox DocumentProvider fails to validate the protocol scheme of document suggestion URLs retrieved from the Google Drive/Cloud Search API. If an attacker can inject a javascript: URL into the suggestions, selecting it executes the payload in the active tab’s context, leading to a potential Universal Cross-Site Scripting (UXSS).

Affected files:

  • components/omnibox/browser/document_provider.cc

Estimated timestamp from git blame: 2018-07-19

Summary

A potential Universal Cross-Site Scripting (UXSS) vulnerability exists in the Omnibox DocumentProvider. When processing document suggestions from the Google Drive or Cloud Search API, the provider fails to validate that the returned URLs use a safe protocol (e.g., HTTP or HTTPS). If an attacker can cause a javascript: URL to be returned in the search results, selecting that suggestion in the Omnibox will execute the JavaScript payload in the context of the user’s currently active tab.

Technical Details

In components/omnibox/browser/document_provider.cc, the function DocumentProvider::ParseDocumentSearchResults parses the JSON response from the document suggestion service. The url string is extracted directly from the JSON and assigned to match.destination_url using the GURL constructor, which successfully parses javascript: schemes:

    const base::DictValue& result = result_value.GetDict();
    const std::string title = FindStringKeyOrFallback(result, "title");
    const std::string url = FindStringKeyOrFallback(result, "url");
...
    match.destination_url = GURL(url);

There is no subsequent validation to ensure the scheme is safe before the match is presented to the user. This contrasts with other Omnibox providers, such as EnterpriseSearchAggregatorProvider, which explicitly enforce gurl.SchemeIsHTTPOrHTTPS().

While the backend service is generally trusted, defense-in-depth principles require that Chrome validates inputs from any network source, especially when they can lead to script execution. This is particularly relevant for Cloud Search, which can index content from various third-party connectors or shared files.

If a javascript: URL is selected by the user, the execution flow is as follows:

  1. OmniboxEditModel::OpenMatch() validates that the GURL is syntactically valid and passes it to chrome::OpenCurrentURL.
  2. The browser’s navigation router (Navigate()) resolves the target WebContents and calls LoadURLWithParams.
  3. In NavigationControllerImpl::NavigateWithoutEntry, the URL is identified as a renderer debug URL via blink::IsRendererDebugURL.
  4. It bypasses standard navigation and sends an IPC to the active tab’s renderer via HandleRendererDebugURL.
  5. The renderer’s LocalFrameMojoHandler::HandleRendererDebugURL calls LocalFrame::LoadJavaScriptURL, which explicitly executes the script with CSPDisposition::DO_NOT_CHECK in the main world of the currently viewed page, resulting in UXSS.

Potential Attack Scenario

Please note these are suggested steps, as our tooling agent does not have the ability to run code:

  1. An attacker influences the data returned by the Google Drive or Cloud Search API. For example, they might share a malicious document shortcut with a javascript: URL, or use a compromised Cloud Search enterprise connector that provides custom URLs.
  2. The victim types a query into the Omnibox that matches the malicious document.
  3. Chrome fetches the suggestions, parses the unvalidated URL from the JSON response, and presents it in the Omnibox dropdown.
  4. The victim selects the malicious suggestion, intending to navigate to the document.
  5. The javascript: payload executes synchronously within the origin of whatever page the user was currently viewing when they used the Omnibox. This achieves Universal Cross-Site Scripting (UXSS), allowing the attacker to read cross-origin data, manipulate the DOM, or steal session tokens from the victim’s active tab.

Recommendation

Update DocumentProvider::ParseDocumentSearchResults to verify that the suggestion URL is valid and uses a safe scheme (HTTP or HTTPS) before creating an AutocompleteMatch or adding it to the matches list.

    GURL gurl(url);
    if (!gurl.is_valid() || !gurl.SchemeIsHTTPOrHTTPS()) {
      continue;
    }
    // ...
    match.destination_url = gurl;

Evaluated with Chrome root at commit: 2155cb00003ec35716a76ed3246eae995f87b7ff


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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