CVE-2026-113149
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.cc |
modified | |
NotifyModalDialogsPositionRequiresUpdatechrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.cc |
modified | |
BrowserWindowInterfacechrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.h |
modified | |
WebContentschrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.h |
modified | |
BrowserWindowModalDialogDelegatechrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.h |
modified |
Files Changed
chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.ccchrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.ccchrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.h
Patch
From 9dcb787a8644439bf44d701efb71e72811cbae81 Mon Sep 17 00:00:00 2001 From: Thomas Lukaszewicz <[email protected]> Date: Sun, 20 Sep 2026 11:43:47 -0700 Subject: [PATCH] Prevent entering fullscreen while a tab is blocked by a modal dialog Update BrowserWindowModalDialogDelegate::SetWebContentsBlocked() to use WebContents::ForSecurityDropFullscreen() instead of only calling WebContents::ExitFullscreen(). Retain the returned ScopedClosureRunner in `fullscreen_blocks_` for the duration of the modal dialog block so that blocked tabs cannot enter or re-enter HTML fullscreen until unblocked. Continue to defer dropping and blocking fullscreen via PostTask() to prevent synchronous window management messages (e.g. WndProc on Windows) from causing reentrant destruction while modal dialog presentation is on the call stack. Preserve the automatic fullscreen content setting exemption check against FullscreenController::requesting_origin() when in content fullscreen and the primary main frame origin otherwise. Also observe TabStripModelObserver::OnTabStripModelChanged() to remove `fullscreen_blocks_` entries when tabs are removed or replaced, avoiding dangling pointers or stale fullscreen blockers when blocked tabs are closed or detached prior to dialog teardown. Fixed: 517221283 Change-Id: Ie3c3995f2dbe5c7031fb953f7d468b27e7b2357d Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8428144 Commit-Queue: Thomas Lukaszewicz <[email protected]> Reviewed-by: Fred Shih <[email protected]> Cr-Commit-Position: refs/heads/main@{#1701498} --- diff --git a/chrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.cc b/chrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.cc index 6ef238d..7b66e48c 100644 --- a/chrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.cc +++ b/chrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.cc @@ -10,7 +10,6 @@ #include "chrome/browser/devtools/devtools_ui_controller.h" #include "chrome/browser/devtools/devtools_window.h" #include "chrome/browser/ui/browser_window.h" // nogncheck -#include "chrome/browser/ui/browser_window/public/browser_window_features.h" #include "chrome/browser/ui/browser_window/public/browser_window_interface.h" #include "chrome/browser/ui/browser_window/public/browser_window_interface_iterator.h" #include "chrome/browser/ui/exclusive_access/exclusive_access_manager.h" @@ -24,6 +23,7 @@ #include "content/public/browser/browser_thread.h" #include "content/public/browser/render_frame_host.h" #include "content/public/browser/web_contents.h" +#include "ui/display/types/display_constants.h" #include "url/origin.h" DEFINE_USER_DATA(BrowserWindowModalDialogDelegate); @@ -31,7 +31,9 @@ BrowserWindowModalDialogDelegate::BrowserWindowModalDialogDelegate( BrowserWindowInterface* browser) : browser_(browser), - scoped_unowned_user_data_(browser->GetUnownedUserDataHost(), *this) {} + scoped_unowned_user_data_(browser->GetUnownedUserDataHost(), *this) { + browser_->GetTabStripModel()->AddObserver(this); +} BrowserWindowModalDialogDelegate::~BrowserWindowModalDialogDelegate() = default; @@ -44,6 +46,10 @@ void BrowserWindowModalDialogDelegate::SetWebContentsBlocked( content::WebContents* web_contents, bool blocked) { + if (!blocked) { + fullscreen_blocks_.erase(web_contents); + } + TabStripModel* tab_strip_model = browser_->GetTabStripModel(); int index = tab_strip_model->GetIndexOfWebContents(web_contents); if (index == TabStripModel::kNoTab) { @@ -59,38 +65,16 @@ return; } - // Drop HTML fullscreen to give users context for making informed decisions. - // Skip browser-fullscreen, which is more expressly user-initiated. - // Skip fullscreen-within-tab, which shows the browser frame. if (blocked) { - FullscreenController* const fullscreen_controller = - ExclusiveAccessManager::From(browser_)->fullscreen_controller(); - content::FullscreenState fullscreen_state = - fullscreen_controller->GetFullscreenState(web_contents); - if (fullscreen_state.target_mode == content::FullscreenMode::kContent) { - // Skip origins with the automatic fullscreen content setting granted. - const url::Origin& requesting_origin = - fullscreen_controller->requesting_origin(); - const GURL url = requesting_origin.GetURL(); - const HostContentSettingsMap* const content_settings = - HostContentSettingsMapFactory::GetForProfile( - web_contents->GetBrowserContext()); - if (requesting_origin.opaque() || - requesting_origin != - web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin() || - content_settings->GetContentSetting( - url, url, ContentSettingsType::AUTOMATIC_FULLSCREEN) != - CONTENT_SETTING_ALLOW) { - // Defer exiting fullscreen to prevent synchronous window management - // messages (e.g. direct WndProc calls on Windows) from destroying the - // WebContents or callers while modal dialog presentation is on the - // stack. - content::GetUIThreadTaskRunner({})->PostTask( - FROM_HERE, base::BindOnce(&content::WebContents::ExitFullscreen, - web_contents->GetWeakPtr(), - /*will_cause_resize=*/true)); - } - } + // Defer exiting and blocking fullscreen to prevent synchronous window + // management messages (e.g. direct WndProc calls on Windows) from + // destroying the WebContents or callers while modal dialog presentation is + // on the stack. + content::GetUIThreadTaskRunner({})->PostTask( + FROM_HERE, + base::BindOnce( + &BrowserWindowModalDialogDelegate::DropFullscreenForSecurity, + weak_ptr_factory_.GetWeakPtr(), web_contents->GetWeakPtr())); } tab_strip_model->SetTabBlocked(index, blocked); @@ -106,6 +90,51 @@ } } +void BrowserWindowModalDialogDelegate::DropFullscreenForSecurity( + base::WeakPtr<content::WebContents> web_contents) { + if (!web_contents) { + return; + } + TabStripModel* tab_strip_model = browser_->GetTabStripModel(); + int index = tab_strip_model->GetIndexOfWebContents(web_contents.get()); + if (index == TabStripModel::kNoTab || !tab_strip_model->IsTabBlocked(index)) { + return; + } + + // Drop HTML fullscreen and prevent re-entry while a tab-modal dialog is + // showing, to give users context for making informed decisions. + // ForSecurityDropFullscreen() skips browser-fullscreen (more expressly + // user-initiated) and fullscreen-within-tab (shows the browser frame). + // Skip origins with the automatic fullscreen content setting granted. + FullscreenController* const fullscreen_controller = + ExclusiveAccessManager::From(browser_)->fullscreen_controller(); + content::FullscreenState fullscreen_state = + fullscreen_controller->GetFullscreenState(web_contents.get()); + const url::Origin primary_origin = + web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin(); + const url::Origin& origin = + fullscreen_state.target_mode == content::FullscreenMode::kContent + ? fullscreen_controller->requesting_origin() + : primary_origin; + const GURL url = origin.GetURL(); + const HostContentSettingsMap* const content_settings = + HostContentSettingsMapFactory::GetForProfile( + web_contents->GetBrowserContext()); + if (origin.opaque() || origin != primary_origin || + content_settings->GetContentSetting( + url, url, ContentSettingsType::AUTOMATIC_FULLSCREEN) != + CONTENT_SETTING_ALLOW) { + auto block = + web_contents->ForSecurityDropFullscreen(display::kInvalidDisplayId); + if (!block) { + // `web_contents` was destroyed while exiting fullscreen. + fullscreen_blocks_.erase(web_contents.get()); + return; + } + fullscreen_blocks_[web_contents.get()] = std::move(*block); + } +} + web_modal::WebContentsModalDialogHost* BrowserWindowModalDialogDelegate::GetWebContentsModalDialogHost( content::WebContents* web_contents) { @@ -115,6 +144,19 @@ web_contents); } +void BrowserWindowModalDialogDelegate::OnTabStripModelChanged( + TabStripModel* tab_strip_model, + const TabStripModelChange& change, + const TabStripSelectionChange& selection) { + if (change.type() == TabStripModelChange::kRemoved) { + for (const auto& contents : change.GetRemove()->contents) { + fullscreen_blocks_.erase(contents.contents); + } + } else if (change.type() == TabStripModelChange::kReplaced) { + fullscreen_blocks_.erase(change.GetReplace()->old_contents); + } +} + void BrowserWindowModalDialogDelegate:: NotifyModalDialogsPositionRequiresUpdate() { BrowserWindow* browser_window = BrowserWindow::FromBrowser(browser_); diff --git a/chrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.h b/chrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.h index 54ff1d613..181d816d 100644 --- a/chrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.h +++ b/chrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.h @@ -5,18 +5,27 @@ #ifndef CHROME_BROWSER_UI_WEB_MODAL_BROWSER_WINDOW_MODAL_DIALOG_DELEGATE_H_ #define CHROME_BROWSER_UI_WEB_MODAL_BROWSER_WINDOW_MODAL_DIALOG_DELEGATE_H_ +#include "base/containers/flat_map.h" +#include "base/functional/callback_helpers.h" +#include "base/memory/weak_ptr.h" #include "chrome/browser/ui/chrome_web_modal_dialog_manager_delegate.h" +#include "chrome/browser/ui/tabs/tab_strip_model_observer.h" #include "ui/base/unowned_user_data/scoped_unowned_user_data.h" class BrowserWindowInterface; +namespace content { +class WebContents; +} // namespace content + // Serves as the WebContentsModalDialogManagerDelegate for each tab's // WebContentsModalDialogManager. Handles dialog host lookup, tab blocking, // and fullscreen exit when modal dialogs are shown. // Delegate wiring (SetDelegate) is managed by Browser::SetAsDelegate to // ensure correct timing relative to layout during tab insertion. class BrowserWindowModalDialogDelegate - : public ChromeWebModalDialogManagerDelegate { + : public ChromeWebModalDialogManagerDelegate, + public TabStripModelObserver { public: DECLARE_USER_DATA(BrowserWindowModalDialogDelegate); @@ -36,12 +45,30 @@ web_modal::WebContentsModalDialogHost* GetWebContentsModalDialogHost( content::WebContents* web_contents) override; + // TabStripModelObserver: + void OnTabStripModelChanged( + TabStripModel* tab_strip_model, + const TabStripModelChange& change, + const TabStripSelectionChange& selection) override; + void NotifyModalDialogsPositionRequiresUpdate(); private: + void DropFullscreenForSecurity( + base::WeakPtr<content::WebContents> web_contents); + const raw_ptr<BrowserWindowInterface> browser_; + + // Per-WebContents fullscreen blocks held while a tab-modal dialog is showing. + // See WebContents::ForSecurityDropFullscreen(). + base::flat_map<content::WebContents*, base::ScopedClosureRunner> + fullscreen_blocks_; + ui::ScopedUnownedUserData<BrowserWindowModalDialogDelegate> scoped_unowned_user_data_; + + base::WeakPtrFactory<BrowserWindowModalDialogDelegate> weak_ptr_factory_{ + this}; }; #endif // CHROME_BROWSER_UI_WEB_MODAL_BROWSER_WINDOW_MODAL_DIALOG_DELEGATE_H_ diff --git a/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc b/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc index 9ad897c..67ae0ff 100644 --- a/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc +++ b/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc @@ -1026,6 +1026,28 @@ } IN_PROC_BROWSER_TEST_F(FullscreenControllerInteractiveTest, + BlockingContentsPreventsEnteringFullscreen) { + ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), GURL("about:blank"))); + WebContents* tab = browser()->GetTabStripModel()->GetActiveWebContents(); + ASSERT_FALSE(IsWindowFullscreenForTabOrPending()); + + // Blocking the tab for a modal dialog prevents entering tab fullscreen for + // the duration of the block. + BrowserWindowModalDialogDelegate::From(browser())->SetWebContentsBlocked( + tab, true); + EXPECT_FALSE( + content::ExecJs(tab, "document.documentElement.requestFullscreen()")); + EXPECT_FALSE(IsWindowFullscreenForTabOrPending()); + + // Unblocking allows entering tab fullscreen again. + BrowserWindowModalDialogDelegate::From(browser())->SetWebContentsBlocked( + tab, false); + EXPECT_TRUE( + content::ExecJs(tab, "document.documentElement.requestFullscreen()")); + EXPECT_TRUE(IsWindowFullscreenForTabOrPending()); +} + +IN_PROC_BROWSER_TEST_F(FullscreenControllerInteractiveTest, CapturedContentEntersFullscreenWithinTab) { SetDisableFullscreenWithinTab(false); // Simulate tab capture, as used by getDisplayMedia() content sharing.
Regression Test / PoC
diff --git a/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc b/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc
index 9ad897c..67ae0ff 100644
--- a/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc
+++ b/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc
@@ -1026,6 +1026,28 @@
}
IN_PROC_BROWSER_TEST_F(FullscreenControllerInteractiveTest,
+ BlockingContentsPreventsEnteringFullscreen) {
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), GURL("about:blank")));
+ WebContents* tab = browser()->GetTabStripModel()->GetActiveWebContents();
+ ASSERT_FALSE(IsWindowFullscreenForTabOrPending());
+
+ // Blocking the tab for a modal dialog prevents entering tab fullscreen for
+ // the duration of the block.
+ BrowserWindowModalDialogDelegate::From(browser())->SetWebContentsBlocked(
+ tab, true);
+ EXPECT_FALSE(
+ content::ExecJs(tab, "document.documentElement.requestFullscreen()"));
+ EXPECT_FALSE(IsWindowFullscreenForTabOrPending());
+
+ // Unblocking allows entering tab fullscreen again.
+ BrowserWindowModalDialogDelegate::From(browser())->SetWebContentsBlocked(
+ tab, false);
+ EXPECT_TRUE(
+ content::ExecJs(tab, "document.documentElement.requestFullscreen()"));
+ EXPECT_TRUE(IsWindowFullscreenForTabOrPending());
+}
+
+IN_PROC_BROWSER_TEST_F(FullscreenControllerInteractiveTest,
CapturedContentEntersFullscreenWithinTab) {
SetDisableFullscreenWithinTab(false);
// Simulate tab capture, as used by getDisplayMedia() content sharing.
Original Bug Report
UI spoofing via delayed HTML fullscreen during active tab-modal dialogs
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: When a tab-modal dialog is shown, BrowserWindowModalDialogDelegate::SetWebContentsBlocked performs a one-shot ExitFullscreen check but does not hold a persistent fullscreen blocker. Consequently, a page with transient user activation can request and enter HTML fullscreen shortly after triggering the dialog. This can hide the omnibox and security indicators while leaving the genuine dialog active, potentially enabling highly convincing browser UI spoofing.
Affected files:
chrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.cccomponents/web_modal/web_contents_modal_dialog_manager.ccchrome/browser/ui/tabs/tab_dialog_manager.cc
Estimated timestamp from git blame: 2020-03-10
Description
Potential Root Cause
BrowserWindowModalDialogDelegate::SetWebContentsBlocked (implemented in chrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.cc) is responsible for dropping HTML fullscreen when a tab-modal or constrained-window dialog is displayed. This ensures that the user can see the omnibox and verify the security context.
However, it only performs a one-shot exit from fullscreen and does not register a persistent fullscreen blocker (e.g., by incrementing fullscreen_blocker_count_ on the WebContents or holding a scoped blocker):
// chrome/browser/ui/web_modal/browser_window_modal_dialog_delegate.cc
if (blocked) {
content::FullscreenState fullscreen_state = ... ->GetFullscreenState(web_contents);
if (fullscreen_state.target_mode == content::FullscreenMode::kContent) {
...
if (content_settings->GetContentSetting(url, url,
ContentSettingsType::AUTOMATIC_FULLSCREEN) != CONTENT_SETTING_ALLOW) {
web_contents->ExitFullscreen(true); // One-shot; no blocker is held.
}
}
}
In contrast, other security-critical dialogs (like permission prompts, file system access dialogs, or Javascript alerts) utilize WebContentsImpl::ForSecurityDropFullscreen (implemented in content/browser/web_contents/web_contents_impl.cc), which increments fullscreen_blocker_count_ and returns a base::ScopedClosureRunner to decrement it when the dialog is dismissed:
// content/browser/web_contents/web_contents_impl.cc
++opener->fullscreen_blocker_count_;
blocked_contents_list.push_back(opener);
...
return base::ScopedClosureRunner(base::BindOnce([](...){
for (auto& wc : blocked_contents_list)
if (wc) --wc->fullscreen_blocker_count_;
}, std::move(blocked_contents_list)));
Because SetWebContentsBlocked does not update fullscreen_blocker_count_, a page can request and successfully re-enter fullscreen after the dialog is displayed on screen, provided it still has transient user activation. The browser-side permission check for entering fullscreen (WebContentsImpl::CanEnterFullscreenMode) only verifies that fullscreen_blocker_count_ == 0 for all openers and does not consult TabStripModel::IsTabBlocked() or WebContentsModalDialogManager::IsDialogActive().
While the dialog manager suppresses future browser-to-renderer input events via IgnoreInputEvents(), this suppression only blocks input event dispatching from the browser and does not filter incoming renderer-to-browser Mojo requests like LocalFrameHost::EnterFullscreen. As a result, the fullscreen request is successfully processed, the browser enters fullscreen, and the genuine native dialog remains active and centered over the newly enlarged viewport.
Potential Trigger Steps
Please note that these are suggested/potential steps, as our tooling agent does not yet have the ability to run code and verify this via an automated proof of concept.
- The user clicks on an attacker-controlled page, registering a transient user activation in both the renderer and browser.
- In the same click event, the attacker triggers a tab-modal dialog that does not consume transient user activation (such as a WebAuthn
navigator.credentials.get(...)request, appending an iframe that triggers an HTTP 401 Basic Authentication prompt, or calling a custom protocol handler). - The browser displays the tab-modal dialog and invokes
WebContentsModalDialogManager::BlockWebContentsInteraction(true). Since the tab is not yet in fullscreen, the check to callExitFullscreenis skipped, andfullscreen_blocker_count_remains0. - Within the activation window (e.g., 200ms later), the page executes a delayed script requesting fullscreen (
document.documentElement.requestFullscreen()). - The browser processes
RenderFrameHostImpl::EnterFullscreen. Sincefullscreen_blocker_count_is still0, the request is granted. - The browser enters HTML fullscreen, hiding the omnibox, tab strip, and other chrome, while the active native dialog remains visible and interactive on top. The attacker can then paint mock browser chrome around the genuine dialog to spoof a secure/trusted domain (e.g.,
https://accounts.google.com).
Suggested Fix
Instead of a one-shot check and exit in BrowserWindowModalDialogDelegate::SetWebContentsBlocked, the delegate should acquire and hold a persistent fullscreen blocker (e.g., by utilizing WebContentsImpl::ForSecurityDropFullscreen or holding a lifetime-bound base::ScopedClosureRunner that increments/decrements fullscreen_blocker_count_) for the entire duration that the WebContents is blocked by an active tab-modal dialog. This ensures that any subsequent fullscreen requests from the renderer are rejected as long as the modal dialog remains open.
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
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.