Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in PWAs
DescriptionInsufficient policy enforcement in PWAs
ComponentPWAs
Bug ClassLogic Error
Tracker487568011
Fix commit35d688b7c598 (chromium/src) +105/-16
CISA KEVNot listed
CreditedTianyi Hu
Disclosed2026-04-07

Changed Functions

FunctionChangeNotes
InstallElementAndApiInteractionBrowserTest
chrome/browser/web_applications/install_element_browsertest.cc
modified
InstallElementAndApiInteractionBrowserTest
chrome/browser/web_applications/install_element_browsertest.cc
modified
if
chrome/browser/web_applications/web_install_service_impl.cc
modified

Files Changed

  • chrome/browser/web_applications/install_element_browsertest.cc
  • chrome/browser/web_applications/web_install_service_impl.cc
  • chrome/browser/web_applications/web_install_service_impl.h
From 35d688b7c5984f28b1cfe4816a93b586fabf6cd6 Mon Sep 17 00:00:00 2001
From: Lia Hiscock <[email protected]>
Date: Tue, 03 Mar 2026 14:39:46 -0800
Subject: [PATCH] [WebInstall] Fix triggered_from_element_ sticky state

WebInstallServiceImpl::InstallFromElement() set the member
triggered_from_element_ to true but never reset it. Because the service
instance is per-document, a subsequent navigator.install() call on the
same page reused the stale flag, skipping the permissions-policy gate
and the WEB_APP_INSTALLATION permission prompt.

Replace the mutable member with a local parameter by introducing a
private InstallInternal(options, callback, triggered_from_element)
helper. Install() forwards with false; InstallFromElement() forwards
with true. This ensures the flag is scoped to a single call and cannot
leak across Mojo method invocations.

Add a regression test that performs an <install>-element install
followed by a navigator.install() call on the same document with the
WEB_APP_INSTALLATION permission blocked, and verifies that the JS API
call is correctly denied.

Bug: 487938764, 487568011, 333795265
Change-Id: Ibe56b00bcb1018a25bfadce92dcdc98e4f9c4360
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7615447
Reviewed-by: Lu Huang <[email protected]>
Reviewed-by: Marijn Kruisselbrink <[email protected]>
Commit-Queue: Lia Hiscock <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1593535}
---

diff --git a/chrome/browser/web_applications/install_element_browsertest.cc b/chrome/browser/web_applications/install_element_browsertest.cc
index 46a85c1..23435c21f 100644
--- a/chrome/browser/web_applications/install_element_browsertest.cc
+++ b/chrome/browser/web_applications/install_element_browsertest.cc
@@ -665,4 +665,80 @@
   WaitForDismissEvent(kInstallElementId);
 }
 
