CVE-2026-113230
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/views/external_protocol_dialog.cc |
modified | |
ExternalProtocolDialogchrome/browser/ui/views/external_protocol_dialog.h |
modified | |
PictureInPictureWatcherchrome/browser/ui/views/external_protocol_dialog.h |
modified |
Files Changed
chrome/browser/ui/views/external_protocol_dialog.ccchrome/browser/ui/views/external_protocol_dialog.h
Patch
From bfc2f747062d5beb014a6841a0bfb271916bb9a3 Mon Sep 17 00:00:00 2001
From: Elly <ellyjones@chromium.org>
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 <bkeen@google.com>
Commit-Queue: Elly <ellyjones@chromium.org>
Reviewed-by: Dana Fried <dfried@chromium.org>
Auto-Submit: Elly <ellyjones@chromium.org>
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_
Original Bug Report
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.ccchrome/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
-
Protector Arming & Acceptance:
DialogClientViewinstalls anInputEventActivationProtectorwithDefaultInputProtectionPolicy(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 (IsPossiblyUnintendedInteractionreturnsfalse). -
Missing Re-arm on Window Activation: Unlike permission prompts (e.g.,
chrome/browser/ui/views/permissions/permission_prompt_bubble.cc:95-104andchrome/browser/ui/views/permissions/chip/chip_controller.cc:228-241), which implementWidgetObserver::OnWidgetActivationChangedand callTriggerInputProtection(/*force_early=*/true)when foregrounded,ExternalProtocolDialogdoes not implementWidgetObserverfor window activation. -
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. -
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.)
- 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$.
- 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://...). - The browser creates and shows
ExternalProtocolDialogin $W_2$, arming its input protector at timestamp $T_0$. - 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$.
- After $>500\text{ms}$ has passed since $T_0$, $W_1$ invokes
w2.focus()shortly before an anticipated user click. - 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. IsPossiblyUnintendedInteractionreturnsfalse, causing the dialog to accept and invokeExternalProtocolHandler::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.