Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect security UI in WebAppInstalls
DescriptionIncorrect security UI in WebAppInstalls
ComponentWebAppInstalls
Bug ClassLogic Error
Tracker514064139
Fix commitd47269cae9d3 (chromium/src) +78/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Files Changed

  • chrome/browser/ui/web_applications/app_browser_controller.cc
  • chrome/browser/ui/web_applications/web_app_browser_controller.cc
  • chrome/browser/ui/web_applications/web_app_uninstall_browsertest.cc
From d47269cae9d3bfbc251774f7b1e4dc974d856216 Mon Sep 17 00:00:00 2001
From: Nate Chapin <japhet@chromium.org>
Date: Tue, 19 May 2026 18:10:17 -0700
Subject: [PATCH] [PWA] Force close windows on uninstallation to prevent origin spoofing

When a PWA is uninstalled, we attempt to close its windows. However,
beforeunload handlers could block this close. If the window remained
open, it was no longer considered "installed", which caused
ShouldShowCustomTabBar() to return false, hiding the custom toolbar.
This allowed the window to navigate without a location bar, leading
to potential origin spoofing.

This CL fixes this by:
1. Forcibly closing PWA windows on uninstallation by bypassing
   beforeunload handlers (using Browser::TryToCloseWindow).
2. As a failsafe, forcing the custom tab bar to show if the app is
   uninstalled but the window somehow remains open.

TAG=agy
CONV=e03aa75d-041f-4b5e-a3de-b5882bcf14d1

Fixed: 514064139
Change-Id: Idbb1ce99f20165e558c8d37a69e8bf5a855e2c2d
Fixed: 514064139
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7857510
Reviewed-by: Daniel Murphy <dmurph@chromium.org>
Commit-Queue: Nate Chapin <japhet@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1633235}
---

diff --git a/chrome/browser/ui/web_applications/app_browser_controller.cc b/chrome/browser/ui/web_applications/app_browser_controller.cc
index 35960ff2..86f7db37 100644
--- a/chrome/browser/ui/web_applications/app_browser_controller.cc
+++ b/chrome/browser/ui/web_applications/app_browser_controller.cc
@@ -286,7 +286,11 @@
 
 bool AppBrowserController::ShouldShowCustomTabBar() const {
   if (!IsInstalled()) {
-    return false;
+    // If the app is uninstalled, the window should be closed. If it is
+    // somehow kept open (e.g. due to a download in progress), we must show
+    // the custom tab bar as a failsafe to reveal the URL/origin and prevent
+    // origin spoofing.
+    return true;
   }
 
   content::WebContents* web_contents =
diff --git a/chrome/browser/ui/web_applications/web_app_browser_controller.cc b/chrome/browser/ui/web_applications/web_app_browser_controller.cc
index 533834b..5094f153 100644
--- a/chrome/browser/ui/web_applications/web_app_browser_controller.cc
+++ b/chrome/browser/ui/web_applications/web_app_browser_controller.cc
@@ -31,6 +31,7 @@
 #include "chrome/browser/ui/browser_window.h"
 #include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
 #include "chrome/browser/ui/tabs/tab_menu_model_factory.h"
+#include "chrome/browser/ui/unload_controller.h"
 #include "chrome/browser/ui/web_applications/web_app_dialogs.h"
 #include "chrome/browser/ui/web_applications/web_app_launch_utils.h"
 #include "chrome/browser/ui/web_applications/web_app_tabbed_utils.h"
@@ -462,7 +463,11 @@
     const webapps::AppId& uninstalled_app_id,
     webapps::WebappUninstallSource uninstall_source) {
   if (uninstalled_app_id == app_id()) {
+    // Forcibly close the window, skipping any beforeunload prompts.
+    UnloadController::From(browser())->set_force_skip_warning_user_on_close(
+        true);
     chrome::CloseWindow(browser());
+    UpdateCustomTabBarVisibility(/*animate=*/false);
   }
 }
 