+///////////////////////////////////////////////////////////////////////////////
+// Regression tests for interactions between <install> element and JS API.
+///////////////////////////////////////////////////////////////////////////////
+
+// Test fixture that enables both the <install> element and the
+// navigator.install() JS API, so we can test interactions between them on the
+// same document (same WebInstallServiceImpl instance).
+class InstallElementAndApiInteractionBrowserTest
+    : public InstallElementBrowserTest {
+ public:
+  InstallElementAndApiInteractionBrowserTest() {
+    additional_features_.InitWithFeatures(
+        {blink::features::kWebAppInstallation}, {});
+  }
+
+ private:
+  base::test::ScopedFeatureList additional_features_;
+};
+
+// Regression test for crbug.com/487568011: triggered_from_element was set by
+// InstallFromElement() but never reset, causing subsequent Install() calls via
+// navigator.install() on the same document to bypass the permissions-policy and
+// permission prompt checks.
+IN_PROC_BROWSER_TEST_F(InstallElementAndApiInteractionBrowserTest,
+                       InstallApiRespectsPermissionsAfterElementInstall) {
+  // Navigate to a page with <install> elements.
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(), https_server()->GetURL(kInstallElementPageStartUrl)));
+
+  auto auto_accept = SetAutoAcceptPWAInstallConfirmationForTesting();
+
+  // Set the <install> element's installurl to a another test page.
+  const GURL element_install_url =
+      https_server()->GetURL(kCustomIdPageInstallUrl);
+  ASSERT_TRUE(SetButtonInstallUrl(element_install_url));
+
+  // Step 1: Click the <install> element. This calls InstallFromElement() on the
+  // browser-side WebInstallServiceImpl instance for this document, which passes
+  // triggered_from_element = true.
+  {
+    ui_test_utils::BrowserCreatedObserver browser_created_observer;
+    ASSERT_TRUE(ClickElementWithId(kInstallElementId));
+    Browser* web_app_browser = browser_created_observer.Wait();
+    WaitForPromptActionEvent(kInstallElementId);
+    ASSERT_TRUE(AppBrowserController::IsWebApp(web_app_browser));
+  }
+
+  // Step 2: From the SAME page (same WebInstallServiceImpl instance), call
+  // navigator.install(url, manifest_id) via JS. This uses the Install() Mojo
+  // method, NOT InstallFromElement(). The permission check should NOT be
+  // bypassed by the sticky triggered_from_element_ flag from step 1.
+  const GURL api_install_url =
+      https_server()->GetURL(kNoCustomIdPageInstallUrl);
+  const GURL api_manifest_id = https_server()->GetURL(kNoCustomIdPageId);
+
+  // Block the WEB_APP_INSTALLATION permission so that if the logic to prompt
+  // for permission is correctly reached, it will be denied. With the sticky
+  // state bug, the permission check would've been skipped entirely and the
+  // install would've succeeded.
+  BlockWebInstallPermission(api_install_url);
+
+  auto result = content::EvalJs(
+      web_contents(), "navigator.install('" + api_install_url.spec() + "', '" +
+                          api_manifest_id.spec() +
+                          "')"
+                          ".then(result => 'success')"
+                          ".catch(error => error.name)");
+
+  // The Install API call should fail because the WEB_APP_INSTALLATION
+  // permission was blocked. With the sticky state bug,
+  // triggered_from_element_ would still be true from step 1, causing
+  // Install() to skip all permission checks and auto-grant, resulting in
+  // 'success' instead.
+  EXPECT_EQ("AbortError", result.ExtractString());
+}
+
 }  // namespace web_app
diff --git a/chrome/browser/web_applications/web_install_service_impl.cc b/chrome/browser/web_applications/web_install_service_impl.cc
index 3ef74d6c..faadccb 100644
--- a/chrome/browser/web_applications/web_install_service_impl.cc
+++ b/chrome/browser/web_applications/web_install_service_impl.cc
@@ -190,6 +190,21 @@
 
 void WebInstallServiceImpl::Install(blink::mojom::InstallOptionsPtr options,
                                     InstallCallback callback) {
+  InstallInternal(std::move(options), std::move(callback),
+                  /*triggered_from_element=*/false);
+}
+
+void WebInstallServiceImpl::InstallFromElement(
+    blink::mojom::InstallOptionsPtr options,
+    InstallCallback callback) {
+  InstallInternal(std::move(options), std::move(callback),
+                  /*triggered_from_element=*/true);
+}
+
+void WebInstallServiceImpl::InstallInternal(
+    blink::mojom::InstallOptionsPtr options,
+    InstallCallback callback,
+    bool triggered_from_element) {
   // Create source ids for UKM logging.
   ukm::SourceId requesting_page_source_id =
       options ? render_frame_host().GetPageUkmSourceId()
@@ -251,18 +266,18 @@
         }
         std::move(callback).Run(install_result, manifest_id_result);
       },
-      std::move(callback), triggered_from_element_, requesting_page_source_id,
+      std::move(callback), triggered_from_element, requesting_page_source_id,
       installed_app_source_id);
 
   web_app::WebInstallServiceType install_type =
       options ? web_app::WebInstallServiceType::kBackgroundDocument
               : web_app::WebInstallServiceType::kCurrentDocument;
   base::UmaHistogramEnumeration(
-      triggered_from_element_ ? kInstallElementTypeUma : kInstallApiTypeUma,
+      triggered_from_element ? kInstallElementTypeUma : kInstallApiTypeUma,
       install_type);
   base::UmaHistogramEnumeration(
       base::StrCat({"WebApp.WebInstallService.",
-                    triggered_from_element_ ? "Element" : "Api",
+                    triggered_from_element ? "Element" : "Api",
                     ".InstallType"}),
       install_type);
 
