Medium chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Browser
DescriptionImproper input validation in Browser
ComponentBrowser
Sub-componentCbui
Bug ClassInput Validation
Tracker553153823
Fix commitbfc2f747062d (chromium/src) +38/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
chrome/browser/ui/views/external_protocol_dialog.cc
modified
ExternalProtocolDialog
chrome/browser/ui/views/external_protocol_dialog.h
modified
PictureInPictureWatcher
chrome/browser/ui/views/external_protocol_dialog.h
modified

Files Changed

  • chrome/browser/ui/views/external_protocol_dialog.cc
  • chrome/browser/ui/views/external_protocol_dialog.h
From bfc2f747062d5beb014a6841a0bfb271916bb9a3 Mon Sep 17 00:00:00 2001
From: Elly <[email protected]>
Date: Tue, 22 Sep 2026 09:43:57 -0700
Subject: [PATCH] cbui: fix ExternalProtocolDialog widget activation clickjack

There's a somewhat complex dance (described on the linked bug) which a
page can do to bypass the clickjacking protection on this dialog,
involving multiple windows passing activation between themselves. To
prevent that, rearm the input protector if the widget's toplevel widget
becomes active. We watch the toplevel widget, rather than the bubble
widget itself, because toplevel widgets are the ones that receive
native activation.

This is heavily inspired by the AI-generated bandaid fix from the
linked bug.

Fixed: 553153823
Change-Id: Ic2285f02b26ba427a6e1d03ae0814d38f2579ff0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8428219
Reviewed-by: Benjamin Keen <[email protected]>
Commit-Queue: Elly <[email protected]>
Reviewed-by: Dana Fried <[email protected]>
Auto-Submit: Elly <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1702724}
---
diff --git a/chrome/browser/ui/views/external_protocol_dialog.cc b/chrome/browser/ui/views/external_protocol_dialog.cc
index d648a9f..6e5a6622 100644
--- a/chrome/browser/ui/views/external_protocol_dialog.cc
+++ b/chrome/browser/ui/views/external_protocol_dialog.cc
@@ -179,6 +179,14 @@
 
   constrained_window::ShowWebModalDialogViews(this, web_contents);
   picture_in_picture_watcher_ = std::make_unique<PictureInPictureWatcher>(this);
+  // Observe the top-level widget hosting the WebContents (the browser
+  // window), not the web-modal child widget: only top-level widgets receive
+  // native activation changes.
+  if (views::Widget* host_widget =
+          views::Widget::GetTopLevelWidgetForNativeView(
+              web_contents->GetNativeView())) {
+    widget_observation_.Observe(host_widget);
+  }
 }
 
 ExternalProtocolDialog::~ExternalProtocolDialog() = default;
@@ -222,6 +230,22 @@
   GetDialogClientView()->TriggerInputProtection();
 }
 