diff --git a/chrome/browser/ui/web_applications/web_app_uninstall_browsertest.cc b/chrome/browser/ui/web_applications/web_app_uninstall_browsertest.cc
index 0bf1479..e4d9822 100644
--- a/chrome/browser/ui/web_applications/web_app_uninstall_browsertest.cc
+++ b/chrome/browser/ui/web_applications/web_app_uninstall_browsertest.cc
@@ -13,6 +13,7 @@
 #include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
 #include "chrome/browser/ui/browser_window/public/global_browser_collection.h"
 #include "chrome/browser/ui/tabs/tab_strip_model.h"
+#include "chrome/browser/ui/web_applications/app_browser_controller.h"
 #include "chrome/browser/ui/web_applications/test/web_app_browsertest_util.h"
 #include "chrome/browser/ui/web_applications/web_app_browsertest_base.h"
 #include "chrome/browser/web_applications/test/web_app_test_observers.h"
@@ -206,4 +207,71 @@
       provider->registrar_unsafe().GetInstallState(app_id).has_value());
 }
 
+// Tests that uninstalling a PWA with a window opened that has a beforeunload
+// handler still closes the window (bypassing the handler).
+IN_PROC_BROWSER_TEST_F(WebAppUninstallBrowserTest,
+                       UninstallPwaWithWindowOpenedAndBeforeunload) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  const GURL app_url = GetSecureAppURL();
+  const webapps::AppId app_id = InstallPWA(app_url);
+  Browser* const app_browser = LaunchWebAppBrowserAndWait(app_id);
+
+  EXPECT_TRUE(IsBrowserOpen(app_browser));
+
+  content::WebContents* const web_contents =
+      app_browser->tab_strip_model()->GetActiveWebContents();
+
+  // Inject beforeunload handler.
+  ASSERT_TRUE(
+      content::ExecJs(web_contents,
+                      "window.addEventListener('beforeunload', (event) => {\n"
+                      "  event.preventDefault();\n"
+                      "  event.returnValue = '';\n"
+                      "});"));
+
+  // Prep contents for beforeunload (triggers user activation).
+  content::PrepContentsForBeforeUnloadTest(web_contents);
+
+  UninstallWebApp(app_id);
+
+  // The browser window should be closed because we bypassed beforeunload.
+  EXPECT_FALSE(IsBrowserOpen(app_browser));
+}
+
+// Tests that ShouldShowCustomTabBar returns true if the app is uninstalled,
+// which acts as a failsafe if the window is somehow kept open.
+IN_PROC_BROWSER_TEST_F(WebAppUninstallBrowserTest,
+                       ShouldShowCustomTabBarForUninstalledApp) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  const GURL app_url = GetSecureAppURL();
+  const webapps::AppId app_id = InstallPWA(app_url);
+  Browser* const app_browser = LaunchWebAppBrowserAndWait(app_id);
+
+  EXPECT_TRUE(IsBrowserOpen(app_browser));
+  auto* app_controller = app_browser->app_controller();
+  ASSERT_TRUE(app_controller);
+
+  EXPECT_FALSE(app_controller->ShouldShowCustomTabBar());
+
+  // Uninstall the app but do not run the loop yet.
+  WebAppProvider* const provider = WebAppProvider::GetForTest(profile());
+  base::test::TestFuture<webapps::UninstallResultCode> future;
+  DCHECK(provider->registrar_unsafe().CanUserUninstallWebApp(app_id));
+  provider->scheduler().RemoveUserUninstallableManagements(
+      app_id, webapps::WebappUninstallSource::kAppMenu, future.GetCallback());
+  EXPECT_EQ(future.Get(), webapps::UninstallResultCode::kAppRemoved);
+
+  // If the window is still open (meaning the close task hasn't run yet),
+  // verify that ShouldShowCustomTabBar() is true.
+  if (IsBrowserOpen(app_browser)) {
+    EXPECT_TRUE(app_controller->ShouldShowCustomTabBar());
+  }
+
+  // Wait for the window to close and clean up.
+  ui_test_utils::WaitForBrowserToClose(app_browser);
+  EXPECT_FALSE(IsBrowserOpen(app_browser));
+}
+
 }  // namespace web_app
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/ui/web_applications/web_app_uninstall_browsertest.cc b/chrome/browser/ui/web_applications/web_app_uninstall_browsertest.cc
index 0bf1479..e4d9822 100644
--- a/chrome/browser/ui/web_applications/web_app_uninstall_browsertest.cc
+++ b/chrome/browser/ui/web_applications/web_app_uninstall_browsertest.cc
@@ -13,6 +13,7 @@
 #include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
 #include "chrome/browser/ui/browser_window/public/global_browser_collection.h"
 #include "chrome/browser/ui/tabs/tab_strip_model.h"
