CVE-2026-87597
Overview
Background
- Trusted Web Activity (TWA)
- an Android mode where a first-party-verified web origin renders full-screen inside a
CustomTabs-based app with Chrome’s URL bar and browser controls hidden. - Browser controls / toolbar
- the Chrome UI (URL bar, security indicator) that a TWA normally suppresses;
@BrowserControlsStatevaluesSHOWNandHIDDENdecide whether it is force-displayed. - `ConnectionSecurityLevel`
- an enum describing a page’s connection trust, including
SECURE,WARNING(e.g. mixed content / passive downgrade),NONE(plain HTTP), andDANGEROUS(active certificate errors). - Security indicator suppression
- the spoofing risk where hiding the toolbar hides the security state, so a downgraded connection looks as trustworthy as a
SECUREone.
Root Cause Analysis
computeBrowserControlsState(@Nullable Tab tab) in TrustedWebActivityBrowserControlsVisibilityManager decides whether the toolbar must be force-shown before falling through to the normal shouldShowBrowserControlsAndCloseButton(tab) logic that lets a TWA hide it.
Pre-patch, it force-showed only when getSecurityLevel(tab) == ConnectionSecurityLevel.DANGEROUS, so every non-DANGEROUS state — including WARNING (mixed content) — fell through and allowed the origin to keep the toolbar hidden. The invariant that a user must see a security indicator whenever the connection is not trustworthy was therefore violated for the WARNING band.
The fix relaxes the force-show gate: it now also returns BrowserControlsState.SHOWN when securityLevel == ConnectionSecurityLevel.WARNING, so mixed-content and similar warning pages can no longer suppress the indicator inside a TWA. The removed comment (“Force browser controls to show when the security level is dangerous”) documents the old, too-narrow behavior.
ConnectionSecurityLevel.DANGEROUS as toolbar-forcing, letting a WARNING-level (e.g. mixed-content) page keep the security indicator hidden in a TWA; the fix broadens the force-show condition to include WARNING so those states can no longer be visually suppressed.Attack Path
- Launch a TWA for an attacker-controlled origin A malicious app opens its verified web origin as a Trusted Web Activity, which by default hides the URL bar and security indicator for a full-screen, app-like presentation.
- Serve content that yields a `WARNING` security level
The page is delivered so the tab’s
getSecurityLevel(tab)returnsConnectionSecurityLevel.WARNING(for example an HTTPS page with mixed/insecure sub-resources) rather thanDANGEROUS. - Fall through the too-narrow gate
Because the pre-patch check only force-showed on
DANGEROUS,computeBrowserControlsStateskips the force-show branch and defers toshouldShowBrowserControlsAndCloseButton(tab), which permits the toolbar to stay hidden. - Present a downgraded connection as trustworthy
With no visible security indicator, the user cannot distinguish the
WARNINGconnection from aSECUREone, enabling UI misrepresentation/spoofing of the site’s security state.
Impact Assessment
WARNING-level (e.g. mixed-content) connection, making an insecurely-loaded page appear as trustworthy as a fully secure one — a spoofing / UI-misrepresentation issue in the browser UI on Android. Preconditions are that the attacker controls (and has digital-asset-links-verified) the origin launched as a TWA and can drive the tab into the WARNING security band. The advisory classifies this as Spoofing / UI misrepresentation in CustomTabs; no exploited-in-the-wild status or memory-safety escalation is indicated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/android/java/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/controller/TrustedWebActivityBrowserControlsVisibilityManager.java |
modified |
Files Changed
chrome/android/junit/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/controller/TrustedWebActivityBrowserControlsVisibilityManagerTest.javachrome/android/java/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/controller/TrustedWebActivityBrowserControlsVisibilityManager.java
Audit Directions
- Security-state gates that enumerate a single "bad" valueSearch for controls-visibility, indicator, or interstitial logic that special-cases only
ConnectionSecurityLevel.DANGEROUS(or one enum member) instead of the inverse== SECUREcheck; any non-secure band left out is a suppression gap. - TWA / `CustomTabs` toolbar-hiding pathsAudit every place a first-party origin can hide the URL bar (
shouldShowBrowserControlsAndCloseButton, close-button visibility, minimal-ui/WebAPK modes) to confirm all untrustworthy connection states still force the indicator. - Intent-versus-implementation driftNote that the commit message states the goal as showing controls for everything except
SECURE(includingNONE), yet the diff only addsWARNINGand the addedtestNoneSecurityLevelassertsNONEstaysHIDDEN; re-audit whether plain-HTTP (NONE) TWAs should also force the toolbar.
Patch
From 3bd23769cab8ef0cc0512054a26c467eeeda2c53 Mon Sep 17 00:00:00 2001 From: Peter E Conn <[email protected]> Date: Mon, 13 Jul 2026 08:43:17 -0700 Subject: [PATCH] Fix TWA SecurityLevel checks for toolbar hiding Instead of specifically checking for ConnectionSecurityLevel.DANGEROUS to force-show the browser controls, check if the security level is SECURE. This ensures that the URL bar is shown not only for DANGEROUS but also for WARNING and NONE states (e.g. mixed content, http). This prevents origins from suppressing security indicators with TWAs. TAG=agy CONV=c4062454-4b45-484e-9930-7d4b9c0288d5 Fix: 533018632 Change-Id: Ic5c84ccc6650b7c346898e0603112761473aefb3 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8065230 Reviewed-by: Daniel Murphy <[email protected]> Commit-Queue: Peter Conn <[email protected]> Cr-Commit-Position: refs/heads/main@{#1661143} --- diff --git a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/controller/TrustedWebActivityBrowserControlsVisibilityManager.java b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/controller/TrustedWebActivityBrowserControlsVisibilityManager.java index 4755dab..9701c6a 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/controller/TrustedWebActivityBrowserControlsVisibilityManager.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/controller/TrustedWebActivityBrowserControlsVisibilityManager.java @@ -115,10 +115,13 @@ } private @BrowserControlsState int computeBrowserControlsState(@Nullable Tab tab) { - // Force browser controls to show when the security level is dangerous for consistency with - // TabStateBrowserControlsVisibilityDelegate. - if (tab != null && getSecurityLevel(tab) == ConnectionSecurityLevel.DANGEROUS) { - return BrowserControlsState.SHOWN; + // Force browser controls to show when the security level is dangerous or warning. + if (tab != null) { + int securityLevel = getSecurityLevel(tab); + if (securityLevel == ConnectionSecurityLevel.DANGEROUS + || securityLevel == ConnectionSecurityLevel.WARNING) { + return BrowserControlsState.SHOWN; + } } return shouldShowBrowserControlsAndCloseButton(tab) diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/controller/TrustedWebActivityBrowserControlsVisibilityManagerTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/controller/TrustedWebActivityBrowserControlsVisibilityManagerTest.java index 5b70936..e560e63 100644 --- a/chrome/android/junit/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/controller/TrustedWebActivityBrowserControlsVisibilityManagerTest.java +++ b/chrome/android/junit/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/controller/TrustedWebActivityBrowserControlsVisibilityManagerTest.java @@ -100,6 +100,25 @@ assertFalse(getLastCloseButtonVisibility()); } + /** Browser controls should be shown for pages with mixed content warnings. */ + @Test + public void testWarningSecurityLevel() { + mController = buildController(mock(BrowserServicesIntentDataProvider.class)); + setTabSecurityLevel(ConnectionSecurityLevel.WARNING); + mController.updateIsInAppMode(true); + assertEquals(BrowserControlsState.SHOWN, getLastBrowserControlsState()); + assertFalse(getLastCloseButtonVisibility()); + } + + /** Browser controls should be hidden for HTTP connections and mixed forms. */ + @Test + public void testNoneSecurityLevel() { + mController = buildController(mock(BrowserServicesIntentDataProvider.class)); + setTabSecurityLevel(ConnectionSecurityLevel.NONE); + mController.updateIsInAppMode(true); + assertEquals(BrowserControlsState.HIDDEN, getLastBrowserControlsState()); + } + /** Browser controls should not be shown for WebAPKs with 'minimal-ui' display mode. */ @Test public void testMinimalUiDisplayMode() { @@ -201,13 +220,16 @@ private TrustedWebActivityBrowserControlsVisibilityManager buildController( BrowserServicesIntentDataProvider intentDataProvider) { - return spy( - new TrustedWebActivityBrowserControlsVisibilityManager( - mTabObserverRegistrar, - mTabProvider, - mToolbarCoordinator, - mCloseButtonVisibilityManager, - intentDataProvider)); + TrustedWebActivityBrowserControlsVisibilityManager controller = + spy( + new TrustedWebActivityBrowserControlsVisibilityManager( + mTabObserverRegistrar, + mTabProvider, + mToolbarCoordinator, + mCloseButtonVisibilityManager, + intentDataProvider)); + doReturn(ConnectionSecurityLevel.SECURE).when(controller).getSecurityLevel(any()); + return controller; } /** Returns the current browser controls state. */