Medium watch CVSS 8.3 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Desktop
DescriptionImproper input validation in Desktop
ComponentDesktop
Sub-componentUI>Browser>New Tab Page (use subcomponent)>Desktop
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.
Tracker523719002
Fix commitdd7083a019ca (chromium/src) +37/-2153
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-06-14 (per vendor advisory)
Fix landed2026-08-31 (public git)
Disclosed2026-09-22
Patch gap22 days — fix public in source before the advisory

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 a PromoData struct.
`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.

Key insight
The core mistake was funneling a remote, server-controlled promo payload into the privileged NTP WebUI without treating it as untrusted input; the fix removes the feature wholesale so the unvalidated data path no longer exists.

Attack Path

  1. Influence the promo response An attacker who can shape the /async/newtab_promos response (or the JSON it delivers) supplies a crafted middle_slot_json payload with a manipulated promo_log_url or can_open_extensions_page flag.
  2. Parse as trusted data PromoService strips the )]}' XSSI preamble and parses the remainder into a PromoData struct without fully validating the fields against expectations.
  3. Cross into the WebUI NewTabPageHandler forwards the PromoData over the NTP Mojo interface to the renderer-side middle_slot_promo component, which renders it and can act on its embedded commands. (inferred)
  4. Trigger unintended action The WebUI honors an attacker-influenced field (for example navigating to chrome://extensions via can_open_extensions_page, or acting on unsanitized markup/URLs), yielding behavior outside the promo’s intended scope. (inferred)

Impact Assessment

An attacker able to influence the promo payload could drive the New Tab Page WebUI into unintended navigation or rendering behavior in the browser/WebUI context, contingent on the middle-slot promo feature being enabled and the malformed response reaching the client. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.3. Any escalation to a stronger primitive such as privilege escalation or code execution is not demonstrated by the patch. (inferred)

Changed Functions

FunctionChangeNotes
if
chrome/browser/new_tab_page/promos/promo_service.cc
modified
for
chrome/browser/new_tab_page/promos/promo_service.cc
modified

Files Changed

  • chrome/browser/new_tab_page/promos/promo_service_factory.h
  • chrome/browser/ui/webui/new_tab_page/new_tab_page.mojom
  • chrome/browser/new_tab_page/promos/promo_service_factory.cc
  • chrome/browser/ui/webui/new_tab_page/new_tab_page_ui.cc
  • chrome/browser/new_tab_page/promos/promo_service.h
  • chrome/browser/ui/webui/browser_command/browser_command_handler.cc
  • tools/traffic_annotation/summary/grouping.xml
  • chrome/browser/new_tab_page/promos/promo_data.cc
  • chrome/browser/resources/new_tab_page/app.html
  • chrome/browser/new_tab_page/promos/promo_service_unittest.cc
  • tools/traffic_annotation/summary/annotations.xml
  • chrome/browser/prefs/browser_prefs.cc
  • chrome/browser/new_tab_page/BUILD.gn
  • chrome/browser/resources/new_tab_page/app.ts
  • chrome/browser/ui/webui/new_tab_page/new_tab_page_handler.cc
  • chrome/browser/resources/new_tab_page/middle_slot_promo.css
  • chrome/common/pref_names.h
  • chrome/browser/ui/webui/new_tab_page/new_tab_page_handler_browsertest.cc
  • chrome/test/data/webui/new_tab_page/middle_slot_promo_test.ts
  • chrome/browser/new_tab_page/promos/promo_service_observer.h
  • chrome/browser/profiles/chrome_browser_main_extra_parts_profiles.cc
  • chrome/test/data/webui/new_tab_page/BUILD.gn
  • chrome/browser/new_tab_page/promos/promo_service.cc
  • chrome/browser/ui/webui/new_tab_page/new_tab_page_handler_unittest.cc
  • chrome/browser/resources/new_tab_page/lazy_load.ts
  • chrome/browser/new_tab_page/promos/promo_data.h
  • chrome/browser/resources/new_tab_page/middle_slot_promo.ts
  • chrome/browser/ui/webui/new_tab_page/new_tab_page_handler.h
  • chrome/test/data/webui/new_tab_page/new_tab_page_browsertest.cc
  • chrome/browser/resources/new_tab_page/app.css
  • chrome/browser/resources/new_tab_page/middle_slot_promo.html
  • chrome/browser/resources/new_tab_page/new_tab_page.gni
  • chrome/test/data/webui/new_tab_page/app_test.ts

Audit Directions

  • Server-controlled payloads into WebUI
    Audit 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 data
    Review any boolean or URL fields (like can_open_extensions_page / promo_log_url) that gate access to chrome:// pages or logging pings, ensuring they cannot be flipped by an untrusted response.
  • XSSI-stripped responses treated as trusted
    Look for other call sites that strip a )]}' preamble and then parse the remainder as trusted structured data without schema or origin validation.
From dd7083a019ca5d30c671bdeacac0834276d6236c Mon Sep 17 00:00:00 2001
From: Dhruv Kathpalia <dhruvkathpalia@google.com>
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 <tiborg@chromium.org>
Reviewed-by: Nicolas Ouellet-Payeur <nicolaso@chromium.org>
Auto-Submit: Dhruv Kathpalia <dhruvkathpalia@google.com>
Commit-Queue: Dhruv Kathpalia <dhruvkathpalia@google.com>
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by rj...@google.com

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.cc
  • ui/webui/resources/cr_elements/cr_auto_img/cr_auto_img.ts
  • chrome/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.

  1. Unvalidated Mojo IPC: When the browser process fetches promo data (via PromoService), the parsed JSON is converted into a Mojo message in NewTabPageHandler::MakePromo (chrome/browser/ui/webui/new_tab_page/new_tab_page_handler.cc). The code extracts the image.image_url string and converts it directly to a GURL without validating the URL scheme:

    // new_tab_page_handler.cc:421
    mojom_image->image_url = GURL(*image_url); // No scheme validation
    
  2. Frontend Wrapper Bypass: In the NTP frontend, the imageUrl is assigned to the autoSrc property of a CrAutoImgElement (ui/webui/resources/cr_elements/cr_auto_img/cr_auto_img.ts). This custom element is designed to wrap external URLs in the chrome://image? prefix. However, it contains an explicit bypass for data: 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.

  1. An attacker compromises the sandboxed network process.
  2. 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.
  3. The forged JSON contains a promo image part where the image_url is a malicious data: URI containing an exploit payload for an image parser (e.g., data:image/png;base64,...).
  4. The browser process parses the JSON and passes the unvalidated data: URI to the privileged NTP WebUI renderer via Mojo.
  5. The NTP renderer assigns the URL to a CrAutoImgElement. Due to the protocol check, the data: URI bypasses SanitizedImageSource and is assigned directly to the img element’s src attribute.
  6. 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.

View on issue tracker