High watch ~ plausible CVSS 4.3 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in WebAppInstalls
DescriptionRace condition in WebAppInstalls
ComponentWebAppInstalls
Sub-componentPWA
Bug ClassRace
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIPlausible
The mapped commit may not contain the whole fix.
Reviewer notes: Diff shown is truncated to Java; the cited C++ unit tests, enqueueNonNavigating comment and committed-URL scope recheck are not visible. Analysis wrongly says advisory gives no severity; it states high (CVSS 4.3). Cross-document delivery impact is inferred.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker523738212
Fix commit689beb9f08c0 (chromium/src) +1831/-182
CWECWE-362 (Race condition)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-06-14 (per vendor advisory)
Fix landed2026-07-21 (public git)
Disclosed2026-08-25
Patch gap35 days — fix public in source before the advisory

Background

Trusted Web Activity (TWA)
An Android Custom Tab mode that runs a verified web app fullscreen, whose launch parameters (target URL, files, package) are delivered to the page’s JS LaunchQueue only after the origin is verified as belonging to the app.
Launch parameter matching
The process of correlating an incoming intent’s launch params with the navigation that actually commits, so params are handed to the correct, verified, committed document.
`CurrentPageVerifier` / `Verifier`
Components that asynchronously verify that a page’s origin is in the web app’s declared scope; verification and page navigation can complete in either order (the race).
`ChromeNavigationUiData` / TWA launch token
Browser-side navigation metadata; the CL adds a twaLaunchToken (sNextLaunchToken) attached to LoadUrlParams so C++ (TwaLaunchQueueTabHelper) can match a launch to a specific NavigationHandle deterministically.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: Diff shown is truncated to Java; the cited C++ unit tests, enqueueNonNavigating comment and committed-URL scope recheck are not visible. Analysis wrongly says advisory gives no severity; it states high (CVSS 4.3). Cross-document delivery impact is inferred.
The pre-patch design split TWA launch-parameter matching between Java and C++: WebAppLaunchHandler extended WebContentsObserver and used a Java mIsPageLoading flag to decide, at verification time, whether to stash launch params or dispatch them, with no deterministic tie between a given verification result and the specific navigation that committed. Because URL verification (mVerifier.verify(...)) and navigation completion run asynchronously, their interleaving could cause launch params to be enqueued against the wrong or unverified document, so a non-navigating delivery relied only on a Java-side getState() check that could be stale by the time C++ actually enqueued. The CL moves the state machine (Pending/Verified/Committed) entirely to C++, keyed by a token generated in Java (++sNextLaunchToken) and carried on the NavigationHandle, matching by URL in DidStartNavigation, re-verifying scope against the last committed URL before enqueuing, and discarding on cross-origin redirect or verification failure (per the unit tests DiscardOnCrossOriginRedirect, DiscardOnVerificationFailed, and SuccessSameOriginVerifyAfterCommit). The removed Java comment explicitly documents the old stash-on-mIsPageLoading race, and the new enqueueNonNavigating path adds a comment that C++ performs a final scope check “against the last committed URL before enqueuing” precisely because another navigation could occur between the Java verification check and delivery.
Key insight
The single mistake was resolving the verification-vs-navigation race in split Java/C++ logic using a coarse Java loading flag with no per-navigation binding, allowing launch params to be delivered to a document that was not the one verified; the fix binds each launch to a specific navigation via a token on the NavigationHandle and performs the final scope check in C++ against the actually committed URL.

Attack Path

  1. Trigger a TWA launch A launch intent for a TWA is handled by WebAppLaunchHandler.handleInitialIntent/handleNewIntent, which begins asynchronous origin verification for the target URL while a navigation is (or will be) started.
  2. Win the race between verification and navigation Because verification and navigation completion are asynchronous and only loosely coordinated by the Java mIsPageLoading flag, an attacker arranges timing (e.g. a redirect or a competing navigation) so that the document present/committed at delivery time differs from the one whose scope was verified. (inferred)
  3. Cross-origin redirect during the launch navigation The navigation started for the verified target URL is redirected to an out-of-scope/cross-origin URL; under the old logic the stashed launch params could still be dispatched once loading completed. (inferred)
  4. Launch params delivered to the wrong document The web app launch parameters (target URL, file handles, write capability) are enqueued to a page that was not verified to be in scope, leaking the launch data / capabilities to an unintended origin. (inferred)

Impact Assessment

An attacker able to influence navigation timing or redirects during a TWA launch could cause launch parameters (including file URIs and write capability) to be delivered to a document other than the verified in-scope page, undermining the origin-verification guarantee in the browser process on Android. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3. The exact cross-origin delivery outcome is reconstructed from the removed race comment and the discard unit tests rather than demonstrated by a shown sink. (inferred) Preconditions are a TWA/Custom Tab launch and the ability to affect the interleaving of verification and navigation (e.g. via a server-controlled redirect).

Changed Functions

FunctionChangeNotes
if
chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/DefaultCustomTabIntentHandlingStrategy.java
modified
if
chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
modified