@@ -314,10 +329,10 @@
 
   // Skip requesting permission in two cases:
   // 1. The install URL matches the current document URL (user is installing
-  //    the page they're on, even if using background install syntax).
-  // 2. Install triggered from the <install> element (permission is handled
-  //    by the element itself). In both cases, the install dialog is shown.
-  if (triggered_from_element_ || install_target == last_committed_url_) {
+  //    the page they're currently on, just using background install syntax).
+  // 2. Install triggered from the <install> element.
+  // In both cases, the install dialog is always shown.
+  if (triggered_from_element || install_target == last_committed_url_) {
     OnPermissionDecided(
         std::move(callback_with_metrics),
         std::vector<content::PermissionResult>({content::PermissionResult(
@@ -341,13 +356,6 @@
       weak_ptr_factory_.GetWeakPtr(), std::move(callback_with_metrics)));
 }
 
-void WebInstallServiceImpl::InstallFromElement(
-    blink::mojom::InstallOptionsPtr options,
-    InstallCallback callback) {
-  triggered_from_element_ = true;
-  Install(std::move(options), std::move(callback));
-}
-
 void WebInstallServiceImpl::OnInstallNotSupportedDialogClosed(
     InstallCallbackWithMetrics callback_with_metrics) {
   std::move(callback_with_metrics)
diff --git a/chrome/browser/web_applications/web_install_service_impl.h b/chrome/browser/web_applications/web_install_service_impl.h
index 6164059..b8325df 100644
--- a/chrome/browser/web_applications/web_install_service_impl.h
+++ b/chrome/browser/web_applications/web_install_service_impl.h
@@ -98,6 +98,13 @@
                           InstallCallback callback) override;
 
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/web_applications/install_element_browsertest.cc b/chrome/browser/web_applications/install_element_browsertest.cc
index 46a85c1..23435c21f 100644
--- a/chrome/browser/web_applications/install_element_browsertest.cc
+++ b/chrome/browser/web_applications/install_element_browsertest.cc
@@ -665,4 +665,80 @@
   WaitForDismissEvent(kInstallElementId);
 }
 
+///////////////////////////////////////////////////////////////////////////////
+// Regression tests for interactions between <install> element and JS API.
+///////////////////////////////////////////////////////////////////////////////
+
+// Test fixture that enables both the <install> element and the
+// navigator.install() JS API, so we can test interactions between them on the
+// same document (same WebInstallServiceImpl instance).
+class InstallElementAndApiInteractionBrowserTest
+    : public InstallElementBrowserTest {
+ public:
+  InstallElementAndApiInteractionBrowserTest() {
+    additional_features_.InitWithFeatures(
+        {blink::features::kWebAppInstallation}, {});
+  }
+
+ private:
+  base::test::ScopedFeatureList additional_features_;
+};
+
+// Regression test for crbug.com/487568011: triggered_from_element was set by
+// InstallFromElement() but never reset, causing subsequent Install() calls via
+// navigator.install() on the same document to bypass the permissions-policy and
+// permission prompt checks.
+IN_PROC_BROWSER_TEST_F(InstallElementAndApiInteractionBrowserTest,
+                       InstallApiRespectsPermissionsAfterElementInstall) {
+  // Navigate to a page with <install> elements.
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(), https_server()->GetURL(kInstallElementPageStartUrl)));
+
+  auto auto_accept = SetAutoAcceptPWAInstallConfirmationForTesting();
+
+  // Set the <install> element's installurl to a another test page.
+  const GURL element_install_url =
+      https_server()->GetURL(kCustomIdPageInstallUrl);
+  ASSERT_TRUE(SetButtonInstallUrl(element_install_url));
+
+  // Step 1: Click the <install> element. This calls InstallFromElement() on the
+  // browser-side WebInstallServiceImpl instance for this document, which passes
+  // triggered_from_element = true.
+  {
+    ui_test_utils::BrowserCreatedObserver browser_created_observer;
+    ASSERT_TRUE(ClickElementWithId(kInstallElementId));
+    Browser* web_app_browser = browser_created_observer.Wait();
+    WaitForPromptActionEvent(kInstallElementId);
+    ASSERT_TRUE(AppBrowserController::IsWebApp(web_app_browser));
+  }
+
+  // Step 2: From the SAME page (same WebInstallServiceImpl instance), call
+  // navigator.install(url, manifest_id) via JS. This uses the Install() Mojo
+  // method, NOT InstallFromElement(). The permission check should NOT be
+  // bypassed by the sticky triggered_from_element_ flag from step 1.
+  const GURL api_install_url =
+      https_server()->GetURL(kNoCustomIdPageInstallUrl);
+  const GURL api_manifest_id = https_server()->GetURL(kNoCustomIdPageId);
+
+  // Block the WEB_APP_INSTALLATION permission so that if the logic to prompt
+  // for permission is correctly reached, it will be denied. With the sticky
+  // state bug, the permission check would've been skipped entirely and the
+  // install would've succeeded.
+  BlockWebInstallPermission(api_install_url);
+
+  auto result = content::EvalJs(
+      web_contents(), "navigator.install('" + api_install_url.spec() + "', '" +
+                          api_manifest_id.spec() +
+                          "')"
+                          ".then(result => 'success')"
+                          ".catch(error => error.name)");
+
+  // The Install API call should fail because the WEB_APP_INSTALLATION
+  // permission was blocked. With the sticky state bug,
+  // triggered_from_element_ would still be true from step 1, causing
+  // Install() to skip all permission checks and auto-grant, resulting in
+  // 'success' instead.
+  EXPECT_EQ("AbortError", result.ExtractString());
+}
+
 }  // namespace web_app
