CVE-2026-87655
Overview
Background
- Download bubble
- the small popup panel anchored to the toolbar download icon that lists in-progress and completed downloads, one
DownloadBubbleRowViewper download. - Main page buttons
- the primary text action buttons on a download row (e.g.
KEEP,Open,Discard), tracked inDownloadBubbleRowView’smain_page_buttons_map, as distinct from the row’stransparent_buttonand itsquick_actions_icon buttons. - Picture-in-picture (PiP) occlusion
- the condition where a floating PiP video window overlaps the download bubble, surfaced to each row through
DownloadBubbleRowView::OnOcclusionStateChanged(bool occluded). - Clickjacking
- an attack in which an attacker-controlled overlay (here a PiP window) sits over a trusted UI control so the user is tricked into activating that control while believing they are interacting with the overlay.
Root Cause Analysis
DownloadBubbleRowView::OnOcclusionStateChanged() is the single place that reacts to a PiP window occluding the download bubble, and its job is to neutralize the row’s interactive controls so a user cannot be tricked into activating them through the overlay. Before the patch it disabled only the row’s transparent_button and every button in quick_actions_, but it left the main_page_buttons_ (the primary text actions such as KEEP) fully enabled and clickable. This violated the intended invariant that all actionable controls on a row are disabled while the row is occluded, leaving a clickjacking gap: a PiP window positioned over a main page button let a user’s click reach and activate that button (for example keeping/opening a dangerous download) while they believed they were clicking the video.
The fix adds a loop that calls SetEnabled(!occluded) on each entry of main_page_buttons_, giving the main page buttons exactly the same occlusion treatment as the other two control kinds and restoring the invariant. The accompanying GetMainPageButtonForTesting() accessor and the OcclusionDisablesButtons unit test lock in that all three button kinds toggle enabled state with occlusion.
main_page_buttons_ alongside the others whenever occluded is true.Attack Path
- Stage an occluding overlay An attacker gets the user to open a page that launches a picture-in-picture video window, which floats above other Chrome UI including the download bubble.
- Trigger a download bubble with an actionable row
The attacker induces a download so the download bubble appears with a row whose main page button (e.g.
KEEP) offers a security-relevant action on a suspicious file. - Position the PiP window over the main page button
The PiP window is moved so it overlaps the row;
OnOcclusionStateChanged(true)fires and disables the transparent and quick-action buttons, but pre-patch leaves the main page button enabled and hittable. - Harvest the click The user, believing they are interacting with the video, clicks through to the still-enabled main page button, activating the download action (such as keeping or opening the file) without realizing it. (inferred) — the diff shows the enabled control but does not itself demonstrate a specific end-to-end user interaction.
Impact Assessment
LogicError causing clickjacking in Downloads; any further consequence such as execution of a kept/opened file is a downstream user action, not something the patch demonstrates (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
forchrome/browser/ui/views/download/bubble/download_bubble_row_view.cc |
modified | |
TEST_Fchrome/browser/ui/views/download/bubble/download_bubble_row_view_unittest.cc |
modified |
Files Changed
chrome/browser/ui/views/download/bubble/download_bubble_row_view.ccchrome/browser/ui/views/download/bubble/download_bubble_row_view.hchrome/browser/ui/views/download/bubble/download_bubble_row_view_unittest.cc
Audit Directions
- Incomplete state fan-out across parallel control collectionsWhen one handler must apply a state change (enable/disable, visibility, focusability) to several sibling collections, audit that every collection is covered; here
transparent_buttonandquick_actions_were handled butmain_page_buttons_was missed. - Occlusion and input-protection handlers in bubbles/overlaysReview other views reacting to PiP or window occlusion (and input-event activation protectors) to confirm all activatable controls, not just a subset, are neutralized while occluded.
- New controls added after a security handler existsWhen a new button or control map is added to a row/view that already has occlusion or anti-clickjacking logic, verify the control is wired into that logic rather than silently left enabled.
Patch
From 3cfe51aa2838e8a95f78dbc9e82327de5db20870 Mon Sep 17 00:00:00 2001 From: Yaw Frempong <[email protected]> Date: Thu, 30 Jul 2026 07:25:30 -0700 Subject: [PATCH] [Download Bubble] Disable download bubble main page buttons on PiP occlusion DownloadBubbleRowView::OnOcclusionStateChanged() already disables the row's transparent button and quick-action buttons while the bubble is occluded by a picture-in-picture window. Apply the same treatment to the main page buttons so that they behave consistently with the other controls on the row, and add a unit test covering all three button kinds. Reviewed in https://crrev.com/i/9601875 Bug: 514023309 Change-Id: I3d27699cca9f8dc7010d18cdd09431927eb09753 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8169860 Commit-Queue: Yaw Frempong <[email protected]> Reviewed-by: Lily Chen <[email protected]> Cr-Commit-Position: refs/heads/main@{#1671046} --- diff --git a/chrome/browser/ui/views/download/bubble/download_bubble_row_view.cc b/chrome/browser/ui/views/download/bubble/download_bubble_row_view.cc index a68d3391..f9b8c9c 100644 --- a/chrome/browser/ui/views/download/bubble/download_bubble_row_view.cc +++ b/chrome/browser/ui/views/download/bubble/download_bubble_row_view.cc @@ -978,6 +978,9 @@ for (auto& [command, action_button] : quick_actions_) { action_button->SetEnabled(!occluded); } + for (auto& [command, main_page_button] : main_page_buttons_) { + main_page_button->SetEnabled(!occluded); + } } std::u16string_view DownloadBubbleRowView::GetSecondaryLabelTextForTesting() { @@ -1053,6 +1056,13 @@ return it->second; } +views::MdTextButton* DownloadBubbleRowView::GetMainPageButtonForTesting( + DownloadCommands::Command command) { + auto it = main_page_buttons_.find(command); + CHECK(it != main_page_buttons_.end()); + return it->second; +} + void DownloadBubbleRowView::SetInputProtectorForTesting( std::unique_ptr<views::InputEventActivationProtector> input_protector) { input_protector_ = std::move(input_protector); diff --git a/chrome/browser/ui/views/download/bubble/download_bubble_row_view.h b/chrome/browser/ui/views/download/bubble/download_bubble_row_view.h index 6ca1a625..141a7fe 100644 --- a/chrome/browser/ui/views/download/bubble/download_bubble_row_view.h +++ b/chrome/browser/ui/views/download/bubble/download_bubble_row_view.h @@ -112,6 +112,8 @@ bool IsQuickActionButtonVisibleForTesting(DownloadCommands::Command command); views::ImageButton* GetQuickActionButtonForTesting( DownloadCommands::Command command); + views::MdTextButton* GetMainPageButtonForTesting( + DownloadCommands::Command command); void SetInputProtectorForTesting( std::unique_ptr<views::InputEventActivationProtector> input_protector); diff --git a/chrome/browser/ui/views/download/bubble/download_bubble_row_view_unittest.cc b/chrome/browser/ui/views/download/bubble/download_bubble_row_view_unittest.cc index 9fb3b9a..9b191dfd 100644 --- a/chrome/browser/ui/views/download/bubble/download_bubble_row_view_unittest.cc +++ b/chrome/browser/ui/views/download/bubble/download_bubble_row_view_unittest.cc @@ -26,6 +26,7 @@ #include "ui/events/test/test_event.h" #include "ui/events/types/event_type.h" #include "ui/views/controls/button/image_button.h" +#include "ui/views/controls/button/md_text_button.h" #include "ui/views/test/mock_input_event_activation_protector.h" namespace { @@ -241,4 +242,28 @@ ->OnMousePressed(event); } +// Test that all button controls on the row are disabled while the row is +// occluded by a picture-in-picture window, and re-enabled when it is not. +TEST_F(DownloadBubbleRowViewTest, OcclusionDisablesButtons) { + views::Button* transparent_button = row_view()->transparent_button(); + views::ImageButton* quick_action = + row_view()->GetQuickActionButtonForTesting(DownloadCommands::CANCEL); + views::MdTextButton* main_page_button = + row_view()->GetMainPageButtonForTesting(DownloadCommands::KEEP); + + ASSERT_TRUE(transparent_button->GetEnabled()); + ASSERT_TRUE(quick_action->GetEnabled()); + ASSERT_TRUE(main_page_button->GetEnabled()); + + row_view()->OnOcclusionStateChanged(/*occluded=*/true); + EXPECT_FALSE(transparent_button->GetEnabled()); + EXPECT_FALSE(quick_action->GetEnabled()); + EXPECT_FALSE(main_page_button->GetEnabled()); + + row_view()->OnOcclusionStateChanged(/*occluded=*/false); + EXPECT_TRUE(transparent_button->GetEnabled()); + EXPECT_TRUE(quick_action->GetEnabled()); + EXPECT_TRUE(main_page_button->GetEnabled()); +} + } // namespace
Regression Test / PoC
diff --git a/chrome/browser/ui/views/download/bubble/download_bubble_row_view_unittest.cc b/chrome/browser/ui/views/download/bubble/download_bubble_row_view_unittest.cc
index 9fb3b9a..9b191dfd 100644
--- a/chrome/browser/ui/views/download/bubble/download_bubble_row_view_unittest.cc
+++ b/chrome/browser/ui/views/download/bubble/download_bubble_row_view_unittest.cc
@@ -26,6 +26,7 @@
#include "ui/events/test/test_event.h"
#include "ui/events/types/event_type.h"
#include "ui/views/controls/button/image_button.h"
+#include "ui/views/controls/button/md_text_button.h"
#include "ui/views/test/mock_input_event_activation_protector.h"
namespace {
@@ -241,4 +242,28 @@
->OnMousePressed(event);
}
+// Test that all button controls on the row are disabled while the row is
+// occluded by a picture-in-picture window, and re-enabled when it is not.
+TEST_F(DownloadBubbleRowViewTest, OcclusionDisablesButtons) {
+ views::Button* transparent_button = row_view()->transparent_button();
+ views::ImageButton* quick_action =
+ row_view()->GetQuickActionButtonForTesting(DownloadCommands::CANCEL);
+ views::MdTextButton* main_page_button =
+ row_view()->GetMainPageButtonForTesting(DownloadCommands::KEEP);
+
+ ASSERT_TRUE(transparent_button->GetEnabled());
+ ASSERT_TRUE(quick_action->GetEnabled());
+ ASSERT_TRUE(main_page_button->GetEnabled());
+
+ row_view()->OnOcclusionStateChanged(/*occluded=*/true);
+ EXPECT_FALSE(transparent_button->GetEnabled());
+ EXPECT_FALSE(quick_action->GetEnabled());
+ EXPECT_FALSE(main_page_button->GetEnabled());
+
+ row_view()->OnOcclusionStateChanged(/*occluded=*/false);
+ EXPECT_TRUE(transparent_button->GetEnabled());
+ EXPECT_TRUE(quick_action->GetEnabled());
+ EXPECT_TRUE(main_page_button->GetEnabled());
+}
+
} // namespace