Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactSide-channel information leakage in Favicons
DescriptionSide-channel information leakage in Favicons
ComponentFavicons
Bug ClassLogic Error
Tracker520196753
Fix commit625aaecd20d6 (chromium/src) +38/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Files Changed

  • components/favicon/core/favicon_handler.cc
  • components/favicon/core/favicon_handler_unittest.cc
From 625aaecd20d6dba3fc53191f0b2593e714c3580a Mon Sep 17 00:00:00 2001
From: Mikel Astiz <[email protected]>
Date: Mon, 15 Jun 2026 14:27:02 -0700
Subject: [PATCH] [favicon] Always redownload Web App manifest favicons in incognito

In consistency with how other favicon types are handled, manifest
favicons should always be redownloaded in incognito. This avoids leaking
whether a site was previously visited in regular mode to the incognito
session via cache hit/miss side-channels.

Fixed: 520196753
Change-Id: Ic9d8941023a6a5c0bfb841be0d34e2a80bfa260a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7921032
Commit-Queue: Mikel Astiz <[email protected]>
Reviewed-by: Peter Kotwicz <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1647081}
---

diff --git a/components/favicon/core/favicon_handler.cc b/components/favicon/core/favicon_handler.cc
index ab36630..07c36d72 100644
--- a/components/favicon/core/favicon_handler.cc
+++ b/components/favicon/core/favicon_handler.cc
@@ -392,9 +392,15 @@
   DCHECK(got_favicon_from_history_);
 
   bool has_valid_result = HasValidResult(favicon_bitmap_results);
+  // For off-the-record profiles pretend that favicons from FaviconService are
+  // expired so websites don't know if a site was previously visited in regular
+  // mode. Note however that any cached favicon may still be displayed in the
+  // UI, as there is no privacy downside in showing favicons fetched while the
+  // user was browsing in non-incognito mode.
   bool has_expired_or_incomplete_result =