Files Changed

  • chrome/browser/android/webapps/twa_launch_queue_delegate.cc
  • chrome/browser/android/webapps/twa_launch_queue_tab_helper.h
  • chrome/browser/renderer_host/chrome_navigation_ui_data.h
  • chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/CustomTabActivityContentTestEnvironment.java
  • components/webapps/docs/android_twa_launch_params.md
  • chrome/browser/android/webapps/web_app_launch_handler.cc
  • components/webapps/docs/android_architecture.md
  • components/webapps/README.md
  • chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandlerTest.java
  • chrome/browser/android/webapps/twa_launch_queue_delegate.h
  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
  • chrome/browser/android/webapps/twa_launch_queue_tab_helper.cc
  • chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/CustomTabActivityLaunchHandlerTest.java
  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/DefaultCustomTabIntentHandlingStrategy.java
  • chrome/browser/renderer_host/chrome_navigation_ui_data.cc
  • chrome/browser/android/webapps/twa_launch_queue_tab_helper_unittest.cc
  • chrome/browser/android/webapps/twa_launch_navigation_handle_user_data.h
  • components/webapps/docs/projects/twa-launch-params/design.md
  • chrome/browser/android/BUILD.gn
  • chrome/test/BUILD.gn
  • chrome/android/java/src/org/chromium/chrome/browser/renderer_host/ChromeNavigationUiData.java
  • chrome/browser/android/webapps/twa_launch_navigation_handle_user_data.cc
  • components/webapps/browser/launch_queue/launch_params.h
  • chrome/browser/renderer_host/android/chrome_navigation_ui_data_android.cc

Audit Directions

  • Cross-language state machines around async verification
    Audit flows where security-relevant state (origin verification, scope checks) is tracked in one layer (Java) but consumed in another (C++/browser), for stale-flag races between the check and the effectful delivery.
  • Delivery not bound to a specific navigation
    Look for launch/param/capability dispatch that keys on “current page” or a boolean loading flag rather than a concrete NavigationHandle/token, since redirects or racing navigations can swap the committed document.
  • Final scope re-check before use
    Verify that any post-verification delivery re-checks scope against the actually committed URL (not the URL that was verified), and that cross-origin redirects invalidate a pending launch.
From 689beb9f08c0421f1d0860907315a033bfce5d06 Mon Sep 17 00:00:00 2001
From: Dan Murphy <dmurph@chromium.org>
Date: Tue, 21 Jul 2026 13:39:43 -0700
Subject: [PATCH] [PWA] Refactor TWA launch parameters matching to C++

This CL moves the matching and stashing logic of Trusted Web Activity
(TWA) launch parameters entirely to C++.

Previously, the matching logic was split between Java and C++, leading
to race conditions between URL verification and navigation completion.
By moving the state machine to C++, we can resolve these races
deterministically using NavigationHandle and tracking committed frames.

Key changes:
- WebAppLaunchHandler (Java) is simplified: it no longer observes
  navigations. It notifies C++ of new launches via JNI
  prepareForLaunch, and reports verification results via
  onLaunchVerified.
- TwaLaunchQueueTabHelper (C++) now tracks launch state machine
  (Pending, Verified, Committed) by a token generated in Java.
- It matches navigations by URL in DidStartNavigation and attaches
  launch params to the NavigationHandle.
- If navigation commits, it verifies scope and stashes params if
  verification is still pending, or enqueues if already verified.
- Fixed JUnit tests to align with the new JNI lifecycle and mock
  isolation.
- Added C++ unit tests covering the new matching flow.

Robustness and Edge Cases:
- Added support for speculative loads (hidden tabs) in C++ by
  allowing verified launches to be enqueued immediately if the active
  page already matches the target URL. Added unit tests for this
  scenario.
- Cleaned up active launch tracking defensively by NavigationHandle
  to avoid potential dangling raw pointers.
- Improved error safety by clearing stale pending launches when any
  new primary main frame navigation commits, avoiding memory
  accumulation if verification hangs.

Bug: b:523738212
Test: out/AL/bin/run_chrome_junit_tests -f "*WebAppLaunchHandlerTest*:*CustomTabActivityLaunchHandlerTest*"
Test: twa_launch_queue_tab_helper_unittest.cc

TAG=agy
CONV=792fd2b9-bf06-4232-90c3-4c7b3b3ddceb

Change-Id: I146f6fa8aab2fb3ea56c26fd8d0b1846b43932f7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8048065
Reviewed-by: Dibyajyoti Pal <dibyapal@chromium.org>
Reviewed-by: Thomas Lukaszewicz <tluk@chromium.org>
Reviewed-by: Michael Thiessen <mthiesse@chromium.org>
Commit-Queue: Daniel Murphy <dmurph@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1665756}
---
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/DefaultCustomTabIntentHandlingStrategy.java b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/DefaultCustomTabIntentHandlingStrategy.java
index 35344740..470cfe0 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/DefaultCustomTabIntentHandlingStrategy.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/DefaultCustomTabIntentHandlingStrategy.java
@@ -13,12 +13,14 @@
 
 import org.chromium.base.IntentUtils;
 import org.chromium.build.annotations.NullMarked;
+import org.chromium.build.annotations.Nullable;
 import org.chromium.chrome.browser.IntentHandler;
 import org.chromium.chrome.browser.browserservices.intents.BrowserServicesIntentDataProvider;
 import org.chromium.chrome.browser.browserservices.ui.controller.CurrentPageVerifier;
 import org.chromium.chrome.browser.browserservices.ui.controller.Verifier;
 import org.chromium.chrome.browser.customtabs.CustomTabAuthUrlHeuristics;
 import org.chromium.chrome.browser.customtabs.CustomTabObserver;
+import org.chromium.chrome.browser.renderer_host.ChromeNavigationUiData;
 import org.chromium.chrome.browser.tab.Tab;
 import org.chromium.components.embedder_support.util.UrlUtilities;
 import org.chromium.content_public.browser.LoadUrlParams;
