CVE-2026-79117
Overview
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
LaunchQueueonly 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 toLoadUrlParamsso C++ (TwaLaunchQueueTabHelper) can match a launch to a specificNavigationHandledeterministically.
Root Cause Analysis
Verify before building a PoC
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.
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.NavigationHandle and performs the final scope check in C++ against the actually committed URL.Attack Path
- 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. - Win the race between verification and navigation
Because verification and navigation completion are asynchronous and only loosely coordinated by the Java
mIsPageLoadingflag, 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) - 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)
- 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/android/java/src/org/chromium/chrome/browser/customtabs/content/DefaultCustomTabIntentHandlingStrategy.java |
modified | |
ifchrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java |
modified |
Files Changed
chrome/browser/android/webapps/twa_launch_queue_delegate.ccchrome/browser/android/webapps/twa_launch_queue_tab_helper.hchrome/browser/renderer_host/chrome_navigation_ui_data.hchrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/CustomTabActivityContentTestEnvironment.javacomponents/webapps/docs/android_twa_launch_params.mdchrome/browser/android/webapps/web_app_launch_handler.cccomponents/webapps/docs/android_architecture.mdcomponents/webapps/README.mdchrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandlerTest.javachrome/browser/android/webapps/twa_launch_queue_delegate.hchrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.javachrome/browser/android/webapps/twa_launch_queue_tab_helper.ccchrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/CustomTabActivityLaunchHandlerTest.javachrome/android/java/src/org/chromium/chrome/browser/customtabs/content/DefaultCustomTabIntentHandlingStrategy.javachrome/browser/renderer_host/chrome_navigation_ui_data.ccchrome/browser/android/webapps/twa_launch_queue_tab_helper_unittest.ccchrome/browser/android/webapps/twa_launch_navigation_handle_user_data.hcomponents/webapps/docs/projects/twa-launch-params/design.mdchrome/browser/android/BUILD.gnchrome/test/BUILD.gnchrome/android/java/src/org/chromium/chrome/browser/renderer_host/ChromeNavigationUiData.javachrome/browser/android/webapps/twa_launch_navigation_handle_user_data.cccomponents/webapps/browser/launch_queue/launch_params.hchrome/browser/renderer_host/android/chrome_navigation_ui_data_android.cc
Audit Directions
- Cross-language state machines around async verificationAudit 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 navigationLook 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 useVerify 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.
Patch
From 689beb9f08c0421f1d0860907315a033bfce5d06 Mon Sep 17 00:00:00 2001 From: Dan Murphy <[email protected]> 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 <[email protected]> Reviewed-by: Thomas Lukaszewicz <[email protected]> Reviewed-by: Michael Thiessen <[email protected]> Commit-Queue: Daniel Murphy <[email protected]> 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)
Regression Test / PoC
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)
Original Bug Report
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.ccchrome/browser/android/webapps/twa_launch_queue_delegate.cccomponents/webapps/browser/launch_queue/launch_queue.ccchrome/browser/android/webapps/web_app_launch_handler.ccchrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.javachrome/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.
- An attacker registers a malicious application that sets up a TWA for their own verified origin (
https://twa.example). - The attacker application sends an intent to launch the TWA, attaching a shared file URI (e.g.,
content://media/external/images/1). - The TWA launches. The initial navigation to
https://twa.examplestarts, missing the async-populated launch parameters. - The TWA page automatically navigates the main frame to a second attacker-controlled origin (
https://malicious.example). DidStartNavigationattaches the stale launch parameters to thehttps://malicious.examplenavigation.- The navigation commits, and the attacker’s site receives a
FileSystemFileHandlewith full read/write access to the user’s media file viawindow.launchQueue.setConsumer.
Suggested Fix
- Verify URL: In
TwaLaunchQueueTabHelper::DidStartNavigation, verify thathandle->GetURL()matchespending_launch_params_->target_url()before attaching the parameters. - Enforce Scope: Change the
DCHECKinLaunchQueue::EnqueueandLaunchQueue::SendLaunchParamstoCHECKor handle it gracefully, and properly implementTwaLaunchQueueDelegate::IsInScope. - 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.