Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in WebAppInstalls
DescriptionRace condition in WebAppInstalls
ComponentWebAppInstalls
Bug ClassRace
Tracker523738212
Fix commit689beb9f08c0 (chromium/src) +1831/-182
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

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

Files Changed

  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/DefaultCustomTabIntentHandlingStrategy.java
  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
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);
     }
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