@@ -67,6 +69,7 @@
 
         CustomTabAuthUrlHeuristics.setFirstCctPageLoadForMetrics(tab);
 
+        Long twaLaunchToken = null;
         if (intentDataProvider.isTrustedWebActivity()) {
             WebContents webContents = tab.getWebContents();
             assumeNonNull(webContents);
@@ -76,15 +79,19 @@
                             mCurrentPageVerifier,
                             mNavigationController,
                             webContents,
-                            mActivity);
-            launchHandler.handleInitialIntent(intentDataProvider);
+                            mActivity,
+                            mTabProvider);
+            twaLaunchToken = launchHandler.handleInitialIntent(intentDataProvider);
         }
 
         if (initialTabCreationMode == TabCreationMode.HIDDEN) {
-            handleInitialLoadForHiddenTab(intentDataProvider);
+            handleInitialLoadForHiddenTab(intentDataProvider, twaLaunchToken);
         } else {
             assumeNonNull(intentDataProvider.getUrlToLoad());
             LoadUrlParams params = new LoadUrlParams(intentDataProvider.getUrlToLoad());
+            if (twaLaunchToken != null) {
+                ChromeNavigationUiData.getOrCreate(params).setTwaLaunchToken(twaLaunchToken);
+            }
             mNavigationController.navigate(params, assumeNonNull(intentDataProvider.getIntent()));
         }
 