Loading diff…

Original Bug Report

reported by [email protected]

WebInstallService InstallFromElement() bypasses WEB_APP_INSTALLATION permission prompt via renderer-callable Mojo method


Report description

WebInstallService InstallFromElement() bypasses WEB_APP_INSTALLATION permission prompt via renderer-callable Mojo method


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/web_applications/web_install_service_impl.cc


The problem

Please describe the technical details of the vulnerability

WebInstallServiceImpl::InstallFromElement() sets triggered_from_element_ = true then calls Install(). In RequestWebInstallPermission(), this flag causes the WEB_APP_INSTALLATION permission to be auto-granted without showing a permission prompt. A compromised renderer can call InstallFromElement() directly via Mojo to install cross-origin PWAs without user consent to the permission.

The permission bypass was designed intentionally for the <install> HTML element — the rationale being that a user clicking an <install> element already implies consent, so no separate permission prompt is needed. However, this design violates the Chromium security model: Mojo does not enforce which renderer code path invoked a method. The browser cannot distinguish between a call from a legitimate <install> element click handler and a call from compromised renderer code. Both arrive as the same InstallFromElement() IPC. The triggered_from_element_ flag is therefore a renderer-trusted boolean that the browser uses to make a security decision — a classic Mojo trust boundary violation. The existing test InstallWithUrl_IgnoresDeniedPermission explicitly asserts this bypass works, confirming the design intent, but the threat model was not considered.

Vulnerable files:

Transmission chain:

  1. The renderer calls WebInstallService::InstallFromElement(options, callback) via Mojo. This method is intended only for the <install> HTML element, but any renderer code can call it.
  2. In WebInstallServiceImpl::InstallFromElement(), the browser unconditionally sets triggered_from_element_ = true, then delegates to Install().
  3. Install() eventually calls RequestWebInstallPermission(). When the permission status is ASK, the method checks triggered_from_element_:
if (triggered_from_element_) {
  // Do not show permission prompt for installs from an element entry point.
  // Grant permission by default if not already granted or denied.
  std::move(callback).Run(
      std::vector<content::PermissionResult>({content::PermissionResult(
          PermissionStatus::GRANTED,
          content::PermissionStatusSource::UNSPECIFIED)}));
  return;
}
  1. The permission is auto-granted and the install proceeds without a permission prompt.
  2. Additionally, triggered_from_element_ is never reset, so all subsequent Install() calls on the same WebInstallServiceImpl instance also bypass the permission prompt (sticky state).

