CVE-2026-13881
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/webapps/browser/launch_queue/launch_queue.cc |
modified | |
MockLaunchQueueDelegatecomponents/webapps/browser/launch_queue/launch_queue_unittest.cc |
modified | |
FakeWebLaunchServicecomponents/webapps/browser/launch_queue/launch_queue_unittest.cc |
modified | |
LaunchQueueTestcomponents/webapps/browser/launch_queue/launch_queue_unittest.cc |
modified | |
TEST_Fcomponents/webapps/browser/launch_queue/launch_queue_unittest.cc |
modified |
Files Changed
components/webapps/browser/BUILD.gncomponents/webapps/browser/launch_queue/launch_queue.cccomponents/webapps/browser/launch_queue/launch_queue_unittest.cc
Patch
From 2375aeac67d8bf1444957a838791fce7c0e33a35 Mon Sep 17 00:00:00 2001 From: Dan Murphy <[email protected]> Date: Thu, 07 May 2026 14:03:17 -0700 Subject: [PATCH] Fix LaunchQueue vulnerability by checking HasCommitted LaunchQueue::DidFinishNavigation failed to check if the navigation committed successfully before delivering File System Access handles. This could allow an attacker page to abort a launch navigation and steal the handles. This CL adds a check for HasCommitted() and IsErrorPage() in DidFinishNavigation. If the navigation failed or resulted in an error page, the launch queue is reset. Also adds comprehensive unit tests in launch_queue_unittest.cc. Fixed: 499100491 Change-Id: I5373d9c259f314049183e9a528f454033471ea1d Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7825100 Commit-Queue: Daniel Murphy <[email protected]> Reviewed-by: Nate Chapin <[email protected]> Auto-Submit: Daniel Murphy <[email protected]> Cr-Commit-Position: refs/heads/main@{#1627220} --- diff --git a/components/webapps/browser/BUILD.gn b/components/webapps/browser/BUILD.gn index 124364b7eb..29c3803 100644 --- a/components/webapps/browser/BUILD.gn +++ b/components/webapps/browser/BUILD.gn @@ -221,6 +221,7 @@ "install_result_code_unittest.cc", "installable/installable_evaluator_unittest.cc", "installable/installable_task_queue_unittest.cc", + "launch_queue/launch_queue_unittest.cc", "pwa_install_path_tracker_unittest.cc", "web_app_url_config_unittest.cc", "web_contents/web_app_url_loader_unittest.cc", diff --git a/components/webapps/browser/launch_queue/launch_queue.cc b/components/webapps/browser/launch_queue/launch_queue.cc index b81de46e..79c8646 100644 --- a/components/webapps/browser/launch_queue/launch_queue.cc +++ b/components/webapps/browser/launch_queue/launch_queue.cc @@ -136,7 +136,8 @@ } if (pending_navigation_) { - if (!delegate_->IsInScope(queue_.front(), handle->GetURL())) { + if (!handle->HasCommitted() || handle->IsErrorPage() || + !delegate_->IsInScope(queue_.front(), handle->GetURL())) { Reset(); return; } diff --git a/components/webapps/browser/launch_queue/launch_queue_unittest.cc b/components/webapps/browser/launch_queue/launch_queue_unittest.cc new file mode 100644 index 0000000..a0cbb77 --- /dev/null +++ b/components/webapps/browser/launch_queue/launch_queue_unittest.cc @@ -0,0 +1,206 @@ +// 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 "components/webapps/browser/launch_queue/launch_queue.h" + +#include <memory> +#include <utility> +#include <vector> + +#include "base/memory/raw_ptr.h" +#include "base/time/time.h" +#include "components/webapps/browser/launch_queue/launch_params.h" +#include "components/webapps/browser/launch_queue/launch_queue_delegate.h" +#include "content/public/browser/file_system_access_permission_context.h" +#include "content/public/browser/web_contents.h" +#include "content/public/test/navigation_simulator.h" +#include "content/public/test/test_renderer_host.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/file_system_access/file_system_access_directory_handle.mojom.h" +#include "third_party/blink/public/mojom/web_launch/web_launch.mojom.h" + +namespace webapps { + +class MockLaunchQueueDelegate : public LaunchQueueDelegate { + public: + MOCK_METHOD(bool, + IsInScope, + (const LaunchParams& launch_params, const GURL& current_url), + (const, override)); + MOCK_METHOD(content::PathInfo, + GetPathInfo, + (const base::FilePath& entry_path), + (const, override)); + MOCK_METHOD(bool, + IsValidLaunchParams, + (const LaunchParams& params), + (const, override)); +}; + +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))); + } + + // blink::mojom::WebLaunchService: + 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 LaunchQueueTest : public content::RenderViewHostTestHarness { + public: + void SetUp() override { + content::RenderViewHostTestHarness::SetUp(); + auto delegate = + std::make_unique<testing::NiceMock<MockLaunchQueueDelegate>>(); + delegate_ = delegate.get(); + + ON_CALL(*delegate_, IsValidLaunchParams) + .WillByDefault(testing::Return(true)); + ON_CALL(*delegate_, IsInScope) + .WillByDefault( + [](const LaunchParams& params, const GURL& url) { return true; }); + + launch_queue_ = + std::make_unique<LaunchQueue>(web_contents(), std::move(delegate)); + + InitTestApi(web_contents()->GetPrimaryMainFrame()); + } + + void TearDown() override { + delegate_ = nullptr; + launch_queue_.reset(); + content::RenderViewHostTestHarness::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, + bool started_new_navigation = true) { + LaunchParams params; + params.target_url = target_url; + params.started_new_navigation = started_new_navigation; + params.app_id = "test_app_id"; + return params; + } + + std::unique_ptr<LaunchQueue> launch_queue_; + raw_ptr<MockLaunchQueueDelegate> delegate_; + FakeWebLaunchService fake_launch_service_; +}; + +TEST_F(LaunchQueueTest, EnqueueAndCommit) { + GURL launch_url("https://example.com/launch"); + LaunchParams params = CreateLaunchParams(launch_url); + + launch_queue_->Enqueue(std::move(params)); + EXPECT_TRUE(launch_queue_->GetPendingLaunchAppId()); + + // Simulate successful navigation commit using NavigationSimulator. + content::NavigationSimulator::NavigateAndCommitFromBrowser(web_contents(), + launch_url); + + launch_queue_->FlushForTesting(); + + EXPECT_TRUE(fake_launch_service_.enqueue_called()); + EXPECT_EQ(fake_launch_service_.launched_url(), launch_url);
Regression Test / PoC
diff --git a/components/webapps/browser/launch_queue/launch_queue_unittest.cc b/components/webapps/browser/launch_queue/launch_queue_unittest.cc
new file mode 100644
index 0000000..a0cbb77
--- /dev/null
+++ b/components/webapps/browser/launch_queue/launch_queue_unittest.cc
@@ -0,0 +1,206 @@
+// 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 "components/webapps/browser/launch_queue/launch_queue.h"
+
+#include <memory>
+#include <utility>
+#include <vector>
+
+#include "base/memory/raw_ptr.h"
+#include "base/time/time.h"
+#include "components/webapps/browser/launch_queue/launch_params.h"
+#include "components/webapps/browser/launch_queue/launch_queue_delegate.h"
+#include "content/public/browser/file_system_access_permission_context.h"
+#include "content/public/browser/web_contents.h"
+#include "content/public/test/navigation_simulator.h"
+#include "content/public/test/test_renderer_host.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/file_system_access/file_system_access_directory_handle.mojom.h"
+#include "third_party/blink/public/mojom/web_launch/web_launch.mojom.h"
+
+namespace webapps {
+
+class MockLaunchQueueDelegate : public LaunchQueueDelegate {
+ public:
+ MOCK_METHOD(bool,
+ IsInScope,
+ (const LaunchParams& launch_params, const GURL& current_url),
+ (const, override));
+ MOCK_METHOD(content::PathInfo,
+ GetPathInfo,
+ (const base::FilePath& entry_path),
+ (const, override));
+ MOCK_METHOD(bool,
+ IsValidLaunchParams,
+ (const LaunchParams& params),
+ (const, override));
+};
+
+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)));
+ }
+
+ // blink::mojom::WebLaunchService:
+ 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 LaunchQueueTest : public content::RenderViewHostTestHarness {
+ public:
+ void SetUp() override {
+ content::RenderViewHostTestHarness::SetUp();
+ auto delegate =
+ std::make_unique<testing::NiceMock<MockLaunchQueueDelegate>>();
+ delegate_ = delegate.get();
+
+ ON_CALL(*delegate_, IsValidLaunchParams)
+ .WillByDefault(testing::Return(true));
+ ON_CALL(*delegate_, IsInScope)
+ .WillByDefault(
+ [](const LaunchParams& params, const GURL& url) { return true; });
+
+ launch_queue_ =
+ std::make_unique<LaunchQueue>(web_contents(), std::move(delegate));
+
+ InitTestApi(web_contents()->GetPrimaryMainFrame());
+ }
+
+ void TearDown() override {
+ delegate_ = nullptr;
+ launch_queue_.reset();
+ content::RenderViewHostTestHarness::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,
+ bool started_new_navigation = true) {
+ LaunchParams params;
+ params.target_url = target_url;
+ params.started_new_navigation = started_new_navigation;
+ params.app_id = "test_app_id";
+ return params;
+ }
+
+ std::unique_ptr<LaunchQueue> launch_queue_;
+ raw_ptr<MockLaunchQueueDelegate> delegate_;
+ FakeWebLaunchService fake_launch_service_;
+};
+
+TEST_F(LaunchQueueTest, EnqueueAndCommit) {
+ GURL launch_url("https://example.com/launch");
+ LaunchParams params = CreateLaunchParams(launch_url);
+
+ launch_queue_->Enqueue(std::move(params));
+ EXPECT_TRUE(launch_queue_->GetPendingLaunchAppId());
+
+ // Simulate successful navigation commit using NavigationSimulator.
+ content::NavigationSimulator::NavigateAndCommitFromBrowser(web_contents(),
+ launch_url);
+
+ launch_queue_->FlushForTesting();
+
+ EXPECT_TRUE(fake_launch_service_.enqueue_called());
+ EXPECT_EQ(fake_launch_service_.launched_url(), launch_url);
+ EXPECT_FALSE(launch_queue_->GetPendingLaunchAppId());
+}
+
+TEST_F(LaunchQueueTest, EnqueueAndAbort) {
+ GURL launch_url("https://example.com/launch");
+ LaunchParams params = CreateLaunchParams(launch_url);
+
+ launch_queue_->Enqueue(std::move(params));
+ EXPECT_TRUE(launch_queue_->GetPendingLaunchAppId());
+
+ // Simulate aborted navigation (does not commit).
+ content::NavigationSimulator::NavigateAndFailFromBrowser(
+ web_contents(), launch_url, net::ERR_ABORTED);
+
+ launch_queue_->FlushForTesting();
+
+ EXPECT_FALSE(fake_launch_service_.enqueue_called());
+ EXPECT_FALSE(
+ launch_queue_->GetPendingLaunchAppId()); // Queue should be reset
+}
+
+TEST_F(LaunchQueueTest, EnqueueAndError) {
+ GURL launch_url("https://example.com/launch");
+ LaunchParams params = CreateLaunchParams(launch_url);
+
+ launch_queue_->Enqueue(std::move(params));
+ EXPECT_TRUE(launch_queue_->GetPendingLaunchAppId());
+
+ // Simulate navigation that commits an error page.
+ content::NavigationSimulator::NavigateAndFailFromBrowser(
+ web_contents(), launch_url, net::ERR_CONNECTION_RESET);
+
+ launch_queue_->FlushForTesting();
+
+ EXPECT_FALSE(fake_launch_service_.enqueue_called());
+ EXPECT_FALSE(
+ launch_queue_->GetPendingLaunchAppId()); // Queue should be reset
+}
+
+TEST_F(LaunchQueueTest, EnqueueAndOutOfScope) {
+ GURL launch_url("https://example.com/launch");
+ GURL out_of_scope_url("https://attacker.com/");
+ LaunchParams params = CreateLaunchParams(launch_url);
+
+ launch_queue_->Enqueue(std::move(params));
+ EXPECT_TRUE(launch_queue_->GetPendingLaunchAppId());
+
+ // Delegate says it is out of scope.
+ EXPECT_CALL(*delegate_, IsInScope(testing::_, out_of_scope_url))
+ .WillOnce(testing::Return(false));
+
+ // Simulate navigation to out of scope URL.
+ content::NavigationSimulator::NavigateAndCommitFromBrowser(web_contents(),
+ out_of_scope_url);
+
+ launch_queue_->FlushForTesting();
+
+ EXPECT_FALSE(fake_launch_service_.enqueue_called());
+ EXPECT_FALSE(
+ launch_queue_->GetPendingLaunchAppId()); // Queue should be reset
+}
+
+} // namespace webapps
Original Bug Report
Potential missing HasCommitted() in LaunchQueue delivers File System handles to attacker page
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 without the security team.
Overview: LaunchQueue::DidFinishNavigation fails to verify if a navigation has committed before delivering File System Access handles. An attacker controlling an in-scope page can abort a file launch navigation, causing the handles to be delivered to their malicious document. This potentially grants the attacker silent read/write access to local files opened by the user.
Affected files:
components/webapps/browser/launch_queue/launch_queue.ccchrome/browser/ui/web_applications/web_app_launch_process.ccthird_party/blink/renderer/modules/launch/launch_queue.cc
Estimated timestamp from git blame: 2025-03-19
Vulnerability Description
A potential security vulnerability exists in LaunchQueue::DidFinishNavigation (components/webapps/browser/launch_queue/launch_queue.cc) where the completion of a navigation is processed without verifying handle->HasCommitted().
When a user opens a file with a PWA configured to use an existing window (client_mode: 'navigate-existing'), Chrome initiates a navigation to the PWA’s file handler URL. If this navigation is aborted before it commits, LaunchQueue::DidFinishNavigation still executes.
The code correctly validates the intended target URL against the app’s scope (delegate_->IsInScope(..., handle->GetURL())). Because handle->GetURL() returns the intended file handler URL even for aborted navigations, this check passes. However, because the navigation failed to commit, the WebContents still hosts the previously loaded document. When SendLaunchParams is subsequently called, it retrieves the PrimaryMainFrame, which refers to the currently committed frame, and delivers the File System Access (FSA) handles to it.
Impact
An attacker controlling an in-scope page can obtain FileSystemFileHandle objects for files the user intended to open with the PWA. These handles are constructed with UserAction::kSave permissions, automatically granting both read and write access. This allows the attacker to read, overwrite, or truncate local files without further user interaction or warnings.
Potential Exploitation Scenario
Note: These are suggested steps; our tooling agent does not have the ability to run code to verify an end-to-end exploit.
- An attacker gains control of a document that falls within the scope of a targeted PWA (e.g., via XSS, or by hosting a malicious page on a shared domain like a
github.iosub-path). The PWA must useclient_mode: 'navigate-existing'. - The user has the PWA window open and navigated to this attacker-controlled, in-scope page.
- The attacker’s page prompts the user to interact with it (e.g., clicking anywhere), granting the attacker’s frame transient user activation.
- While the page is active, the user attempts to open a file from the OS file manager using the PWA.
- Chrome locates the existing PWA window and initiates a browser-initiated navigation to the PWA’s file handler URL.
- Within the brief window of transient user activation, the attacker’s JavaScript intercepts the navigation attempt and triggers a renderer-initiated navigation (e.g.,
window.stop()orlocation.href = location.href). Because of the transient activation, this successfully aborts the pending browser-initiated navigation. - The aborted navigation triggers
LaunchQueue::DidFinishNavigation. Since the intended URL was in-scope, the logic proceeds without checkingHasCommitted(). The attacker’s frame remains active and receives the highly privileged Mojo handles for the file via thelaunchQueue.setConsumercallback.
Suggested Fix
Add a check for handle->HasCommitted() at the start of the pending_navigation_ logic in LaunchQueue::DidFinishNavigation.
void LaunchQueue::DidFinishNavigation(content::NavigationHandle* handle) {
// Currently, launch data is only sent the primary main frame.
if (!handle->IsInPrimaryMainFrame()) {
return;
}
if (pending_navigation_) {
if (!handle->HasCommitted() || !delegate_->IsInScope(queue_.front(), handle->GetURL())) {
Reset();
return;
}
pending_navigation_ = false;
SendQueuedLaunchParams(handle->GetURL());
return;
}
// ... existing reload logic
}
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.