CVE-2026-95341
Overview
Background
- Middle-slot promo
- A promotional card injected into the middle of the New Tab Page whose content was fetched at runtime from a Google endpoint (
/async/newtab_promos) as JSON. - `PromoService`
- The browser-side service (
chrome/browser/new_tab_page/promos/promo_service.cc) that downloaded, XSSI-stripped, and parsed the remote promo payload into aPromoDatastruct. - `PromoData`
- The struct (
middle_slot_json,promo_log_url,promo_id,can_open_extensions_page) passed over Mojo to the NTP WebUI renderer for rendering and command dispatch. - XSSI preamble
- The
)]}'prefix (kXSSIResponsePreamble) stripped from the server response before the remainder is treated as trusted JSON.
Root Cause Analysis
The middle-slot promo path took a server-controlled JSON blob, stripped the XSSI preamble, parsed it, and forwarded the raw middle_slot_json, a promo_log_url, and a can_open_extensions_page flag into the privileged NTP WebUI via Mojo. Because the payload was treated as trusted structured data rather than validated untrusted input, a malformed or attacker-influenced promo response could drive the WebUI into unintended behavior — for example honoring can_open_extensions_page to navigate to chrome://extensions, or rendering markup/URLs the renderer did not adequately sanitize. The invariant violated is that data crossing the network-to-WebUI boundary must be validated before it can influence privileged UI or command dispatch, which this feature did not fully enforce.
The fix removes the entire feature — the PromoService, PromoData, the associated Mojo APIs, the NewTabPageHandler backend logic, and the middle_slot_promo WebUI component — eliminating the untrusted-input path altogether. With the code path deleted, the improperly validated data can no longer reach the New Tab Page.
Attack Path
- Influence the promo response
An attacker who can shape the
/async/newtab_promosresponse (or the JSON it delivers) supplies a craftedmiddle_slot_jsonpayload with a manipulatedpromo_log_urlorcan_open_extensions_pageflag. - Parse as trusted data
PromoServicestrips the)]}'XSSI preamble and parses the remainder into aPromoDatastruct without fully validating the fields against expectations. - Cross into the WebUI
NewTabPageHandlerforwards thePromoDataover the NTP Mojo interface to the renderer-sidemiddle_slot_promocomponent, which renders it and can act on its embedded commands.(inferred) - Trigger unintended action
The WebUI honors an attacker-influenced field (for example navigating to
chrome://extensionsviacan_open_extensions_page, or acting on unsanitized markup/URLs), yielding behavior outside the promo’s intended scope.(inferred)
Impact Assessment
(inferred)Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/new_tab_page/promos/promo_service.cc |
modified | |
forchrome/browser/new_tab_page/promos/promo_service.cc |
modified |
Files Changed
chrome/browser/new_tab_page/promos/promo_service_factory.hchrome/browser/ui/webui/new_tab_page/new_tab_page.mojomchrome/browser/new_tab_page/promos/promo_service_factory.ccchrome/browser/ui/webui/new_tab_page/new_tab_page_ui.ccchrome/browser/new_tab_page/promos/promo_service.hchrome/browser/ui/webui/browser_command/browser_command_handler.cctools/traffic_annotation/summary/grouping.xmlchrome/browser/new_tab_page/promos/promo_data.ccchrome/browser/resources/new_tab_page/app.htmlchrome/browser/new_tab_page/promos/promo_service_unittest.cctools/traffic_annotation/summary/annotations.xmlchrome/browser/prefs/browser_prefs.ccchrome/browser/new_tab_page/BUILD.gnchrome/browser/resources/new_tab_page/app.tschrome/browser/ui/webui/new_tab_page/new_tab_page_handler.ccchrome/browser/resources/new_tab_page/middle_slot_promo.csschrome/common/pref_names.hchrome/browser/ui/webui/new_tab_page/new_tab_page_handler_browsertest.ccchrome/test/data/webui/new_tab_page/middle_slot_promo_test.tschrome/browser/new_tab_page/promos/promo_service_observer.hchrome/browser/profiles/chrome_browser_main_extra_parts_profiles.ccchrome/test/data/webui/new_tab_page/BUILD.gnchrome/browser/new_tab_page/promos/promo_service.ccchrome/browser/ui/webui/new_tab_page/new_tab_page_handler_unittest.ccchrome/browser/resources/new_tab_page/lazy_load.tschrome/browser/new_tab_page/promos/promo_data.hchrome/browser/resources/new_tab_page/middle_slot_promo.tschrome/browser/ui/webui/new_tab_page/new_tab_page_handler.hchrome/test/data/webui/new_tab_page/new_tab_page_browsertest.ccchrome/browser/resources/new_tab_page/app.csschrome/browser/resources/new_tab_page/middle_slot_promo.htmlchrome/browser/resources/new_tab_page/new_tab_page.gnichrome/test/data/webui/new_tab_page/app_test.ts
Audit Directions
- Server-controlled payloads into WebUIAudit other NTP/WebUI features that fetch remote JSON and forward it over Mojo, confirming each field is validated before it can influence navigation, markup, or command dispatch.
- Privileged-action flags from remote dataReview any boolean or URL fields (like
can_open_extensions_page/promo_log_url) that gate access tochrome://pages or logging pings, ensuring they cannot be flipped by an untrusted response. - XSSI-stripped responses treated as trustedLook for other call sites that strip a
)]}'preamble and then parse the remainder as trusted structured data without schema or origin validation.
Patch
From dd7083a019ca5d30c671bdeacac0834276d6236c Mon Sep 17 00:00:00 2001 From: Dhruv Kathpalia <[email protected]> Date: Mon, 31 Aug 2026 13:59:37 -0700 Subject: [PATCH] Remove the NTP middle slot promo This CL removes the middle slot promo feature from the New Tab Page (NTP). This includes deleting the ntp-middle-slot-promo WebUI component, removing its associated Mojo APIs, cleaning up the backend handler logic in NewTabPageHandler, and deleting the corresponding unit and browser tests. Fixed: b:523719002 Change-Id: Iec4aec861ffb3adecb54306421d269024dcf6f27 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8290051 Reviewed-by: Tibor Goldschwendt <[email protected]> Reviewed-by: Nicolas Ouellet-Payeur <[email protected]> Auto-Submit: Dhruv Kathpalia <[email protected]> Commit-Queue: Dhruv Kathpalia <[email protected]> Cr-Commit-Position: refs/heads/main@{#1689239} --- diff --git a/chrome/browser/new_tab_page/promos/promo_data.cc b/chrome/browser/new_tab_page/promos/promo_data.cc deleted file mode 100644 index 0127d343..0000000 --- a/chrome/browser/new_tab_page/promos/promo_data.cc +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2018 The Chromium Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#include "chrome/browser/new_tab_page/promos/promo_data.h" - -PromoData::PromoData() = default; -PromoData::PromoData(const PromoData&) = default; -PromoData::PromoData(PromoData&&) = default; -PromoData::~PromoData() = default; - -PromoData& PromoData::operator=(const PromoData&) = default; -PromoData& PromoData::operator=(PromoData&&) = default; - -bool operator==(const PromoData& lhs, const PromoData& rhs) { - return lhs.middle_slot_json == rhs.middle_slot_json && - lhs.promo_log_url == rhs.promo_log_url && lhs.promo_id == rhs.promo_id; -} diff --git a/chrome/browser/new_tab_page/promos/promo_data.h b/chrome/browser/new_tab_page/promos/promo_data.h deleted file mode 100644 index 4c02ecb6..0000000 --- a/chrome/browser/new_tab_page/promos/promo_data.h +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2018 The Chromium Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#ifndef CHROME_BROWSER_NEW_TAB_PAGE_PROMOS_PROMO_DATA_H_ -#define CHROME_BROWSER_NEW_TAB_PAGE_PROMOS_PROMO_DATA_H_ - -#include <string> - -#include "url/gurl.h" - -// This struct contains all the data needed to inject a middle-slot Promo into -// a page. -struct PromoData { - PromoData(); - PromoData(const PromoData&); - PromoData(PromoData&&); - ~PromoData(); - - PromoData& operator=(const PromoData&); - PromoData& operator=(PromoData&&); - - // The structured JSON data of the middle slot promo. - std::string middle_slot_json; - - // URL to ping to log a promo impression. May be invalid. - GURL promo_log_url; - - // The unique identifier for this promo. May be empty. - std::string promo_id; - - // Allow the promo to open chrome://extensions for the extensions checkup - // experiment. - bool can_open_extensions_page = false; -}; - -bool operator==(const PromoData& lhs, const PromoData& rhs); - -#endif // CHROME_BROWSER_NEW_TAB_PAGE_PROMOS_PROMO_DATA_H_ diff --git a/chrome/browser/new_tab_page/promos/promo_service.cc b/chrome/browser/new_tab_page/promos/promo_service.cc deleted file mode 100644 index c7dc20b..0000000 --- a/chrome/browser/new_tab_page/promos/promo_service.cc +++ /dev/null @@ -1,379 +0,0 @@ -// Copyright 2018 The Chromium Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#include "chrome/browser/new_tab_page/promos/promo_service.h" - -#include <optional> -#include <string> -#include <utility> - -#include "base/feature_list.h" -#include "base/functional/bind.h" -#include "base/functional/callback.h" -#include "base/json/json_reader.h" -#include "base/json/json_string_value_serializer.h" -#include "base/metrics/field_trial_params.h" -#include "base/observer_list.h" -#include "base/strings/strcat.h" -#include "base/strings/string_number_conversions.h" -#include "base/strings/stringprintf.h" -#include "base/task/sequenced_task_runner.h" -#include "base/time/time.h" -#include "base/values.h" -#include "chrome/browser/profiles/profile.h" -#include "chrome/common/pref_names.h" -#include "chrome/common/webui_url_constants.h" -#include "components/google/core/common/google_util.h" -#include "components/prefs/pref_service.h" -#include "components/prefs/scoped_user_pref_update.h" -#include "components/search/ntp_features.h" -#include "extensions/common/extension_features.h" -#include "net/base/load_flags.h" -#include "net/base/url_util.h" -#include "net/traffic_annotation/network_traffic_annotation.h" -#include "services/network/public/cpp/resource_request.h" -#include "services/network/public/cpp/shared_url_loader_factory.h" -#include "services/network/public/cpp/simple_url_loader.h" -#include "ui/base/l10n/l10n_util.h" -#include "ui/webui/resources/js/browser_command/browser_command.mojom.h" - -namespace { - -// The number of days until a blocklist entry expires. -const int kDaysThatBlocklistExpiresIn = 28; - -const char kNewTabPromosApiPath[] = "/async/newtab_promos"; - -const char kXSSIResponsePreamble[] = ")]}'"; - -constexpr char kWarningSymbol[] = - "data:image/" - "svg+xml;base64," - "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii01IC" - "01IDU4IDU4IiBmaWxsPSIjZmRkNjMzIj48cGF0aCBkPSJNMiA0Mmg0NEwyNCA0IDIgNDJ6" - "bTI0LTZoLTR2LTRoNHY0em0wLThoLTR2LThoNHY4eiIvPjwvc3ZnPg=="; -constexpr char kFakePromo[] = R"({ - "update": { - "promos": { - "middle": "test", - "middle_announce_payload": { - "hidden": false, - "part": [{ - "image": { - "image_url": "%s", - "target": "command:%s" - } - },{ - "link": { - "url": "command:%s", - "text": "Test command: %s" - } - }] - }, - "id": "test%s" - } - } -})"; - -bool CanBlockPromos() { - return base::FeatureList::IsEnabled( - ntp_features::kNtpMiddleSlotPromoDismissal); -} - -GURL GetGoogleBaseUrl() { - GURL google_base_url = google_util::CommandLineGoogleBaseURL(); - if (!google_base_url.is_valid()) { - google_base_url = GURL(google_util::kGoogleHomepageURL); - } - return google_base_url; -} - -GURL GetApiUrl() { - return GetGoogleBaseUrl().Resolve(kNewTabPromosApiPath); -} - -// Parses an update proto from |value|. Will return false if |value| is not of -// the form: {"update":{"promos":{"middle_announce_payload": ""}}}, and true -// otherwise. -// Additionally, there can be a "log_url" or "id" field in the promo. Those are -// populated if found. They're not set for emergency promos. |data| will never -// be std::nullopt if top level dictionary keys of "update" and "promos" are -// present. Note: the "log_url" (if found), is resolved against -// GetGoogleBaseUrl() to form a valid GURL. -bool JsonToPromoData(const base::DictValue& dict, - std::optional<PromoData>* data) { - *data = std::nullopt; - - const base::DictValue* update = dict.FindDict("update"); - if (!update) { - DVLOG(1) << "Parse error: no update"; - return false; - } - - const base::DictValue* promos = update->FindDict("promos"); - if (!promos) { - DVLOG(1) << "Parse error: no promos"; - return false; - } - - PromoData result; - *data = result; - - const base::DictValue* middle_announce_payload = - promos->FindDict("middle_announce_payload"); - if (!middle_announce_payload) { - DVLOG(1) << "No middle announce payload"; - return false; - } - JSONStringValueSerializer serializer(&result.middle_slot_json); - serializer.Serialize(*middle_announce_payload); - - const std::string* maybe_log_url = promos->FindString("log_url"); - // Emergency promos don't have these, so it's OK if this key is missing. - std::string log_url = maybe_log_url ? *maybe_log_url : std::string(); - - GURL promo_log_url; - if (!log_url.empty()) - promo_log_url = GetGoogleBaseUrl().Resolve(log_url); - - std::string promo_id; - if (CanBlockPromos()) { - const std::string* maybe_promo_id = promos->FindString("id"); - if (maybe_promo_id) - promo_id = *maybe_promo_id; - else - net::GetValueForKeyInQuery(promo_log_url, "id", &promo_id); - } - - // Emergency promos may not have IDs, which is OK. They also can't be - // dismissed (because of this). - - result.promo_log_url = promo_log_url; - result.promo_id = promo_id; - - *data = result; - - return true; -} - -} // namespace - -PromoService::PromoService( - scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory, - Profile* profile) - : url_loader_factory_(url_loader_factory), profile_(profile) {} - -PromoService::~PromoService() = default; - -void PromoService::Refresh() { - std::string command_id; - // Replace the promo URL with "command:<id>" if such a command ID is set - // via the feature params. - // If fake data is being used, we set the command_id to 7, which corresponds - // to kNoOpCommand in - // ui/webui/resources/js/browser_command/browser_command.mojom - if (base::GetFieldTrialParamValueByFeature( - ntp_features::kNtpMiddleSlotPromoDismissal, - ntp_features::kNtpMiddleSlotPromoDismissalParam) == "fake") { - command_id = base::NumberToString( - static_cast<int>(browser_command::mojom::Command::kNoOpCommand)); - } - - if (!command_id.empty()) { - auto fake_promo_json = std::make_optional<std::string>(base::StringPrintf( - kFakePromo, kWarningSymbol, command_id.c_str(), command_id.c_str(), - command_id.c_str(), command_id.c_str())); - base::SequencedTaskRunner::GetCurrentDefault()->PostTask( - FROM_HERE, base::BindOnce(&PromoService::OnLoadDone, - weak_ptr_factory_.GetWeakPtr(), - std::move(fake_promo_json))); - return; - } - - net::NetworkTrafficAnnotationTag traffic_annotation = - net::DefineNetworkTrafficAnnotation("promo_service", R"( - semantics { - sender: "Promo Service" - description: "Downloads promos." - trigger: - "Displaying the new tab page on Desktop, if Google is the " - "configured search provider." - data: "None." - destination: GOOGLE_OWNED_SERVICE - } - policy { - cookies_allowed: NO - setting: - "Users can control this feature via selecting a non-Google default " - "search engine in Chrome settings under 'Search Engine'." - chrome_policy { - DefaultSearchProviderEnabled { - policy_options {mode: MANDATORY} - DefaultSearchProviderEnabled: false - } - } - })"); - - auto resource_request = std::make_unique<network::ResourceRequest>(); - resource_request->url = GetApiUrl(); - resource_request->request_initiator = - url::Origin::Create(chrome::ChromeUINewTabURLAsGURL()); - resource_request->credentials_mode = network::mojom::CredentialsMode::kOmit; - - simple_loader_ = network::SimpleURLLoader::Create(std::move(resource_request), - traffic_annotation); - simple_loader_->DownloadToString( - url_loader_factory_.get(), - base::BindOnce(&PromoService::OnLoadDone, base::Unretained(this)), - 1024 * 1024); -} - -void PromoService::OnLoadDone(std::optional<std::string> response_body) { - if (!response_body) { - // This represents network errors (i.e. the server did not provide a - // response). - DVLOG(1) << "Request failed with error: " << simple_loader_->NetError(); - PromoDataLoaded(Status::TRANSIENT_ERROR, std::nullopt); - return; - } - - std::string response = std::move(response_body).value(); - - // The response may start with )]}'. Ignore this. - auto remainder = base::RemovePrefix(response, kXSSIResponsePreamble); - if (remainder) { - response = std::string(*remainder); - } - std::optional<base::DictValue> value = - base::JSONReader::ReadDict(response, base::JSON_PARSE_RFC); - if (!value) { - DVLOG(1) << "Parsing JSON failed"; - PromoDataLoaded(Status::FATAL_ERROR, std::nullopt); - return; - } - - std::optional<PromoData> data; - PromoService::Status status; - - if (JsonToPromoData(*value, &data)) { - bool is_blocked = IsBlockedAfterClearingExpired(data->promo_id); - if (is_blocked) { - data = PromoData(); - } - status = is_blocked ? Status::OK_BUT_BLOCKED : Status::OK_WITH_PROMO; - } else { - status = data ? Status::OK_WITHOUT_PROMO : Status::FATAL_ERROR; - } - - PromoDataLoaded(status, data); -} - -void PromoService::Shutdown() { - for (auto& observer : observers_) { - observer.OnPromoServiceShuttingDown(); - } - - DCHECK(observers_.empty()); -} - -// static -void PromoService::RegisterProfilePrefs(PrefRegistrySimple* registry) { - registry->RegisterDictionaryPref(prefs::kNtpPromoBlocklist); -} - -const std::optional<PromoData>& PromoService::promo_data() const { - return promo_data_; -} - -void PromoService::AddObserver(PromoServiceObserver* observer) { - observers_.AddObserver(observer); -} - -void PromoService::RemoveObserver(PromoServiceObserver* observer) { - observers_.RemoveObserver(observer); -} - -void PromoService::BlocklistPromo(const std::string& promo_id) { - if (!CanBlockPromos() || promo_id.empty() || - IsBlockedAfterClearingExpired(promo_id)) { - return; - } - - ScopedDictPrefUpdate update(profile_->GetPrefs(), prefs::kNtpPromoBlocklist); - double now = base::Time::Now().ToDeltaSinceWindowsEpoch().InSecondsF(); - update->Set(promo_id, now); ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/new_tab_page/promos/promo_service_unittest.cc b/chrome/browser/new_tab_page/promos/promo_service_unittest.cc
deleted file mode 100644
index 85e9820..0000000
--- a/chrome/browser/new_tab_page/promos/promo_service_unittest.cc
+++ /dev/null
@@ -1,390 +0,0 @@
-// Copyright 2018 The Chromium Authors
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-#include "chrome/browser/new_tab_page/promos/promo_service.h"
-
-#include <utility>
-#include <vector>
-
-#include "base/run_loop.h"
-#include "base/strings/string_number_conversions.h"
-#include "base/test/bind.h"
-#include "base/test/scoped_feature_list.h"
-#include "base/test/task_environment.h"
-#include "chrome/browser/new_tab_page/promos/promo_data.h"
-#include "chrome/common/pref_names.h"
-#include "chrome/test/base/testing_profile.h"
-#include "components/prefs/pref_service.h"
-#include "components/prefs/scoped_user_pref_update.h"
-#include "components/search/ntp_features.h"
-#include "components/signin/public/identity_manager/identity_test_environment.h"
-#include "content/public/test/browser_task_environment.h"
-#include "services/network/public/cpp/weak_wrapper_shared_url_loader_factory.h"
-#include "services/network/public/mojom/url_response_head.mojom.h"
-#include "services/network/test/test_url_loader_factory.h"
-#include "testing/gmock/include/gmock/gmock.h"
-#include "testing/gtest/include/gtest/gtest.h"
-#include "ui/base/l10n/l10n_util.h"
-#include "ui/webui/resources/js/browser_command/browser_command.mojom.h"
-#include "url/gurl.h"
-
-using testing::Eq;
-using testing::StartsWith;
-
-class PromoServiceTest : public testing::Test {
- public:
- PromoServiceTest()
- : task_environment_(content::BrowserTaskEnvironment::IO_MAINLOOP),
- test_shared_loader_factory_(
- base::MakeRefCounted<network::WeakWrapperSharedURLLoaderFactory>(
- &test_url_loader_factory_)) {}
-
- void SetUp() override {
- testing::Test::SetUp();
-
- service_ =
- std::make_unique<PromoService>(test_shared_loader_factory_, &profile_);
- }
-
- void SetUpResponseWithData(const GURL& load_url,
- const std::string& response) {
- test_url_loader_factory_.SetInterceptor(base::BindLambdaForTesting(
- [&](const network::ResourceRequest& request) {}));
- test_url_loader_factory_.AddResponse(load_url.spec(), response);
- }
-
- void SetUpResponseWithNetworkError(const GURL& load_url) {
- test_url_loader_factory_.AddResponse(
- load_url, network::mojom::URLResponseHead::New(), std::string(),
- network::URLLoaderCompletionStatus(net::HTTP_NOT_FOUND));
- }
-
- PromoService* service() { return service_.get(); }
- PrefService* prefs() { return profile_.GetPrefs(); }
- network::TestURLLoaderFactory& test_url_loader_factory() {
- return test_url_loader_factory_;
- }
-
- protected:
- // Required to run tests from UI and threads.
- content::BrowserTaskEnvironment task_environment_{
- base::test::TaskEnvironment::ThreadPoolExecutionMode::QUEUED};
-
- private:
-
- network::TestURLLoaderFactory test_url_loader_factory_;
- scoped_refptr<network::SharedURLLoaderFactory> test_shared_loader_factory_;
-
- std::unique_ptr<PromoService> service_;
-
- TestingProfile profile_;
-};
-
-TEST_F(PromoServiceTest, PromoDataNetworkError) {
- SetUpResponseWithNetworkError(service()->GetLoadURLForTesting());
-
- ASSERT_EQ(service()->promo_data(), std::nullopt);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::NOT_UPDATED);
-
- service()->Refresh();
- task_environment_.RunUntilIdle();
-
- EXPECT_EQ(service()->promo_data(), std::nullopt);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::TRANSIENT_ERROR);
-}
-
-TEST_F(PromoServiceTest, BadPromoResponse) {
- SetUpResponseWithData(service()->GetLoadURLForTesting(),
- "{\"update\":{\"promotions\":{}}}");
-
- ASSERT_EQ(service()->promo_data(), std::nullopt);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::NOT_UPDATED);
-
- service()->Refresh();
- task_environment_.RunUntilIdle();
-
- EXPECT_EQ(service()->promo_data(), std::nullopt);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::FATAL_ERROR);
-}
-
-TEST_F(PromoServiceTest, PromoResponseMissingData) {
- SetUpResponseWithData(service()->GetLoadURLForTesting(),
- "{\"update\":{\"promos\":{}}}");
-
- ASSERT_EQ(service()->promo_data(), std::nullopt);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::NOT_UPDATED);
-
- service()->Refresh();
- task_environment_.RunUntilIdle();
-
- EXPECT_EQ(service()->promo_data(), PromoData());
- EXPECT_EQ(service()->promo_status(), PromoService::Status::OK_WITHOUT_PROMO);
-}
-
-TEST_F(PromoServiceTest, GoodPromoResponse) {
- std::string response_string =
- "{\"update\":{\"promos\":{\"middle_announce_payload\":"
- "{\"part\":[{\"text\":{\"text\":\"Foo\"}}]},"
- "\"log_url\":\"/log_url?id=42\",\"id\":\"42\"}}}";
- SetUpResponseWithData(service()->GetLoadURLForTesting(), response_string);
-
- ASSERT_EQ(service()->promo_data(), std::nullopt);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::NOT_UPDATED);
-
- service()->Refresh();
- task_environment_.RunUntilIdle();
-
- PromoData promo;
- promo.middle_slot_json = "{\"part\":[{\"text\":{\"text\":\"Foo\"}}]}";
- promo.promo_log_url = GURL("https://www.google.com/log_url?id=42");
- promo.promo_id = "42";
-
- EXPECT_EQ(service()->promo_data(), promo);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::OK_WITH_PROMO);
-}
-
-TEST_F(PromoServiceTest, GoodPromoResponseCanDismiss) {
- base::test::ScopedFeatureList feature_list;
- feature_list.InitAndEnableFeature(ntp_features::kNtpMiddleSlotPromoDismissal);
-
- std::string response_string =
- "{\"update\":{\"promos\":{\"middle_announce_payload\":"
- "{\"part\":[{\"text\":{\"text\":\"Foo\"}}]},"
- "\"log_url\":\"/log_url?id=42\",\"id\":\"42\"}}}";
- SetUpResponseWithData(service()->GetLoadURLForTesting(), response_string);
-
- ASSERT_EQ(service()->promo_data(), std::nullopt);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::NOT_UPDATED);
-
- service()->Refresh();
- task_environment_.RunUntilIdle();
-
- PromoData promo;
- promo.middle_slot_json = "{\"part\":[{\"text\":{\"text\":\"Foo\"}}]}";
- promo.promo_log_url = GURL("https://www.google.com/log_url?id=42");
- promo.promo_id = "42";
-
- EXPECT_EQ(service()->promo_data(), promo);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::OK_WITH_PROMO);
-}
-
-TEST_F(PromoServiceTest, GoodPromoResponseNoIdField) {
- base::test::ScopedFeatureList feature_list;
- feature_list.InitAndEnableFeature(ntp_features::kNtpMiddleSlotPromoDismissal);
-
- std::string response_string =
- "{\"update\":{\"promos\":{\"middle_announce_payload\":"
- "{\"part\":[{\"text\":{\"text\":\"Foo\"}}]},"
- "\"log_url\":\"/log_url?id=42\"}}}";
- SetUpResponseWithData(service()->GetLoadURLForTesting(), response_string);
-
- ASSERT_EQ(service()->promo_data(), std::nullopt);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::NOT_UPDATED);
-
- service()->Refresh();
- task_environment_.RunUntilIdle();
-
- PromoData promo;
- promo.middle_slot_json = "{\"part\":[{\"text\":{\"text\":\"Foo\"}}]}";
- promo.promo_log_url = GURL("https://www.google.com/log_url?id=42");
- promo.promo_id = "42";
-
- EXPECT_EQ(service()->promo_data(), promo);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::OK_WITH_PROMO);
-}
-
-TEST_F(PromoServiceTest, GoodPromoResponseNoIdFieldNorLogUrl) {
- base::test::ScopedFeatureList feature_list;
- feature_list.InitAndEnableFeature(ntp_features::kNtpMiddleSlotPromoDismissal);
-
- std::string response_string =
- "{\"update\":{\"promos\":{\"middle_announce_payload\":"
- "{\"part\":[{\"text\":{\"text\":\"Foo\"}}]}}}}";
- SetUpResponseWithData(service()->GetLoadURLForTesting(), response_string);
-
- ASSERT_EQ(service()->promo_data(), std::nullopt);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::NOT_UPDATED);
-
- service()->Refresh();
- task_environment_.RunUntilIdle();
-
- PromoData promo;
- promo.middle_slot_json = "{\"part\":[{\"text\":{\"text\":\"Foo\"}}]}";
-
- EXPECT_EQ(service()->promo_data(), promo);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::OK_WITH_PROMO);
-}
-
-TEST_F(PromoServiceTest, GoodPromoWithBlockedID) {
- base::test::ScopedFeatureList feature_list;
- feature_list.InitAndEnableFeature(ntp_features::kNtpMiddleSlotPromoDismissal);
-
- {
- ScopedDictPrefUpdate update(prefs(), prefs::kNtpPromoBlocklist);
- base::Time recent = base::Time::Now() - base::Hours(2);
- update->Set("42", recent.ToDeltaSinceWindowsEpoch().InSecondsF());
- }
-
- std::string response_string =
- "{\"update\":{\"promos\":{\"middle_announce_payload\":"
- "{\"part\":[{\"text\":{\"text\":\"Foo\"}}]},"
- "\"log_url\":\"/log_url?id=42\",\"id\":\"42\"}}}";
- SetUpResponseWithData(service()->GetLoadURLForTesting(), response_string);
-
- ASSERT_EQ(service()->promo_data(), std::nullopt);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::NOT_UPDATED);
-
- service()->Refresh();
- task_environment_.RunUntilIdle();
-
- EXPECT_EQ(service()->promo_data(), PromoData());
- EXPECT_EQ(service()->promo_status(), PromoService::Status::OK_BUT_BLOCKED);
-}
-
-TEST_F(PromoServiceTest, BlocklistPromo) {
- base::test::ScopedFeatureList feature_list;
- feature_list.InitAndEnableFeature(ntp_features::kNtpMiddleSlotPromoDismissal);
-
- std::string response_string =
- "{\"update\":{\"promos\":{\"middle_announce_payload\":"
- "{\"part\":[{\"text\":{\"text\":\"Foo\"}}]},"
- "\"log_url\":\"/log_url?id=42\",\"id\":\"42\"}}}";
- SetUpResponseWithData(service()->GetLoadURLForTesting(), response_string);
-
- ASSERT_EQ(service()->promo_data(), std::nullopt);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::NOT_UPDATED);
-
- service()->Refresh();
- task_environment_.RunUntilIdle();
-
- PromoData promo;
- promo.middle_slot_json = "{\"part\":[{\"text\":{\"text\":\"Foo\"}}]}";
- promo.promo_log_url = GURL("https://www.google.com/log_url?id=42");
- promo.promo_id = "42";
-
- EXPECT_EQ(service()->promo_data(), promo);
- EXPECT_EQ(service()->promo_status(), PromoService::Status::OK_WITH_PROMO);
-
- ASSERT_EQ(0u, prefs()->GetDict(prefs::kNtpPromoBlocklist).size());
-
- service()->BlocklistPromo("42");
-
- EXPECT_EQ(service()->promo_data(), PromoData());
- EXPECT_EQ(service()->promo_status(), PromoService::Status::OK_BUT_BLOCKED);
-
- const auto& blocklist = prefs()->GetDict(prefs::kNtpPromoBlocklist);
- ASSERT_EQ(1u, blocklist.size());
- ASSERT_TRUE(blocklist.Find("42"));
-}
-
-TEST_F(PromoServiceTest, BlocklistExpiration) {
- base::test::ScopedFeatureList feature_list;
- feature_list.InitAndEnableFeature(ntp_features::kNtpMiddleSlotPromoDismissal);
-
- {
- ScopedDictPrefUpdate update(prefs(), prefs::kNtpPromoBlocklist);
- ASSERT_EQ(0u, update->size());
- base::Time past = base::Time::Now() - base::Days(365);
- update->Set("42", past.ToDeltaSinceWindowsEpoch().InSecondsF());
- }
-
- ASSERT_EQ(1u, prefs()->GetDict(prefs::kNtpPromoBlocklist).size());
-
- std::string response_string =
... (truncated)
Original Bug Report
Isolation bypass in NTP promo images via unvalidated data: URIs
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A compromised network process can bypass the SanitizedImageSource security boundary by injecting data: URIs into the New Tab Page (NTP) promo JSON. The NTP handler fails to validate the image URL scheme, and the frontend explicitly bypasses the chrome://image wrapper for data: URIs. This forces the privileged NTP WebUI renderer to directly decode attacker-controlled image data, exposing it to potential image parser vulnerabilities.
Affected files:
chrome/browser/ui/webui/new_tab_page/new_tab_page_handler.ccui/webui/resources/cr_elements/cr_auto_img/cr_auto_img.tschrome/browser/resources/new_tab_page/middle_slot_promo.ts
Estimated timestamp from git blame: Unknown (Google3 checkout)
Background
To protect privileged WebUI renderers (like the New Tab Page at chrome://new-tab-page) from vulnerabilities in image decoding libraries (e.g., libpng, libjpeg-turbo), Chrome routes untrusted external images through SanitizedImageSource (chrome://image). This mechanism downloads the image and safely decodes it in an isolated data_decoder utility process before sending the safe, re-encoded bitmap back to the WebUI renderer.
Vulnerability Details
There is a potential architectural bypass of this security boundary when loading promo images on the New Tab Page.
-
Unvalidated Mojo IPC: When the browser process fetches promo data (via
PromoService), the parsed JSON is converted into a Mojo message inNewTabPageHandler::MakePromo(chrome/browser/ui/webui/new_tab_page/new_tab_page_handler.cc). The code extracts theimage.image_urlstring and converts it directly to aGURLwithout validating the URL scheme:// new_tab_page_handler.cc:421 mojom_image->image_url = GURL(*image_url); // No scheme validation -
Frontend Wrapper Bypass: In the NTP frontend, the
imageUrlis assigned to theautoSrcproperty of aCrAutoImgElement(ui/webui/resources/cr_elements/cr_auto_img/cr_auto_img.ts). This custom element is designed to wrap external URLs in thechrome://image?prefix. However, it contains an explicit bypass fordata:URIs:// cr_auto_img.ts:93 if (url.protocol === 'data:' || url.protocol === 'chrome:') { this.src = url.href; return; // Bypasses the chrome://image wrapper }
Because the WebUI Content Security Policy (CSP) explicitly allows data: URIs in the img-src directive by default, the browser does not block the inline load.
Suggested Attack Scenario
Note: These are suggested steps to trigger the vulnerability; our tooling has not run a working proof-of-concept.
- An attacker compromises the sandboxed network process.
- When the browser process fetches NTP promo data (e.g., from
/async/newtab_promos), the compromised network process intercepts the request and provides a spoofed JSON response via the Mojo data pipe. - The forged JSON contains a promo image part where the
image_urlis a maliciousdata:URI containing an exploit payload for an image parser (e.g.,data:image/png;base64,...). - The browser process parses the JSON and passes the unvalidated
data:URI to the privileged NTP WebUI renderer via Mojo. - The NTP renderer assigns the URL to a
CrAutoImgElement. Due to the protocol check, thedata:URI bypassesSanitizedImageSourceand is assigned directly to theimgelement’ssrcattribute. - Blink’s internal image decoders attempt to parse the malicious image data directly within the privileged WebUI renderer process.
Impact
If the attacker’s data: URI payload successfully exploits a memory corruption vulnerability in an image decoder, they achieve Remote Code Execution (RCE) within the privileged NTP WebUI renderer. This renderer has access to sensitive Mojo interfaces that are not available to standard web content, which could facilitate further escalation or sandbox escapes.
Suggested Fix
Enforce strict scheme validation in NewTabPageHandler::MakePromo (and ideally in PromoService as well) to ensure that only https:// URLs are accepted for promo images. Any other scheme, especially data:, should be rejected and dropped before constructing the Mojo message.
Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb
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.