CVE-2026-87513
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ControlledFrameClipboardFSABypassTestchrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc |
modified | |
ifchrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc |
modified | |
ifchrome/browser/controlled_frame/controlled_frame_permission_request_test_base.cc |
modified |
Files Changed
chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.ccchrome/browser/controlled_frame/controlled_frame_permission_request_test_base.cc
Patch
From 029d1d53ede2782b4fbb7de057ff9e2c05a318d5 Mon Sep 17 00:00:00 2001 From: Simon Hangl <[email protected]> Date: Wed, 05 Aug 2026 05:39:51 -0700 Subject: [PATCH] [FSA] Gate getAsFileSystemHandle() with CanShowFilePicker DataTransferItem.getAsFileSystemHandle() (used for files delivered via drag-and-drop or clipboard paste) was not consulting the embedder's CanShowFilePicker() check, so frames that are not allowed to obtain local file handles via showOpenFilePicker() could still obtain them via paste/drop. Consult CanShowFilePicker() when resolving a data-transfer token to a handle. In Chrome this means <controlledframe> guests and other non-default-StoragePartition http(s) frames now reject getAsFileSystemHandle() with NotAllowedError, matching their existing showOpenFilePicker() behaviour. Bug: 511773417 Change-Id: Ic5e884708bcf46408153da3764e77070e786b337 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8176064 Commit-Queue: Simon Hangl <[email protected]> Reviewed-by: Mingyu Lei <[email protected]> Cr-Commit-Position: refs/heads/main@{#1674064} --- diff --git a/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc b/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc index e0a2912..2918959 100644 --- a/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc +++ b/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc @@ -31,6 +31,10 @@ #include "services/device/public/cpp/test/fake_usb_device_manager.h" #include "services/device/public/mojom/serial.mojom.h" #include "services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom-forward.h" +#include "ui/base/clipboard/clipboard_buffer.h" +#include "ui/base/clipboard/file_info.h" +#include "ui/base/clipboard/scoped_clipboard_writer.h" +#include "ui/base/clipboard/test/test_clipboard.h" #include "ui/shell_dialogs/select_file_dialog.h" namespace { @@ -377,4 +381,108 @@ return info.param.name; }); +class ControlledFrameClipboardFSABypassTest + : public ControlledFrameDisabledPermissionTest { + public: + void SetUpOnMainThread() override { + ASSERT_TRUE( + temp_dir_.CreateUniqueTempDirUnderPath(base::GetTempDirForTesting())); + ui::TestClipboard::CreateForCurrentThread(); + ControlledFrameDisabledPermissionTest::SetUpOnMainThread(); + } + + void TearDownOnMainThread() override { + ui::Clipboard::DestroyClipboardForCurrentThread(); + ASSERT_TRUE(temp_dir_.Delete()); + ControlledFrameDisabledPermissionTest::TearDownOnMainThread(); + } + + base::FilePath CreateTestFile(const std::string& contents) { + base::ScopedAllowBlockingForTesting allow_blocking; + base::FilePath result; + EXPECT_TRUE(base::CreateTemporaryFileInDir(temp_dir_.GetPath(), &result)); + EXPECT_TRUE(base::WriteFile(result, contents)); + return result; + } + + private: + base::ScopedTempDir temp_dir_; +}; + +IN_PROC_BROWSER_TEST_P(ControlledFrameClipboardFSABypassTest, + GuestReadsLocalFileViaClipboardPaste) { + DisabledPermissionTestParam test_param = GetParam(); + if (test_param.name == "BothFailsWhenPermissionsPolicyIsNotEnabled") { + return; + } + + DisabledPermissionTestCase test_case; + auto [app_frame, controlled_frame] = + SetUpControlledFrame(test_case, test_param); + if (!app_frame || !controlled_frame) { + return; + } + + FocusControlledFrame(app_frame, controlled_frame, + /*must_wait_document_focus=*/true); + + const std::string secret = "SECRET-HOST-FILE-CONTENTS-42"; + const base::FilePath test_file = CreateTestFile(secret); + + { + ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste); + writer.WriteFilenames( + ui::FileInfosToURIList({ui::FileInfo(test_file, base::FilePath())})); + } + + ASSERT_TRUE(content::ExecJs(controlled_frame, R"( + var p = new Promise((resolve, reject) => { + window.document.onpaste = async (event) => { + try { + if (event.clipboardData.items.length !== 1) { + reject('Expected 1 clipboard item. length=' + + event.clipboardData.items.length); + return; + } + const fileItem = event.clipboardData.items[0]; + const fileHandle = await fileItem.getAsFileSystemHandle(); + if (!fileHandle) { + reject('fileHandle is falsey. kind=' + + fileItem.kind + ' type=' + fileItem.type); + return; + } + const file = await fileHandle.getFile(); + const text = await file.text(); + resolve('READ:' + text); + } catch (e) { + if (e.name === 'NotAllowedError') { + resolve('error'); + } else { + reject(e.name + ': ' + e.message); + } + } + }; + }); + )")); + + // Ensure the guest frame is focused and has user activation, which is + // required by ChromeContentBrowserClient::IsClipboardPasteAllowed to allow + // paste, and required on ChromeOS so that paste events reach the guest frame. + content::WebContents* web_contents = + content::WebContents::FromRenderFrameHost(controlled_frame); + web_contents->Paste(); + + EXPECT_EQ("error", content::EvalJs(controlled_frame, "p")); +} + +INSTANTIATE_TEST_SUITE_P(/*no prefix*/ + , + ControlledFrameClipboardFSABypassTest, + testing::ValuesIn( + GetDefaultDisabledPermissionTestParams()), + [](const testing::TestParamInfo< + DisabledPermissionTestParam>& info) { + return info.param.name; + }); + } // namespace controlled_frame diff --git a/chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.cc b/chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.cc index e22a1a5..da6d65e 100644 --- a/chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.cc +++ b/chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.cc @@ -129,56 +129,6 @@ return ContentSetting::CONTENT_SETTING_BLOCK; } -void FocusControlledFrame(content::RenderFrameHost* app_frame, - content::RenderFrameHost* controlled_frame, - bool must_wait_document_focus) { - // Focus when the frame is loaded. - EXPECT_TRUE(content::ExecJs(app_frame, - R"( - (function() { - const frame = document.getElementsByTagName('controlledframe')[0]; - if (!frame) { - throw new Error('FAIL: Could not find a controlledframe element.'); - } - frame.addEventListener('loadstop', () => { - frame.focus(); - }); - return 'SUCCESS'; - })(); - )")); - - WaitForHitTestData(controlled_frame); - - // Make user activation on <controlledframe> with a fake click. - content::SimulateMouseClickAt( - content::WebContents::FromRenderFrameHost(app_frame), - /*modifiers=*/0, blink::WebMouseEvent::Button::kLeft, - controlled_frame->GetView()->TransformPointToRootCoordSpace( - gfx::Point(20, 20))); - - if (must_wait_document_focus) { - // Wait for the focus. - // Couldn't get FocusChangedObserver to work, it resulted in - // timeouts, probably because webContents already was focused, - // and there are internal race conditions. - base::test::ScopedRunLoopTimeout default_timeout(FROM_HERE, - base::Seconds(5)); - base::test::RunUntil([&]() -> bool { - auto* web_contents = content::WebContents::FromRenderFrameHost(app_frame); - return web_contents->GetFocusedFrame() == controlled_frame; - }); - - // Verify document focused. - EXPECT_TRUE( - content::EvalJs(controlled_frame, "document.hasFocus()").ExtractBool()); -
Regression Test / PoC
diff --git a/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc b/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc
index e0a2912..2918959 100644
--- a/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc
+++ b/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc
@@ -31,6 +31,10 @@
#include "services/device/public/cpp/test/fake_usb_device_manager.h"
#include "services/device/public/mojom/serial.mojom.h"
#include "services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom-forward.h"
+#include "ui/base/clipboard/clipboard_buffer.h"
+#include "ui/base/clipboard/file_info.h"
+#include "ui/base/clipboard/scoped_clipboard_writer.h"
+#include "ui/base/clipboard/test/test_clipboard.h"
#include "ui/shell_dialogs/select_file_dialog.h"
namespace {
@@ -377,4 +381,108 @@
return info.param.name;
});
+class ControlledFrameClipboardFSABypassTest
+ : public ControlledFrameDisabledPermissionTest {
+ public:
+ void SetUpOnMainThread() override {
+ ASSERT_TRUE(
+ temp_dir_.CreateUniqueTempDirUnderPath(base::GetTempDirForTesting()));
+ ui::TestClipboard::CreateForCurrentThread();
+ ControlledFrameDisabledPermissionTest::SetUpOnMainThread();
+ }
+
+ void TearDownOnMainThread() override {
+ ui::Clipboard::DestroyClipboardForCurrentThread();
+ ASSERT_TRUE(temp_dir_.Delete());
+ ControlledFrameDisabledPermissionTest::TearDownOnMainThread();
+ }
+
+ base::FilePath CreateTestFile(const std::string& contents) {
+ base::ScopedAllowBlockingForTesting allow_blocking;
+ base::FilePath result;
+ EXPECT_TRUE(base::CreateTemporaryFileInDir(temp_dir_.GetPath(), &result));
+ EXPECT_TRUE(base::WriteFile(result, contents));
+ return result;
+ }
+
+ private:
+ base::ScopedTempDir temp_dir_;
+};
+
+IN_PROC_BROWSER_TEST_P(ControlledFrameClipboardFSABypassTest,
+ GuestReadsLocalFileViaClipboardPaste) {
+ DisabledPermissionTestParam test_param = GetParam();
+ if (test_param.name == "BothFailsWhenPermissionsPolicyIsNotEnabled") {
+ return;
+ }
+
+ DisabledPermissionTestCase test_case;
+ auto [app_frame, controlled_frame] =
+ SetUpControlledFrame(test_case, test_param);
+ if (!app_frame || !controlled_frame) {
+ return;
+ }
+
+ FocusControlledFrame(app_frame, controlled_frame,
+ /*must_wait_document_focus=*/true);
+
+ const std::string secret = "SECRET-HOST-FILE-CONTENTS-42";
+ const base::FilePath test_file = CreateTestFile(secret);
+
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+ writer.WriteFilenames(
+ ui::FileInfosToURIList({ui::FileInfo(test_file, base::FilePath())}));
+ }
+
+ ASSERT_TRUE(content::ExecJs(controlled_frame, R"(
+ var p = new Promise((resolve, reject) => {
+ window.document.onpaste = async (event) => {
+ try {
+ if (event.clipboardData.items.length !== 1) {
+ reject('Expected 1 clipboard item. length=' +
+ event.clipboardData.items.length);
+ return;
+ }
+ const fileItem = event.clipboardData.items[0];
+ const fileHandle = await fileItem.getAsFileSystemHandle();
+ if (!fileHandle) {
+ reject('fileHandle is falsey. kind=' +
+ fileItem.kind + ' type=' + fileItem.type);
+ return;
+ }
+ const file = await fileHandle.getFile();
+ const text = await file.text();
+ resolve('READ:' + text);
+ } catch (e) {
+ if (e.name === 'NotAllowedError') {
+ resolve('error');
+ } else {
+ reject(e.name + ': ' + e.message);
+ }
+ }
+ };
+ });
+ )"));
+
+ // Ensure the guest frame is focused and has user activation, which is
+ // required by ChromeContentBrowserClient::IsClipboardPasteAllowed to allow
+ // paste, and required on ChromeOS so that paste events reach the guest frame.
+ content::WebContents* web_contents =
+ content::WebContents::FromRenderFrameHost(controlled_frame);
+ web_contents->Paste();
+
+ EXPECT_EQ("error", content::EvalJs(controlled_frame, "p"));
+}
+
+INSTANTIATE_TEST_SUITE_P(/*no prefix*/
+ ,
+ ControlledFrameClipboardFSABypassTest,
+ testing::ValuesIn(
+ GetDefaultDisabledPermissionTestParams()),
+ [](const testing::TestParamInfo<
+ DisabledPermissionTestParam>& info) {
+ return info.param.name;
+ });
+
} // namespace controlled_frame
diff --git a/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc b/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
index e81f04bb..77258dc1 100644
--- a/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
@@ -210,6 +210,8 @@
EXPECT_CALL(permission_context_, IsFileTypeDangerous_)
.WillRepeatedly(testing::Return(false));
+ EXPECT_CALL(permission_context_, CanShowFilePicker(testing::_))
+ .WillRepeatedly(testing::Return(base::ok()));
}
void TearDown() override {
Original Bug Report
Potential cross-partition permission leak in ControlledFrame via FSA DataTransfer
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 Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: ControlledFrame guests are intentionally blocked from using the File System Access (FSA) file picker to prevent profile-wide permission leaks across isolation boundaries. However, drag-and-drop and clipboard paste operations fail to enforce this restriction and mint FSA tokens. When a guest redeems these tokens, it obtains persistent, profile-wide read access to the file, bypassing the isolation.
Affected files:
content/browser/renderer_host/clipboard_host_impl.cccontent/browser/renderer_host/render_widget_host_impl.cccontent/browser/file_system_access/file_system_access_manager_impl.ccchrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
Estimated timestamp from git blame: 2025-03-11
Background
ControlledFrame is an isolation mechanism used primarily by Isolated Web Apps (IWAs) to host untrusted guest content while strictly isolating its state from the broader user profile. Because File System Access (FSA) permissions are currently scoped to the entire user Profile, granting an FSA permission to a guest would leak that permission to the rest of the profile.
To prevent this, ChromeFileSystemAccessPermissionContext::CanShowFilePicker explicitly blocks the FSA file picker UI for ControlledFrame guests by checking guest->IsOwnedByControlledFrameEmbedder().
Vulnerability
This isolation boundary can be bypassed through clipboard paste or drag-and-drop operations.
When a user drops a file onto a ControlledFrame, the drop event is handled by RenderWidgetHostImpl::DragTargetDrop. This function eventually calls FileInfosToDataTransferFiles (in content/browser/renderer_host/data_transfer_util.cc). This function iterates over the dropped files and calls CreateFileSystemAccessDataTransferToken to mint FSA tokens. Crucially, there is no check in this path to verify if the receiving context is an isolated ControlledFrame. A similar flow exists in ClipboardHostImpl::OnReadFiles for pasted files.
Exploitation Scenario (Suggested Steps)
- An attacker controls the web content loaded inside a
ControlledFrameguest (e.g.,https://attacker.com). - The attacker uses social engineering to trick the user into dragging a sensitive local file into the
ControlledFrameguest content. - The guest’s JavaScript intercepts the
dropevent and callsitem.getAsFileSystemHandle()on the file item indataTransfer.items. - The browser process handles the token redemption via
FileSystemAccessManagerImpl::GetEntryFromDataTransferTokenandResolveDataTransferTokenWithFileType. - Because the dropped item is a file,
ResolveDataTransferTokenWithFileTypeskips theConfirmSensitiveEntryAccessprompt and callsGetSharedHandleStateForNonSandboxedPath. - This calls
ChromeFileSystemAccessPermissionContext::GetReadPermissionGrantwith the guest’s origin (https://attacker.com) andUserAction::kDragAndDrop. - The context immediately sets the grant status to
PermissionStatus::GRANTEDand saves it to the profile-wideactive_permissions_map_and on-disk preferences. - The attacker navigates a normal, un-isolated tab in the main browser window to
https://attacker.com. Because the permission is profile-wide and active in memory for the current session, the attacker can immediately read the file without any prompts, entirely bypassing theControlledFrameisolation boundary.
(Note: These are potential exploitation steps; our tooling has not run a live proof-of-concept).
Suggested Fix
Add a check to prevent FSA DataTransfer tokens from being minted or redeemed for ControlledFrame guests. This could be implemented by:
- Checking
IsOwnedByControlledFrameEmbedderin the drag-and-drop and clipboard handlers before callingFileInfosToDataTransferFiles. - Alternatively, enforcing the restriction during token redemption in
FileSystemAccessManagerImpl, ensuring that isolated contexts cannot obtain profile-wide permission grants.
Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955
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.