+#include "chrome/browser/ui/web_applications/app_browser_controller.h"
 #include "chrome/browser/ui/web_applications/test/web_app_browsertest_util.h"
 #include "chrome/browser/ui/web_applications/web_app_browsertest_base.h"
 #include "chrome/browser/web_applications/test/web_app_test_observers.h"
@@ -206,4 +207,71 @@
       provider->registrar_unsafe().GetInstallState(app_id).has_value());
 }
 
+// Tests that uninstalling a PWA with a window opened that has a beforeunload
+// handler still closes the window (bypassing the handler).
+IN_PROC_BROWSER_TEST_F(WebAppUninstallBrowserTest,
+                       UninstallPwaWithWindowOpenedAndBeforeunload) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  const GURL app_url = GetSecureAppURL();
+  const webapps::AppId app_id = InstallPWA(app_url);
+  Browser* const app_browser = LaunchWebAppBrowserAndWait(app_id);
+
+  EXPECT_TRUE(IsBrowserOpen(app_browser));
+
+  content::WebContents* const web_contents =
+      app_browser->tab_strip_model()->GetActiveWebContents();
+
+  // Inject beforeunload handler.
+  ASSERT_TRUE(
+      content::ExecJs(web_contents,
+                      "window.addEventListener('beforeunload', (event) => {\n"
+                      "  event.preventDefault();\n"
+                      "  event.returnValue = '';\n"
+                      "});"));
+
+  // Prep contents for beforeunload (triggers user activation).
+  content::PrepContentsForBeforeUnloadTest(web_contents);
+
+  UninstallWebApp(app_id);
+
+  // The browser window should be closed because we bypassed beforeunload.
+  EXPECT_FALSE(IsBrowserOpen(app_browser));
+}
+
+// Tests that ShouldShowCustomTabBar returns true if the app is uninstalled,
+// which acts as a failsafe if the window is somehow kept open.
+IN_PROC_BROWSER_TEST_F(WebAppUninstallBrowserTest,
+                       ShouldShowCustomTabBarForUninstalledApp) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  const GURL app_url = GetSecureAppURL();
+  const webapps::AppId app_id = InstallPWA(app_url);
+  Browser* const app_browser = LaunchWebAppBrowserAndWait(app_id);
+
+  EXPECT_TRUE(IsBrowserOpen(app_browser));
+  auto* app_controller = app_browser->app_controller();
+  ASSERT_TRUE(app_controller);
+
+  EXPECT_FALSE(app_controller->ShouldShowCustomTabBar());
+
+  // Uninstall the app but do not run the loop yet.
+  WebAppProvider* const provider = WebAppProvider::GetForTest(profile());
+  base::test::TestFuture<webapps::UninstallResultCode> future;
+  DCHECK(provider->registrar_unsafe().CanUserUninstallWebApp(app_id));
+  provider->scheduler().RemoveUserUninstallableManagements(
+      app_id, webapps::WebappUninstallSource::kAppMenu, future.GetCallback());
+  EXPECT_EQ(future.Get(), webapps::UninstallResultCode::kAppRemoved);
+
+  // If the window is still open (meaning the close task hasn't run yet),
+  // verify that ShouldShowCustomTabBar() is true.
+  if (IsBrowserOpen(app_browser)) {
+    EXPECT_TRUE(app_controller->ShouldShowCustomTabBar());
+  }
+
+  // Wait for the window to close and clean up.
+  ui_test_utils::WaitForBrowserToClose(app_browser);
+  EXPECT_FALSE(IsBrowserOpen(app_browser));
+}
+
 }  // namespace web_app
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential Origin Spoofing in PWA Windows after Uninstallation via beforeunload Persistence

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 PWA window can be persisted after uninstallation by using a beforeunload handler to intercept the window-close command. In this uninstalled-but-open state, the window suppresses essential origin indicators like the Custom Tab Bar, enabling origin spoofing for cross-origin navigations.