-      !has_valid_result || HasExpiredOrIncompleteResult(preferred_icon_size(),
-                                                        favicon_bitmap_results);
+      !has_valid_result || delegate_->IsOffTheRecord() ||
+      HasExpiredOrIncompleteResult(preferred_icon_size(),
+                                   favicon_bitmap_results);
 
   if (has_valid_result &&
       (notification_icon_url_ != manifest_url_ ||
@@ -600,7 +606,9 @@
   bool has_valid_result = HasValidResult(favicon_bitmap_results);
   // For off-the-record profiles pretend that favicons from FaviconService are
   // expired so websites don't know if a site was previously visited in regular
-  // mode.
+  // mode. Note however that any cached favicon may still be displayed in the
+  // UI, as there is no privacy downside in showing favicons fetched while the
+  // user was browsing in non-incognito mode.
   initial_history_result_expired_or_incomplete_ =
       !has_valid_result || delegate_->IsOffTheRecord() ||
       HasExpiredOrIncompleteResult(preferred_icon_size(),
diff --git a/components/favicon/core/favicon_handler_unittest.cc b/components/favicon/core/favicon_handler_unittest.cc
index 9043872f..aecae32 100644
--- a/components/favicon/core/favicon_handler_unittest.cc
+++ b/components/favicon/core/favicon_handler_unittest.cc
@@ -1947,6 +1947,33 @@
   EXPECT_THAT(delegate_.downloads(), IsEmpty());
 }
 
+TEST_F(FaviconHandlerManifestsEnabledTest,
+       GetFaviconFromManifestInHistoryInIncognito) {
+  ON_CALL(delegate_, IsOffTheRecord()).WillByDefault(Return(true));
+  favicon_service_.fake()->Store(
+      kPageURL, kManifestURL,
+      CreateRawBitmapResult(kManifestURL, kWebManifestIcon));
+
+  // OnFaviconUpdated should be called with the cached icon initially.
+  EXPECT_CALL(delegate_,
+              OnFaviconUpdated(_, FaviconDriverObserver::TOUCH_LARGEST,
+                               kManifestURL, _, _));
+
+  // Since the fake manifest downloader returns empty results by default, the
+  // handler will fall back to the candidate icon and download it (see below for
+  // more details about why a download is expected).
+  EXPECT_CALL(delegate_,
+              OnFaviconUpdated(_, FaviconDriverObserver::TOUCH_LARGEST,
+                               kIconURL12x12, _, _));
+
+  RunHandlerWithSimpleTouchIconCandidates({kIconURL12x12}, kManifestURL);
+
+  // The handler should have requested the manifest download, although the icon
+  // was cached, because the handler is in incognito mode and cached icons must
+  // be treated as expired to prevent side-channel attacks.
+  EXPECT_THAT(delegate_.downloads(), ElementsAre(kManifestURL, kIconURL12x12));
+}
+
 // Test that a favicon corresponding to a web manifest is reported when:
 // - There is data in the favicon database for the manifest URL.
 // AND
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/favicon/core/favicon_handler_unittest.cc b/components/favicon/core/favicon_handler_unittest.cc
index 9043872f..aecae32 100644
--- a/components/favicon/core/favicon_handler_unittest.cc
+++ b/components/favicon/core/favicon_handler_unittest.cc
@@ -1947,6 +1947,33 @@
   EXPECT_THAT(delegate_.downloads(), IsEmpty());
 }
 
+TEST_F(FaviconHandlerManifestsEnabledTest,
+       GetFaviconFromManifestInHistoryInIncognito) {
+  ON_CALL(delegate_, IsOffTheRecord()).WillByDefault(Return(true));
+  favicon_service_.fake()->Store(
+      kPageURL, kManifestURL,
+      CreateRawBitmapResult(kManifestURL, kWebManifestIcon));
+
+  // OnFaviconUpdated should be called with the cached icon initially.
+  EXPECT_CALL(delegate_,
+              OnFaviconUpdated(_, FaviconDriverObserver::TOUCH_LARGEST,
+                               kManifestURL, _, _));
+
+  // Since the fake manifest downloader returns empty results by default, the
+  // handler will fall back to the candidate icon and download it (see below for
+  // more details about why a download is expected).
+  EXPECT_CALL(delegate_,
+              OnFaviconUpdated(_, FaviconDriverObserver::TOUCH_LARGEST,
+                               kIconURL12x12, _, _));
+
+  RunHandlerWithSimpleTouchIconCandidates({kIconURL12x12}, kManifestURL);
+
+  // The handler should have requested the manifest download, although the icon
+  // was cached, because the handler is in incognito mode and cached icons must
+  // be treated as expired to prevent side-channel attacks.
+  EXPECT_THAT(delegate_.downloads(), ElementsAre(kManifestURL, kIconURL12x12));
+}
+
 // Test that a favicon corresponding to a web manifest is reported when:
 // - There is data in the favicon database for the manifest URL.
 // AND
Loading diff…

Original Bug Report

reported by [email protected]

Potential Incognito history leak via Web App Manifest favicon path due to missing OTR check

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 potential privacy side-channel in Chrome for Android and iOS allows a malicious website visited in Incognito mode to detect if a user has previously visited specific websites in regular browsing mode. Due to a missing off-the-record (OTR) check in the Web App Manifest favicon callback, the browser may reuse cached regular profile bitmaps inside OTR sessions without forcing a re-download. An attacker can observe whether or not a fallback image download occurs to infer the user’s regular-profile browsing history.

Affected files:

  • components/favicon/core/favicon_handler.cc

Estimated timestamp from git blame: 2017-05-18

Detailed Analysis of the Potential Vulnerability

In Chrome’s favicon handling architecture, Incognito (Off-the-Record) sessions use the regular profile’s FaviconService via GetOriginalProfile() and ServiceAccessType::IMPLICIT_ACCESS (see chrome/browser/favicon/favicon_utils.cc). To prevent the leak of regular profile navigation history to OTR sessions, OTR profiles must treat any cached favicons returned by the database as expired, forcing a network re-download so that websites cannot use cache timing or cache hit/miss behavior as an oracle.

This defense-in-depth mechanism is correctly implemented in FaviconHandler::OnFaviconDataForInitialURLFromFaviconService and FaviconHandler::OnFaviconData via the delegate_->IsOffTheRecord() check (in components/favicon/core/favicon_handler.cc):

// For off-the-record profiles pretend that favicons from FaviconService are
// expired so websites don't know if a site was previously visited in regular
// mode.
bool has_expired_or_incomplete_result =
    !has_valid_result || delegate_->IsOffTheRecord() ||
    HasExpiredOrIncompleteResult(preferred_icon_size(), favicon_bitmap_results);

However, a potential omission exists in the Web App Manifest callback path: FaviconHandler::OnFaviconDataForManifestFromFaviconService (components/favicon/core/favicon_handler.cc):

void FaviconHandler::OnFaviconDataForManifestFromFaviconService(
    const std::vector<favicon_base::FaviconRawBitmapResult>&
        favicon_bitmap_results) {
  DCHECK(got_favicon_from_history_);

  bool has_valid_result = HasValidResult(favicon_bitmap_results);
  bool has_expired_or_incomplete_result =
      !has_valid_result || HasExpiredOrIncompleteResult(preferred_icon_size(),
                                                        favicon_bitmap_results);
  ...
  if (has_expired_or_incomplete_result) {
    manifest_download_request_.Reset(base::BindOnce(
        &FaviconHandler::OnDidDownloadManifest, base::Unretained(this)));
    delegate_->DownloadManifest(manifest_url_,
                                manifest_download_request_.callback());
  }
}

This callback lacks the delegate_->IsOffTheRecord() disjunct, allowing cached regular profile bitmaps to be considered valid and up-to-date inside an OTR session.

Potential Attack Vector & Side-Channel Mechanics

(Note: These are potential steps based on code tracing; our analysis has not been validated on running processes.)

  1. Preconditions:

    • The victim site (e.g., https://victim.example) is a Progressive Web App (PWA) with a declared manifest URL and icon, which the user previously visited in a regular browsing session, caching its favicon entry in the database as favicon_base::IconType::kWebManifestIcon.
    • The user later visits https://attacker.example/probe.html in an Incognito tab on Android or iOS (where the TOUCH_LARGEST handler is compiled in and processes kWebManifestIcon).
  2. Side-Channel Generation:

    • The attacker page declares:
      <link rel='apple-touch-icon' href='https://attacker.example/probe-victim.png'>
      <link rel='manifest' href='https://victim.example/manifest.json'>
      
    • The browser initiates a cache lookup on the cross-origin manifest URL https://victim.example/manifest.json under GetFaviconAndUpdateMappingsUnlessIncognito.
    • Case A (User Visited Victim): The lookup succeeds, returning the cached regular profile bitmap. Because preferred_icon_size() is 0 under TOUCH_LARGEST, HasExpiredOrIncompleteResult returns false, leading to has_expired_or_incomplete_result = false. Line 409 evaluates to false, and the download is skipped. The attacker’s server receives no request for probe-victim.png.
    • Case B (User Has Not Visited Victim): The lookup misses. has_expired_or_incomplete_result is true, triggering DownloadManifest. The renderer attempts to fetch the cross-origin manifest, which fails CORS validation. On failure, OnDidDownloadManifest falls back to the inlined apple-touch-icon, issuing a request to https://attacker.example/probe-victim.png.
    • By observing the presence or absence of the request to probe-victim.png, the attacker can determine if the user visited the victim site in their regular profile.

Suggested Remediation

To resolve this potential side-channel, update FaviconHandler::OnFaviconDataForManifestFromFaviconService to include the delegate_->IsOffTheRecord() check, matching the behavior of its sibling callbacks:

  bool has_expired_or_incomplete_result =
      !has_valid_result || delegate_->IsOffTheRecord() ||
      HasExpiredOrIncompleteResult(preferred_icon_size(),
                                   favicon_bitmap_results);

Evaluated with Chrome root at commit: d8b226a3be7c9c1ac9240c09e14698866c82e4ac


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