@@ -93,7 +100,7 @@
 
     // The hidden tab case needs a bit of special treatment.
     private void handleInitialLoadForHiddenTab(
-            BrowserServicesIntentDataProvider intentDataProvider) {
+            BrowserServicesIntentDataProvider intentDataProvider, @Nullable Long twaLaunchToken) {
         Tab tab = mTabProvider.getTab();
         if (tab == null) {
             throw new IllegalStateException("handleInitialIntent called before Tab created");
@@ -114,6 +121,9 @@
         if (useSpeculation) return;
 
         LoadUrlParams params = new LoadUrlParams(url);
+        if (twaLaunchToken != null) {
+            ChromeNavigationUiData.getOrCreate(params).setTwaLaunchToken(twaLaunchToken);
+        }
 
         // The following block is a hack that deals with urls preloaded with
         // the wrong fragment. Does an extra pageload and replaces history.
@@ -153,7 +163,8 @@
                             mCurrentPageVerifier,
                             mNavigationController,
                             webContents,
-                            mActivity);
+                            mActivity,
+                            mTabProvider);
             launchHandler.handleNewIntent(intentDataProvider);
         } else {
             loadUrl(intentDataProvider);
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
index f16fb9b..8ac0bcd 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
@@ -34,6 +34,7 @@
 import org.chromium.base.Log;
 import org.chromium.build.annotations.NullMarked;
 import org.chromium.build.annotations.Nullable;
+import org.chromium.chrome.browser.ShortcutHelper;
 import org.chromium.chrome.browser.browserservices.intents.BrowserServicesIntentDataProvider;
 import org.chromium.chrome.browser.browserservices.intents.SessionHolder;
 import org.chromium.chrome.browser.browserservices.ui.controller.CurrentPageVerifier;
@@ -42,10 +43,10 @@
 import org.chromium.chrome.browser.customtabs.content.WebAppLaunchHandlerHistogram.ClientModeAction;
 import org.chromium.chrome.browser.customtabs.content.WebAppLaunchHandlerHistogram.FailureReasonAction;
 import org.chromium.chrome.browser.customtabs.content.WebAppLaunchHandlerHistogram.FileHandlingAction;
+import org.chromium.chrome.browser.renderer_host.ChromeNavigationUiData;
+import org.chromium.components.embedder_support.util.UrlUtilities;
 import org.chromium.content_public.browser.LoadUrlParams;
-import org.chromium.content_public.browser.NavigationHandle;
 import org.chromium.content_public.browser.WebContents;
-import org.chromium.content_public.browser.WebContentsObserver;
 
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -58,7 +59,7 @@
  */
 @NullMarked
 @JNINamespace("webapps")
-public class WebAppLaunchHandler extends WebContentsObserver {
+public class WebAppLaunchHandler {
     private static final String TAG = "WebAppLaunchHandler";
     private static final @ClientMode int DEFAULT_CLIENT_MODE = NAVIGATE_EXISTING;
     private final WebContents mWebContents;
@@ -66,12 +67,9 @@
     private final Verifier mVerifier;
     private final CurrentPageVerifier mCurrentPageVerifier;
     private final Activity mActivity;
+    private final CustomTabActivityTabProvider mTabProvider;
 
-    // Tracks the WebContents top-level frame loading state to resolve a race condition between URL
-    // verification and navigation completion. LaunchParams are stashed if verification finishes
-    // before the page has loaded. They are dispatched to the JS LaunchQueue once loading is
-    // complete.
-    private boolean mIsPageLoading;
+    private static long sNextLaunchToken;
 
     /**
      * Retrieves the ClientMode enum value from a given AndroidX enum. Defaults to
@@ -105,10 +103,16 @@
             CurrentPageVerifier currentPageVerifier,
             CustomTabActivityNavigationController navigationController,
             WebContents webContents,
-            Activity activity) {
+            Activity activity,
+            CustomTabActivityTabProvider tabProvider) {
 
         return new WebAppLaunchHandler(
-                verifier, currentPageVerifier, navigationController, webContents, activity);
+                verifier,
+                currentPageVerifier,
+                navigationController,
+                webContents,
+                activity,
+                tabProvider);
     }
 
     private WebAppLaunchHandler(
@@ -116,12 +120,14 @@
             CurrentPageVerifier currentPageVerifier,
             CustomTabActivityNavigationController navigationController,
             WebContents webContents,
-            Activity activity) {
+            Activity activity,
+            CustomTabActivityTabProvider tabProvider) {
         mWebContents = webContents;
         mNavigationController = navigationController;
         mVerifier = verifier;
         mCurrentPageVerifier = currentPageVerifier;
         mActivity = activity;
+        mTabProvider = tabProvider;
     }
 
     private boolean isValidFileHandlingData(FileHandlingData fileHandlingData) {
@@ -202,40 +208,53 @@
     }
 
     /**
-     * Handles an intent that triggers a TWA creation. It doesn't trigger a url loading because in
-     * the case of initial intent it has been triggered earlier. Passes launch params to a launch
-     * queue. Always uses startNewNavigation = true because opening new custom tab is always url
-     * loading. In case if the target url provided in the intentDataProvider is out of scope of the
-     * TWA a launch queue will not be notified.
+     * Handles an intent that triggers a TWA creation (cold start). It doesn't trigger a url loading
+     * because the initial navigation is initiated elsewhere in CCT startup. Generates a token to
+     * correlate this launch with C++ navigation events.
      *
-     * @param intentDataProvider Provides incoming intent with Custom Tabs specific customization
-     *     data.
+     * <p>Detects if this launch reused a speculative navigation (started via mayLaunchUrl) and
+     * passes this status to C++.
+     *
+     * @param intentDataProvider Provides incoming intent data.
+     * @return The launch token if a navigation correlation is needed, or null if the launch was
+     *     invalid or did not require navigation.
      */
-    public void handleInitialIntent(BrowserServicesIntentDataProvider intentDataProvider) {
+    public @Nullable Long handleInitialIntent(
+            BrowserServicesIntentDataProvider intentDataProvider) {
         WebAppLaunchHandlerHistogram.logClientMode(ClientModeAction.INITIAL_INTENT);
 
         FileHandlingData filteredData = filterFileHandlingData(intentDataProvider);
+        String urlToLoad = assertNonNull(intentDataProvider.getUrlToLoad());
         WebAppLaunchParams launchParams =
                 getLaunchParams(
                         /* newNavigationStarted= */ true,
-                        assertNonNull(intentDataProvider.getUrlToLoad()),
+                        urlToLoad,
                         assertNonNull(intentDataProvider.getClientPackageName()),
                         filteredData,
                         intentDataProvider.getSession());
 
-        maybeNotifyLaunchQueue(launchParams);
+        boolean isHidden = mTabProvider.getInitialTabCreationMode() == TabCreationMode.HIDDEN;
+        boolean hasSpeculativeNavigation = false;
+        if (isHidden) {
+            String speculatedUrl = mTabProvider.getSpeculatedUrl();
+            hasSpeculativeNavigation = TextUtils.equals(speculatedUrl, urlToLoad);
+        }
+
+        return maybeNotifyLaunchQueue(launchParams, hasSpeculativeNavigation);
     }
 
     /**
-     * Handles an intent that comes after a TWA creation. It triggers a url loading in case of
-     * navigate-existing mode. It opens a new TWA in a new task in a case of navigate-new mode. It
-     * Passes launch params to a launch queue. In case if the target url provided in the
-     * intentDataProvider is out of scope of the TWA a launch queue will not be notified. If
-     * currently open page is out of scope of the TWA and it's not going to be reloaded a launch
-     * queue will not be notified as well.
+     * Handles an intent that comes after TWA creation (warm start, reusing existing tab).
      *
-     * @param intentDataProvider Provides incoming intent with Custom Tabs specific customization
-     *     data.
+     * <p>Triggers a navigation in the existing tab if the client mode is NAVIGATE_EXISTING, or if
+     * it is FOCUS_EXISTING but the current page is out of scope (fallback to navigate-existing
+     * behavior). Otherwise, delivers the parameters directly to the current page without
+     * navigating.
+     *
+     * <p>Detects if there is a speculative navigation in progress in the existing tab (via
+     * mayLaunchUrl) and passes this status to C++.
+     *
+     * @param intentDataProvider Provides incoming intent data.
      */
     public void handleNewIntent(BrowserServicesIntentDataProvider intentDataProvider) {
         @ClientMode int clientModeFromIntent = intentDataProvider.getLaunchHandlerClientMode();
@@ -249,19 +268,27 @@
         FileHandlingData filteredData = filterFileHandlingData(intentDataProvider);
 
         CurrentPageVerifier.VerificationState state = mCurrentPageVerifier.getState();
+        // If the current page is not fully verified (including if verification is still PENDING),
+        // we fallback to starting a new navigation. We choose not to wait for PENDING verification
+        // here to keep intent delivery simple and avoid stashing intents. Since the page is
+        // already loaded, verification should usually be complete and cached; if it is still
+        // pending, navigating is the safer fallback.
         if (clientMode == NAVIGATE_NEW
                 || state == null
                 || state.status != CurrentPageVerifier.VerificationStatus.SUCCESS) {
             launchNewIntent(urlToLoad, packageName, filteredData);
         } else {
-            boolean startNavigation =
-                    clientMode == NAVIGATE_EXISTING && !TextUtils.isEmpty(urlToLoad);
+            String currentUrl = mWebContents.getLastCommittedUrl().getSpec();
+            String scopeUrl = getScopeUrl(urlToLoad);
+            boolean isInScope = UrlUtilities.isUrlWithinScope(currentUrl, scopeUrl);
 
-            if (startNavigation) {
-                LoadUrlParams params = new LoadUrlParams(urlToLoad);
-                mNavigationController.navigate(
-                        params, assumeNonNull(intentDataProvider.getIntent()));
-            }
+            // Note: This models the default behavior for Android, which is to default to
+            // navigate-existing rather than navigate-new when launching an app with a url.
+            // This behavior might change in the future.
+            boolean startNavigation =
+                    (clientMode == NAVIGATE_EXISTING
+                                    || (clientMode == FOCUS_EXISTING && !isInScope))
+                            && !TextUtils.isEmpty(urlToLoad);
 
             assert packageName != null;
             WebAppLaunchParams launchParams =
@@ -272,7 +299,18 @@
                             filteredData,
                             intentDataProvider.getSession());
 
-            maybeNotifyLaunchQueue(launchParams);
+            String speculatedUrl = mTabProvider.getSpeculatedUrl();
+            boolean hasSpeculativeNavigation = TextUtils.equals(speculatedUrl, urlToLoad);
+            Long token = maybeNotifyLaunchQueue(launchParams, hasSpeculativeNavigation);
+
+            if (startNavigation) {
+                LoadUrlParams params = new LoadUrlParams(urlToLoad);
+                if (token != null) {
+                    ChromeNavigationUiData.getOrCreate(params).setTwaLaunchToken(token);
+                }
+                mNavigationController.navigate(
+                        params, assumeNonNull(intentDataProvider.getIntent()));
+            }
         }
     }
 
@@ -332,42 +370,54 @@
         }
     }
 
-    private void maybeNotifyLaunchQueue(WebAppLaunchParams launchParams) {
+    private @Nullable Long maybeNotifyLaunchQueue(
+            WebAppLaunchParams launchParams, boolean hasSpeculativeNavigation) {
+        String scopeUrl = getScopeUrl(launchParams.targetUrl);
 
         if (!launchParams.newNavigationStarted) {
-            // Check if the URL of the current page is in the web app scope.
-            // Launch params should not be sent to a not verified origin.
             CurrentPageVerifier.VerificationState state = mCurrentPageVerifier.getState();
             if (state == null || state.status != CurrentPageVerifier.VerificationStatus.SUCCESS) {
                 WebAppLaunchHandlerHistogram.logFailureReason(
                         FailureReasonAction.CURRENT_PAGE_VERIFICATION_FAILED);
                 Log.w(TAG, "Current page verification has been failed.");
-                return;
+                return null;
             }
 
+            // We cannot guarantee that another navigation won't occur between this
+            // Java-side verification check and the actual delivery in C++. As a
+            // safeguard, C++ will perform a final scope check against the last
+            // committed URL before enqueuing.
             WebAppLaunchHandlerJni.get()
-                    .notifyLaunchQueue(
+                    .enqueueNonNavigating(
                             mWebContents,
-                            false,
                             launchParams.targetUrl,
                             launchParams.packageName,
                             launchParams.fileUris,
-                            launchParams.canWrite);
-            return;
+                            launchParams.canWrite,
+                            scopeUrl);
+            return null;
         }
 
-        observe(mWebContents);
+        long token = ++sNextLaunchToken;
+        WebAppLaunchHandlerJni.get()
+                .prepareForLaunch(
+                        mWebContents,
+                        token,
+                        launchParams.targetUrl,
+                        launchParams.packageName,
+                        launchParams.fileUris,
+                        launchParams.canWrite,
+                        scopeUrl,
+                        hasSpeculativeNavigation);
+
         mVerifier
                 .verify(launchParams.targetUrl)
                 .then(
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/android/webapps/twa_launch_queue_tab_helper_unittest.cc b/chrome/browser/android/webapps/twa_launch_queue_tab_helper_unittest.cc
new file mode 100644
index 0000000..adf3234
--- /dev/null
+++ b/chrome/browser/android/webapps/twa_launch_queue_tab_helper_unittest.cc
@@ -0,0 +1,621 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "chrome/browser/android/webapps/twa_launch_queue_tab_helper.h"
+
+#include <memory>
+#include <utility>
+#include <vector>
+
+#include "base/memory/raw_ptr.h"
+#include "chrome/browser/android/webapps/twa_launch_navigation_handle_user_data.h"
+#include "chrome/browser/chrome_content_browser_client.h"
+#include "chrome/browser/renderer_host/chrome_navigation_ui_data.h"
+#include "chrome/test/base/chrome_render_view_host_test_harness.h"
+#include "components/webapps/browser/launch_queue/launch_params.h"
+#include "content/public/browser/navigation_handle.h"
+#include "content/public/browser/web_contents.h"
+#include "content/public/common/content_client.h"
+#include "content/public/test/navigation_simulator.h"
+#include "mojo/public/cpp/bindings/associated_receiver.h"
+#include "net/base/net_errors.h"
+#include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/common/associated_interfaces/associated_interface_provider.h"
+#include "third_party/blink/public/mojom/web_launch/web_launch.mojom.h"
+
+namespace webapps {
+
+class FakeWebLaunchService : public blink::mojom::WebLaunchService {
+ public:
+  FakeWebLaunchService() = default;
+  ~FakeWebLaunchService() override = default;
+
+  void Bind(mojo::ScopedInterfaceEndpointHandle handle) {
+    receiver_.reset();
+    receiver_.Bind(
+        mojo::PendingAssociatedReceiver<blink::mojom::WebLaunchService>(
+            std::move(handle)));
+  }
+
+  void EnqueueLaunchParams(
+      const GURL& launch_url,
+      base::TimeTicks time_navigation_started_in_browser,
+      bool navigation_started,
+      std::vector<blink::mojom::FileSystemAccessEntryPtr> files) override {
+    launched_url_ = launch_url;
+    enqueue_called_ = true;
+  }
+
+  bool enqueue_called() const { return enqueue_called_; }
+  const GURL& launched_url() const { return launched_url_; }
+
+  void Reset() {
+    enqueue_called_ = false;
+    launched_url_ = GURL();
+  }
+
+ private:
+  mojo::AssociatedReceiver<blink::mojom::WebLaunchService> receiver_{this};
+  bool enqueue_called_ = false;
+  GURL launched_url_;
+};
+
+class TestChromeContentBrowserClient : public ChromeContentBrowserClient {
+ public:
+  std::unique_ptr<content::NavigationUIData> GetNavigationUIData(
+      content::NavigationHandle* navigation_handle) override {
+    auto ui_data = std::make_unique<ChromeNavigationUIData>(navigation_handle);
+    if (next_twa_launch_token_) {
+      ui_data->set_twa_launch_token(next_twa_launch_token_);
+      next_twa_launch_token_ = std::nullopt;
+    }
+    return ui_data;
+  }
+
+  void set_next_twa_launch_token(std::optional<int64_t> token) {
+    next_twa_launch_token_ = token;
+  }
+
+ private:
+  std::optional<int64_t> next_twa_launch_token_;
+};
+
+class TwaLaunchQueueTabHelperTest : public ChromeRenderViewHostTestHarness {
+ public:
+  void SetUp() override {
+    ChromeRenderViewHostTestHarness::SetUp();
+    test_browser_client_ = std::make_unique<TestChromeContentBrowserClient>();
+    old_browser_client_ =
+        content::SetBrowserClientForTesting(test_browser_client_.get());
+
+    TwaLaunchQueueTabHelper::CreateForWebContents(web_contents());
+    tab_helper_ = TwaLaunchQueueTabHelper::FromWebContents(web_contents());
+    InitTestApi(web_contents()->GetPrimaryMainFrame());
+  }
+
+  void TearDown() override {
+    content::SetBrowserClientForTesting(old_browser_client_);
+    ChromeRenderViewHostTestHarness::TearDown();
+  }
+
+  void InitTestApi(content::RenderFrameHost* rfh) {
+    rfh->GetRemoteAssociatedInterfaces()->OverrideBinderForTesting(
+        blink::mojom::WebLaunchService::Name_,
+        base::BindRepeating(&FakeWebLaunchService::Bind,
+                            base::Unretained(&fake_launch_service_)));
+  }
+
+ protected:
+  LaunchParams CreateLaunchParams(const GURL& target_url) {
+    LaunchParams params;
+    params.set_target_url(target_url);
+    params.set_started_new_navigation(true);
+    params.set_app_id("test_app_id");
+    params.set_scope(target_url);
+    return params;
+  }
+
+  std::unique_ptr<TestChromeContentBrowserClient> test_browser_client_;
+  raw_ptr<content::ContentBrowserClient> old_browser_client_;
+  raw_ptr<TwaLaunchQueueTabHelper> tab_helper_;
+  FakeWebLaunchService fake_launch_service_;
+};
+
+TEST_F(TwaLaunchQueueTabHelperTest, SuccessSameOrigin) {
+  GURL target_url("https://example.com/twa");
+  LaunchParams params = CreateLaunchParams(target_url);
+  int64_t token = 123;
+
+  tab_helper_->PrepareForLaunch(token, params,
+                                /*has_speculative_navigation=*/false);
+  test_browser_client_->set_next_twa_launch_token(token);
+
+  auto simulator = content::NavigationSimulator::CreateBrowserInitiated(
+      target_url, web_contents());
+  simulator->Start();
+
+  tab_helper_->OnLaunchVerified(token, /*success=*/true);
+  simulator->Commit();
+  tab_helper_->FlushLaunchQueueForTesting();
+
+  EXPECT_TRUE(fake_launch_service_.enqueue_called());
+  EXPECT_EQ(fake_launch_service_.launched_url(), target_url);
+}
+
+TEST_F(TwaLaunchQueueTabHelperTest, SuccessSameOriginVerifyAfterCommit) {
+  GURL target_url("https://example.com/twa");
+  LaunchParams params = CreateLaunchParams(target_url);
+  int64_t token = 123;
+
+  tab_helper_->PrepareForLaunch(token, params,
+                                /*has_speculative_navigation=*/false);
+  test_browser_client_->set_next_twa_launch_token(token);
+
+  auto simulator = content::NavigationSimulator::CreateBrowserInitiated(
+      target_url, web_contents());
+  simulator->Start();
+  simulator->Commit();
+  tab_helper_->FlushLaunchQueueForTesting();
+
+  // Verification is still pending, so it shouldn't be enqueued yet.
+  EXPECT_FALSE(fake_launch_service_.enqueue_called());
+
+  // Now verify.
+  tab_helper_->OnLaunchVerified(token, /*success=*/true);
+  tab_helper_->FlushLaunchQueueForTesting();
+
+  EXPECT_TRUE(fake_launch_service_.enqueue_called());
+  EXPECT_EQ(fake_launch_service_.launched_url(), target_url);
+}
+
+TEST_F(TwaLaunchQueueTabHelperTest, DiscardOnCrossOriginRedirect) {
+  GURL target_url("https://example.com/twa");
+  GURL redirect_url("https://malicious.com/hijack");
+  LaunchParams params = CreateLaunchParams(target_url);
+  int64_t token = 123;
+
+  tab_helper_->PrepareForLaunch(token, params,
+                                /*has_speculative_navigation=*/false);
+  test_browser_client_->set_next_twa_launch_token(token);
+
+  auto simulator = content::NavigationSimulator::CreateBrowserInitiated(
+      target_url, web_contents());
+  simulator->Start();
+  tab_helper_->OnLaunchVerified(token, /*success=*/true);
+
+  simulator->Redirect(redirect_url);
+  simulator->Commit();
+  tab_helper_->FlushLaunchQueueForTesting();
+
+  EXPECT_FALSE(fake_launch_service_.enqueue_called());
+}
+
+TEST_F(TwaLaunchQueueTabHelperTest, DiscardOnVerificationFailed) {
+  GURL target_url("https://example.com/twa");
+  LaunchParams params = CreateLaunchParams(target_url);
+  int64_t token = 123;
+
+  tab_helper_->PrepareForLaunch(token, params,
+                                /*has_speculative_navigation=*/false);
+  test_browser_client_->set_next_twa_launch_token(token);
+
+  auto simulator = content::NavigationSimulator::CreateBrowserInitiated(
+      target_url, web_contents());
+  simulator->Start();
+
+  tab_helper_->OnLaunchVerified(token, /*success=*/false);
+  simulator->Commit();
+  tab_helper_->FlushLaunchQueueForTesting();
+
+  EXPECT_FALSE(fake_launch_service_.enqueue_called());
+}
+
+TEST_F(TwaLaunchQueueTabHelperTest, EnqueueNonNavigatingSameOrigin) {
+  GURL target_url("https://example.com/twa");
+  LaunchParams params = CreateLaunchParams(target_url);
+
+  content::NavigationSimulator::NavigateAndCommitFromBrowser(web_contents(),
+                                                             target_url);
+
+  tab_helper_->EnqueueNonNavigating(params);
+  tab_helper_->FlushLaunchQueueForTesting();
+
+  EXPECT_TRUE(fake_launch_service_.enqueue_called());
+  EXPECT_EQ(fake_launch_service_.launched_url(), target_url);
+}
+
+TEST_F(TwaLaunchQueueTabHelperTest, EnqueueNonNavigatingCrossOrigin) {
+  GURL target_url("https://example.com/twa");
+  GURL current_url("https://malicious.com/hijack");
+  LaunchParams params = CreateLaunchParams(target_url);
+
+  content::NavigationSimulator::NavigateAndCommitFromBrowser(web_contents(),
+                                                             current_url);
+  InitTestApi(web_contents()->GetPrimaryMainFrame());
+
+  tab_helper_->EnqueueNonNavigating(params);
+  tab_helper_->FlushLaunchQueueForTesting();
+
+  EXPECT_FALSE(fake_launch_service_.enqueue_called());
+}
+
+TEST_F(TwaLaunchQueueTabHelperTest, SuccessSameOriginInScope) {
+  GURL target_url("https://example.com/twa/launch");
+  GURL scope_url("https://example.com/twa/");
+  LaunchParams params = CreateLaunchParams(target_url);
+  params.set_scope(scope_url);
+  int64_t token = 123;
+
+  tab_helper_->PrepareForLaunch(token, params,
+                                /*has_speculative_navigation=*/false);
+  test_browser_client_->set_next_twa_launch_token(token);
+
+  auto simulator = content::NavigationSimulator::CreateBrowserInitiated(
+      target_url, web_contents());
+  simulator->Start();
+  tab_helper_->OnLaunchVerified(token, /*success=*/true);
+
+  simulator->Commit();
+  tab_helper_->FlushLaunchQueueForTesting();
+
+  EXPECT_TRUE(fake_launch_service_.enqueue_called());
+}
+
+TEST_F(TwaLaunchQueueTabHelperTest, DiscardSameOriginOutOfScope) {
+  GURL target_url("https://example.com/twa/launch");
+  GURL scope_url("https://example.com/twa/");
+  GURL navigated_url("https://example.com/other");
+  LaunchParams params = CreateLaunchParams(target_url);
+  params.set_scope(scope_url);
+  int64_t token = 123;
+
+  tab_helper_->PrepareForLaunch(token, params,
+                                /*has_speculative_navigation=*/false);
+  test_browser_client_->set_next_twa_launch_token(token);
+
+  auto simulator = content::NavigationSimulator::CreateBrowserInitiated(
+      target_url, web_contents());
+  simulator->Start();
+  tab_helper_->OnLaunchVerified(token, /*success=*/true);
+
+  simulator->Redirect(navigated_url);
+  simulator->Commit();
+  tab_helper_->FlushLaunchQueueForTesting();
+
+  EXPECT_FALSE(fake_launch_service_.enqueue_called());
+}
+
+TEST_F(TwaLaunchQueueTabHelperTest, DiscardIfNavigatedAwayBeforeVerification) {
+  GURL target_url("https://example.com/twa");
+  GURL other_url("https://example.com/other");
+  LaunchParams params = CreateLaunchParams(target_url);
+  int64_t token = 123;
... (truncated)
Loading diff…

Original Bug Report

reported by rj...@google.com

Cross-origin file handle leak in TWA via stale pending launch parameters

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 timing race during Trusted Web Activity (TWA) intent handling causes file launch parameters to be populated asynchronously after the initial navigation begins. A malicious origin can hijack these stale, dangling parameters on a subsequent navigation, bypassing missing scope checks to receive an auto-granted read/write FileSystemFileHandle.

Affected files:

  • chrome/browser/android/webapps/twa_launch_queue_tab_helper.cc
  • chrome/browser/android/webapps/twa_launch_queue_delegate.cc
  • components/webapps/browser/launch_queue/launch_queue.cc
  • chrome/browser/android/webapps/web_app_launch_handler.cc
  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/DefaultCustomTabIntentHandlingStrategy.java

Estimated timestamp from git blame: Unknown (Google3 checkout)

Summary

A potential vulnerability in the Trusted Web Activity (TWA) file handling logic allows a malicious website to obtain FileSystemFileHandle objects (with auto-granted read/write access) that were intended for a different, verified TWA origin.

This occurs due to a combination of a timing race where launch parameters are populated asynchronously after navigation starts, a failure to verify the URL of the navigation handle when attaching stale parameters, and missing scope enforcement in production builds.

Technical Details

1. The Timing Race (Java)

When a TWA is launched with a file intent, DefaultCustomTabIntentHandlingStrategy.handleInitialIntent creates a WebAppLaunchHandler and initiates Digital Asset Link (DAL) verification asynchronously via mVerifier.verify(launchParams.targetUrl).then(...) (in WebAppLaunchHandler.maybeNotifyLaunchQueue).

Because Chromium’s org.chromium.base.Promise.then() always posts callbacks to the message loop (even if the promise is already fulfilled), the JNI callback notifyLaunchQueue is queued. Meanwhile, handleInitialIntent synchronously starts the initial navigation to the TWA origin (mNavigationController.navigate(...)).

By the time JNI_WebAppLaunchHandler_NotifyLaunchQueue is called and populates pending_launch_params_ in C++, the intended navigation’s DidStartNavigation event has already fired, missing the parameters. The parameters are left dangling.

2. Blind Parameter Attachment (C++)

In chrome/browser/android/webapps/twa_launch_queue_tab_helper.cc, DidStartNavigation blindly attaches any existing pending_launch_params_ to the next primary main frame navigation without verifying the URL:

void TwaLaunchQueueTabHelper::DidStartNavigation(content::NavigationHandle* handle) {
  if (handle->IsInPrimaryMainFrame() && pending_launch_params_) {
    TwaLaunchNavigationHandleUserData::CreateForNavigationHandle(
        *handle, std::move(*pending_launch_params_));
    pending_launch_params_.reset();
  }
}

If the TWA page loads and then navigates to an attacker-controlled origin, DidStartNavigation for the attacker origin will adopt these stale parameters.

3. Missing Scope Enforcement and Auto-Granted Permissions

When the attacker navigation commits, LaunchQueue::Enqueue receives the parameters. It attempts to check scope: DCHECK(delegate_->IsInScope(launch_params, launch_params.target_url())) However, DCHECKs are stripped in release builds. Furthermore, TwaLaunchQueueDelegate::IsInScope is a stub that currently returns true.

When LaunchQueue::SendLaunchParams runs, it uses EntriesBuilder to construct the FileSystemAccessEntry. Crucially, EntriesBuilder builds its BindingContext using launch_params.target_url() (the verified TWA origin), not the attacker’s current_url. It calls CreateFileEntryFromPath with UserAction::kSave, which bypasses user prompts and unconditionally grants read/write access (via ChromeFileSystemAccessPermissionContext::GetWritePermissionGrant).

The Mojo WebLaunchService then delivers this fully authorized handle to the attacker’s renderer.

Potential Attack Scenario

Note: These are suggested steps; our tooling has not run a live exploit.

  1. An attacker registers a malicious application that sets up a TWA for their own verified origin (https://twa.example).
  2. The attacker application sends an intent to launch the TWA, attaching a shared file URI (e.g., content://media/external/images/1).
  3. The TWA launches. The initial navigation to https://twa.example starts, missing the async-populated launch parameters.
  4. The TWA page automatically navigates the main frame to a second attacker-controlled origin (https://malicious.example).
  5. DidStartNavigation attaches the stale launch parameters to the https://malicious.example navigation.
  6. The navigation commits, and the attacker’s site receives a FileSystemFileHandle with full read/write access to the user’s media file via window.launchQueue.setConsumer.

Suggested Fix

  1. Verify URL: In TwaLaunchQueueTabHelper::DidStartNavigation, verify that handle->GetURL() matches pending_launch_params_->target_url() before attaching the parameters.
  2. Enforce Scope: Change the DCHECK in LaunchQueue::Enqueue and LaunchQueue::SendLaunchParams to CHECK or handle it gracefully, and properly implement TwaLaunchQueueDelegate::IsInScope.
  3. Address Timing Race: Consider tracking the intended Navigation ID when parameters are queued from Java to avoid relying entirely on the next chronological navigation event.

Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb


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