Affected files:

  • chrome/browser/ui/web_applications/app_browser_controller.cc
  • chrome/browser/ui/web_applications/web_app_browser_controller.cc
  • chrome/browser/ui/views/web_apps/frame_toolbar/web_app_origin_text.cc
  • chrome/browser/web_applications/web_app_registrar.cc
  • chrome/browser/web_applications/jobs/uninstall/remove_web_app_job.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

Summary

A potential vulnerability in the Progressive Web App (PWA) UI logic allows an uninstalled app to maintain an open window that lacks all browser-supplied origin indicators. By using a beforeunload handler to cancel the window closure triggered by uninstallation, an attacker can keep a chromeless TYPE_APP window active. Because the security logic fails open when an app is no longer found in the registrar, the window will not display origin attribution (the Custom Tab Bar) even during cross-origin navigations.

Technical Details

In Chromium, PWA windows (standalone or minimal-ui) lack a standard omnibox. Security and origin attribution are primarily provided by the Custom Tab Bar (CCT), which appears when the window navigates out of the app’s scope, and a transient origin ‘flash’ in the title bar.

1. Fail-Open Logic in ShouldShowCustomTabBar

The visibility of the persistent Custom Tab Bar is determined by AppBrowserController::ShouldShowCustomTabBar(). The implementation begins with a check that fails open if the app is no longer considered installed:

// chrome/browser/ui/web_applications/app_browser_controller.cc
bool AppBrowserController::ShouldShowCustomTabBar() const {
  if (!IsInstalled()) {
    return false;
  }
  // ... checks for out-of-scope navigation ...
}

When an app is uninstalled, IsInstalled() returns false. Consequently, ShouldShowCustomTabBar() returns false, and the Custom Tab Bar is suppressed for all navigations in that window, regardless of whether the navigation is out-of-scope or to an insecure origin.

2. Persistence via beforeunload

When a PWA is uninstalled, WebAppBrowserController::OnWebAppUninstalled() triggers a window closure via chrome::CloseWindow(browser()). This follows the standard window closing flow, which respects beforeunload event handlers. An attacker can use a beforeunload handler to prompt the user to “Stay” on the page. If the user chooses to stay, the TYPE_APP window remains active while the underlying app metadata is removed from the WebAppRegistrar.

3. Suppression of Origin Indicators

Once the app is uninstalled but the window remains open, the primary security indicators fail to provide attribution:

  • Custom Tab Bar: Suppressed by the fail-open check in ShouldShowCustomTabBar().
  • Origin Flash: WebAppOriginText::DidFinishNavigation relies on GetAppStartUrl(). When an app is uninstalled, the registrar returns an empty GURL, causing the origin flash logic to return early without displaying any attribution.
  • Window Title: WebAppBrowserController::GetTitle() attempts to prefix the page title with the app name. Since the app is uninstalled, GetAppShortName() returns an empty string. The logic then defaults to returning the raw page <title> because base::StartsWith(raw_title, "") is always true in Chromium’s string utility functions.

This results in a window with no origin indicators that can navigate to any site, allowing a potential spoofing of trusted origins within a standalone window frame.

Potential Reproduction Steps (Suggested)

  1. Serve a PWA from https://attacker.example with display: standalone and a beforeunload handler that prompts the user to stay.
  2. Install and open the PWA.
  3. Uninstall the app via the browser’s menu (‘Uninstall [App]…’).
  4. When the ‘Leave site?’ prompt appears, click ‘Stay’.
  5. The window remains open, but the app is now uninstalled from the system.
  6. From the attacker page, navigate the window to a phishing target (e.g., window.location = 'https://trusted.example').
  7. Observe that the window shows no Custom Tab Bar and no origin flash, and the title bar displays the attacker-controlled page title without the expected app prefix.

Suggested Fix

  • Modify AppBrowserController::ShouldShowCustomTabBar() to fail-safe: if the installation status is unknown or the app is uninstalled, the Custom Tab Bar should be shown by default for all navigations.
  • Alternatively, ensure that WebAppBrowserController::OnWebAppUninstalled uses a closing mechanism that bypasses beforeunload handlers to ensure the window is terminated upon uninstallation.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


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