Steps to reproduce:

  1. Check out stable tag: git checkout -b poc 145.0.7632.117
  2. git apply poc_patch.diff
  3. autoninja -C out/Default chrome -j8
  4. Place PoC files (index.html, serve.py) in a directory and start the server:
    • python3 serve.py
  5. Launch patched Chrome:
    • out/Default/Chromium.app/Contents/MacOS/Chromium --user-data-dir=/tmp/chrome-poc-test --enable-blink-features=WebAppInstallation http://localhost:8080/index.html
  6. Click “Install cross-origin PWA”. The install dialog appears directly without a permission prompt.

The renderer patch replaces Install() with InstallFromElement() in the renderer’s navigator.install() implementation, simulating a compromised renderer. --enable-blink-features=WebAppInstallation is needed to expose the JS API, but the underlying Mojo interface is already bound by default (the kWebAppInstallation base feature is enabled by default).

Bisect: Introducing commit: 1fd86f307a82e5a58bb86dafcb0e19cb7c29016e

  • Author: Kristin Lee ([email protected]), Dec 19 2025
  • Message: “[<install> Element] Do not show the permission prompt”
  • CL: https://chromium-review.googlesource.com/c/chromium/src/+/7266918
  • Evidence: parent f8e2c3ebdf4e904db590874c40168861ccf4f606 has no InstallFromElement or triggered_from_element_; this commit adds both.
  • Cr-Commit-Position: refs/heads/main@{#1560950} — after M144 branch (1552494), before M145 branch (1568190).
  • Earliest affected: M145. Latest confirmed: M145 (145.0.7632.117).

Note: a follow-up commit 8d4441fe2c2a7f58a3db483da333f9ed0b933822 (Jan 20 2026, CL https://chromium-review.googlesource.com/c/chromium/src/+/7278710) changed the bypass from “allow unless previously denied” to “always GRANTED unconditionally,” worsening the bug in M146+.

Suggested fix (attached as fix.diff):

Remove triggered_from_element_ from the permission bypass condition in Install():

-  if (triggered_from_element_ || install_target == last_committed_url_) {
+  if (install_target == last_committed_url_) {

The browser cannot verify that InstallFromElement() was called from a genuine <install> element versus a compromised renderer. The fix removes the renderer-trusted flag from the permission decision, so InstallFromElement() goes through the normal RequestWebInstallPermission() flow. The triggered_from_element_ field is retained for UMA/UKM metrics routing only. The existing InstallWithUrl_IgnoresDeniedPermission test is updated to expect permission denial instead of bypass.

Impact analysis

A compromised renderer can install arbitrary cross-origin PWAs without the WEB_APP_INSTALLATION permission prompt. This bypasses the permission model that gates the Web Install API.

User harm:

  • Phishing: Install a malicious PWA mimicking a banking/email app — PWAs appear as native apps with no browser chrome, making phishing more convincing.
  • Persistence: PWAs persist beyond browser sessions and appear in the OS app list/dock, giving the attacker a persistent foothold.
  • No user awareness: The permission prompt is the user’s only signal that a cross-origin install is happening. Bypassing it means the user sees only the install dialog (which may look legitimate) without the preceding “allow this site to install apps?” consent.

Reachability: A compromised renderer in any top-level frame on desktop Chrome. Reachable via renderer exploits (e.g., V8 bugs) or compromised ad network code.

Compounding factors:

  1. The triggered_from_element_ flag is sticky — once set, it persists for the lifetime of the WebInstallServiceImpl instance, so all subsequent Install() calls on the same document also bypass the permission prompt.
  2. On M145 (stable), the bypass is in RequestWebInstallPermission() after the GRANTED/DENIED switch — so the bypass fires when permission status is ASK (the default). A user who previously clicked “Block” is protected. However, on main (shipping in M146), the bypass was moved before RequestWebInstallPermission() entirely (line 320: if (triggered_from_element_ || ...)OnPermissionDecided(GRANTED)), meaning the permission is auto-granted even if the user previously denied it. This makes the M146 variant strictly worse.

The cause

What version of Chrome have you found the security issue in?

145.0.7632.117 (Stable)

No, it is not related to a crash.

Choose the type of vulnerability

Permissions Bypass

How would you like to be publicly acknowledged for your report?

Tianyi Hu

View on issue tracker