+void ExternalProtocolDialog::OnWidgetActivationChanged(views::Widget* widget,
+                                                       bool active) {
+  // Re-arm the input protector whenever the dialog's widget becomes active
+  // again. Without this, a background window hosting this dialog can be
+  // raised under the user's cursor (e.g. via an opener's popup.focus() call)
+  // and the first click ever delivered to the dialog is accepted against the
+  // stale protection timestamp from the original show.
+  if (active) {
+    GetDialogClientView()->TriggerInputProtection(/*force_early=*/true);
+  }
+}
+
+void ExternalProtocolDialog::OnWidgetDestroying(views::Widget* widget) {
+  widget_observation_.Reset();
+}
+
 bool ExternalProtocolDialog::ShouldIgnoreButtonPressedEventHandling(
     View* button,
     const ui::Event& event) const {
diff --git a/chrome/browser/ui/views/external_protocol_dialog.h b/chrome/browser/ui/views/external_protocol_dialog.h
index 97f376a..9ef1125d 100644
--- a/chrome/browser/ui/views/external_protocol_dialog.h
+++ b/chrome/browser/ui/views/external_protocol_dialog.h
@@ -9,8 +9,11 @@
 
 #include "base/memory/raw_ptr.h"
 #include "base/memory/weak_ptr.h"
+#include "base/scoped_observation.h"
 #include "content/public/browser/weak_document_ptr.h"
 #include "ui/base/metadata/metadata_header_macros.h"
+#include "ui/views/widget/widget.h"
+#include "ui/views/widget/widget_observer.h"
 #include "ui/views/window/dialog_delegate.h"
 #include "url/gurl.h"
 #include "url/origin.h"
@@ -32,7 +35,8 @@
 //
 // Users can allow or block the launch and optionally remember this decision
 // for the specific protocol and initiating origin.
-class ExternalProtocolDialog : public views::DialogDelegateView {
+class ExternalProtocolDialog : public views::DialogDelegateView,
+                               public views::WidgetObserver {
   METADATA_HEADER(ExternalProtocolDialog, views::DialogDelegateView)
 
  public:
@@ -50,6 +54,10 @@
   bool ShouldShowCloseButton() const override;
   std::u16string GetWindowTitle() const override;
 
+  // views::WidgetObserver:
+  void OnWidgetActivationChanged(views::Widget* widget, bool active) override;
+  void OnWidgetDestroying(views::Widget* widget) override;
+
  private:
   friend class test::ExternalProtocolDialogTestApi;
 
@@ -82,6 +90,11 @@
   // windows, to ensure input protection and ignore spurious interactions.
   class PictureInPictureWatcher;
   std::unique_ptr<PictureInPictureWatcher> picture_in_picture_watcher_;
+
+  // Observes the dialog's widget for activation changes to re-arm input
+  // protection on z-order/activation reveals.
+  base::ScopedObservation<views::Widget, views::WidgetObserver>
+      widget_observation_{this};
 };
 
 #endif  // CHROME_BROWSER_UI_VIEWS_EXTERNAL_PROTOCOL_DIALOG_H_
Loading diff…

Original Bug Report

reported by [email protected]

Clickjacking in ExternalProtocolDialog due to missing input protection on activation

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: ExternalProtocolDialog uses InputEventActivationProtector to prevent unintended clicks, but does not re-arm it when its window is activated or undergoes z-order changes. An attacker could potentially display the dialog in an opener-controlled popup, occlude it behind an active window during user interaction, and programmatically focus the popup right before a user click lands. This allows the in-flight click to immediately accept the dialog and launch an external application without genuine consent.

Affected files:

  • chrome/browser/ui/views/external_protocol_dialog.cc
  • chrome/browser/ui/views/external_protocol_dialog.h

Estimated timestamp from git blame: 2025-06-16

Summary

ExternalProtocolDialog (located in chrome/browser/ui/views/external_protocol_dialog.cc) gates launches of external applications through custom URI schemes (e.g., vscode://, zoommtg://, steam://). To protect against clickjacking and accidental clicks, views dialogs utilize InputEventActivationProtector.

However, ExternalProtocolDialog does not observe widget activation or z-order changes. While it re-arms input protection when unoccluded by Picture-in-Picture windows (PictureInPictureWatcher), it does not re-arm when the window itself is brought to the foreground via w2.focus() or OS window activation. This creates a potential clickjacking surface where an attacker can pace user clicks and elevate the dialog under the cursor after its initial protection cooldown has expired.

Root Cause Analysis

  1. Protector Arming & Acceptance: DialogClientView installs an InputEventActivationProtector with DefaultInputProtectionPolicy (ui/views/input_protection/default_input_protection_policy.cc). Upon initial display, view_protected_time_stamp_ is set to $T_0$. If no further events reach the dialog and the cooldown period (typically 500ms) elapses, any subsequent click is treated as valid (IsPossiblyUnintendedInteraction returns false).

  2. Missing Re-arm on Window Activation: Unlike permission prompts (e.g., chrome/browser/ui/views/permissions/permission_prompt_bubble.cc:95-104 and chrome/browser/ui/views/permissions/chip/chip_controller.cc:228-241), which implement WidgetObserver::OnWidgetActivationChanged and call TriggerInputProtection(/*force_early=*/true) when foregrounded, ExternalProtocolDialog does not implement WidgetObserver for window activation.

  3. Stationarity Monitor Scope: The global WindowsStationarityMonitor (ui/views/windows_stationarity_monitor_aura.cc:65-79, ui/views/windows_stationarity_monitor_mac.mm:56-66) only resets protection when windows are destroyed or their bounds change—not on activation or z-order shifts.

  4. Opener Focus Carve-Out: In Blink, DOMWindow::focus() explicitly permits an opener window to focus a popup it opened (third_party/blink/renderer/core/frame/dom_window.cc:736-740). If the opener has transient user activation from previous clicks (third_party/blink/renderer/core/frame/frame.cc:669-687), the focus request succeeds and instructs the browser to activate the popup window (WebContentsImpl::Activate() $\to$ BrowserView::Activate() $\to$ Widget::Activate()).

Potential Steps to Trigger

(Note: These are potential steps derived from code analysis; our tooling agent does not currently have the capability to execute live browser code.)

  1. Victim visits an attacker-controlled page ($W_1$) presenting a fast-paced clicking interaction (e.g., a mini-game) requiring rhythmic clicks at fixed screen coordinate $P$.
  2. On the first click, $W_1$ obtains transient user activation, calls window.open() to spawn popup $W_2$ positioned so that its tab-modal [Open] button aligns with coordinate $P$, and triggers a navigation in $W_2$ to an external URI (e.g., vscode://...).
  3. The browser creates and shows ExternalProtocolDialog in $W_2$, arming its input protector at timestamp $T_0$.
  4. Subsequent clicks in the game land on $W_1$, natively raising $W_1$ over $W_2$. $W_2$ becomes occluded behind $W_1$, but its dialog remains widget-visible and its input protector timestamp remains $T_0$.
  5. After $>500\text{ms}$ has passed since $T_0$, $W_1$ invokes w2.focus() shortly before an anticipated user click.
  6. The browser activates and raises $W_2$ directly under the cursor. Because activation triggers no input protection reset, the in-flight click is delivered to DialogClientView::ButtonPressed.
  7. IsPossiblyUnintendedInteraction returns false, causing the dialog to accept and invoke ExternalProtocolHandler::LaunchUrlWithoutSecurityCheck(), launching the external application without genuine consent.

Impact

Bypasses user consent for launching registered external application URI handlers. In scenarios where a local application has argument injection or custom handler vulnerabilities, this could lead to untrusted command execution.

Suggested Fix

Implement views::WidgetObserver on ExternalProtocolDialog (or its host window) and override OnWidgetActivationChanged. When the widget or its parent browser window becomes active, invoke TriggerInputProtection(/*force_early=*/true) to re-arm the protection window, mirroring the mitigation in PermissionPromptBubble:

void ExternalProtocolDialog::OnWidgetActivationChanged(views::Widget* widget, bool active) {
  if (active) {
    TriggerInputProtection(/*force_early=*/true);
  }
}

Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c


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