CVE-2026-5892
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
InstallElementAndApiInteractionBrowserTestchrome/browser/web_applications/install_element_browsertest.cc |
modified | |
InstallElementAndApiInteractionBrowserTestchrome/browser/web_applications/install_element_browsertest.cc |
modified | |
ifchrome/browser/web_applications/web_install_service_impl.cc |
modified |
Files Changed
chrome/browser/web_applications/install_element_browsertest.ccchrome/browser/web_applications/web_install_service_impl.ccchrome/browser/web_applications/web_install_service_impl.h
Patch
From 35d688b7c5984f28b1cfe4816a93b586fabf6cd6 Mon Sep 17 00:00:00 2001
From: Lia Hiscock <liahiscock@microsoft.com>
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 <luhua@microsoft.com>
Reviewed-by: Marijn Kruisselbrink <mek@chromium.org>
Commit-Queue: Lia Hiscock <liahiscock@microsoft.com>
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;
Regression Test / PoC
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
Original Bug Report
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?
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:
- Mojo definition: web_install.mojom
- Browser: web_install_service_impl.cc
- Registration: chrome_browser_interface_binders.cc
Transmission chain:
- 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. - In
WebInstallServiceImpl::InstallFromElement(), the browser unconditionally setstriggered_from_element_ = true, then delegates toInstall(). Install()eventually callsRequestWebInstallPermission(). When the permission status isASK, the method checkstriggered_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;
}
- The permission is auto-granted and the install proceeds without a permission prompt.
- Additionally,
triggered_from_element_is never reset, so all subsequentInstall()calls on the sameWebInstallServiceImplinstance also bypass the permission prompt (sticky state).
Steps to reproduce:
- Check out stable tag:
git checkout -b poc 145.0.7632.117 git apply poc_patch.diffautoninja -C out/Default chrome -j8- Place PoC files (
index.html,serve.py) in a directory and start the server:python3 serve.py
- 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
- 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 (kristinlee@microsoft.com), Dec 19 2025
- Message: “[<install> Element] Do not show the permission prompt”
- CL: https://chromium-review.googlesource.com/c/chromium/src/+/7266918
- Evidence: parent
f8e2c3ebdf4e904db590874c40168861ccf4f606has noInstallFromElementortriggered_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:
- The
triggered_from_element_flag is sticky — once set, it persists for the lifetime of theWebInstallServiceImplinstance, so all subsequentInstall()calls on the same document also bypass the permission prompt. - On M145 (stable), the bypass is in
RequestWebInstallPermission()after theGRANTED/DENIEDswitch — so the bypass fires when permission status isASK(the default). A user who previously clicked “Block” is protected. However, onmain(shipping in M146), the bypass was moved beforeRequestWebInstallPermission()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)
Is the security issue related to a crash?
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
- http://localhost:8080/index.html
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://chromium-review.googlesource.com/c/chromium/src/+/7266918
- https://chromium-review.googlesource.com/c/chromium/src/+/7278710
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/chrome_browser_interface_binders.cc
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/web_applications/web_install_service_impl.cc
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/public/mojom/web_install/web_install.mojom