CVE-2026-84357
Overview
Background
- `DocumentProvider`
- the Omnibox autocomplete provider that turns Google Drive/Docs search results returned by a remote server into
AutocompleteMatchsuggestions. - `ParseDocumentSearchResults`
- the
DocumentProvidermethod (exercised directly by the added test) that iterates the server’s JSONresultsarray and builds oneAutocompleteMatchper entry. - `AutocompleteMatch::destination_url`
- the
GURLan omnibox suggestion navigates to when the user selects it; it is populated from the server-suppliedurlfield. - `GURL::SchemeIsHTTPOrHTTPS` / `GURL::is_valid`
GURLpredicates that report whether a parsed URL is well-formed and whether its scheme ishttp/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.
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
- Control the response
An attacker who can influence the Document suggestions response (a compromised/malicious backend or MITM position
(inferred)) returns aresultsentry whoseurlis a non-HTTP scheme such asjavascript:alert(1);ordata:text/html,.... - Match is built unvalidated
ParseDocumentSearchResultsassignsGURL(url)straight intomatch.destination_urlwith no scheme check, producing a valid-lookingDOCUMENT_SUGGESTIONin the omnibox dropdown. - User selects the suggestion
When the user picks the crafted suggestion, the omnibox navigates to the attacker-chosen
destination_url, causing execution of ajavascript:/data:URL in the current context or navigation to an otherwise-blocked scheme(inferred). - Fix blocks the entry
After the patch the entry hits the
!is_valid() || !SchemeIsHTTPOrHTTPS()guard and iscontinued, so it never appears as a selectable match.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
forcomponents/omnibox/browser/document_provider_unittest.cc |
modified |
Files Changed
components/omnibox/browser/document_provider_unittest.cccomponents/omnibox/browser/document_provider.cc
Audit Directions
- Server-controlled URLs becoming navigation targetsAudit every autocomplete/suggestion provider that maps a network-supplied field into
AutocompleteMatch::destination_urlorfill_into_edit, and confirm each validates scheme and validity before use. - `GURL` from untrusted strings without scheme checksSearch for
GURL(...)constructed from remote/JSON data and assigned to a navigable sink without aSchemeIsHTTPOrHTTPS/allowlist check. - Parse loops that build UI entriesReview result-parsing loops that append UI-selectable items to ensure invalid entries are skipped (via
continue) rather than silently accepted.
Patch
From 83357115666f96501a50d1e7db25a811077da06d Mon Sep 17 00:00:00 2001
From: Justin Donnelly <jdonnelly@chromium.org>
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 <ananyaseelam@google.com>
Commit-Queue: Justin Donnelly <jdonnelly@chromium.org>
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": "badEmail1@gmail.com"}, {"emailAddress": "gOOdemaIl@gmail.com"}]}}},
- {"title": "bad title1 title2", "score": 997, "url": "url", "metadata": {"owner": {"emailAddresses": [{"emailAddress": "badEmail2@gmail.com"}]}}},
- {"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": "badEmail1@gmail.com"},
+ {"emailAddress": "gOOdemaIl@gmail.com"}
+ ]
+ }
+ }
+ },
+ {
+ "title": "bad title1 title2",
+ "score": 997,
+ "url": "https://documentprovider.tld/doc?id=4",
+ "metadata": {
+ "owner": {
+ "emailAddresses": [
+ {"emailAddress": "badEmail2@gmail.com"}
+ ]
+ }
+ }
+ },
+ {
+ "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});
}
}
Regression Test / PoC
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": "badEmail1@gmail.com"}, {"emailAddress": "gOOdemaIl@gmail.com"}]}}},
- {"title": "bad title1 title2", "score": 997, "url": "url", "metadata": {"owner": {"emailAddresses": [{"emailAddress": "badEmail2@gmail.com"}]}}},
- {"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": "badEmail1@gmail.com"},
+ {"emailAddress": "gOOdemaIl@gmail.com"}
+ ]
+ }
+ }
+ },
+ {
+ "title": "bad title1 title2",
+ "score": 997,
+ "url": "https://documentprovider.tld/doc?id=4",
+ "metadata": {
+ "owner": {
+ "emailAddresses": [
+ {"emailAddress": "badEmail2@gmail.com"}
+ ]
+ }
+ }
+ },
+ {
+ "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)
Original Bug Report
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:
OmniboxEditModel::OpenMatch()validates that theGURLis syntactically valid and passes it tochrome::OpenCurrentURL.- The browser’s navigation router (
Navigate()) resolves the target WebContents and callsLoadURLWithParams. - In
NavigationControllerImpl::NavigateWithoutEntry, the URL is identified as a renderer debug URL viablink::IsRendererDebugURL. - It bypasses standard navigation and sends an IPC to the active tab’s renderer via
HandleRendererDebugURL. - The renderer’s
LocalFrameMojoHandler::HandleRendererDebugURLcallsLocalFrame::LoadJavaScriptURL, which explicitly executes the script withCSPDisposition::DO_NOT_CHECKin 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:
- 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. - The victim types a query into the Omnibox that matches the malicious document.
- Chrome fetches the suggestions, parses the unvalidated URL from the JSON response, and presents it in the Omnibox dropdown.
- The victim selects the malicious suggestion, intending to navigate to the document.
- 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.