Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in ControlledFrame
DescriptionMissing authorization in ControlledFrame
ComponentControlledFrame
Bug ClassLogic Error
Tracker511773417
Fix commit029d1d53ede2 (chromium/src) +199/-52
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
ControlledFrameClipboardFSABypassTest
chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc
modified
if
chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc
modified
if
chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.cc
modified

Files Changed

  • chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc
  • chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.cc
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());
-
Loading diff…

Regression Test / PoC

shipped with the fix
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 {
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • content/browser/renderer_host/render_widget_host_impl.cc
  • content/browser/file_system_access/file_system_access_manager_impl.cc
  • chrome/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)

  1. An attacker controls the web content loaded inside a ControlledFrame guest (e.g., https://attacker.com).
  2. The attacker uses social engineering to trick the user into dragging a sensitive local file into the ControlledFrame guest content.
  3. The guest’s JavaScript intercepts the drop event and calls item.getAsFileSystemHandle() on the file item in dataTransfer.items.
  4. The browser process handles the token redemption via FileSystemAccessManagerImpl::GetEntryFromDataTransferToken and ResolveDataTransferTokenWithFileType.
  5. Because the dropped item is a file, ResolveDataTransferTokenWithFileType skips the ConfirmSensitiveEntryAccess prompt and calls GetSharedHandleStateForNonSandboxedPath.
  6. This calls ChromeFileSystemAccessPermissionContext::GetReadPermissionGrant with the guest’s origin (https://attacker.com) and UserAction::kDragAndDrop.
  7. The context immediately sets the grant status to PermissionStatus::GRANTED and saves it to the profile-wide active_permissions_map_ and on-disk preferences.
  8. 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 the ControlledFrame isolation 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:

  1. Checking IsOwnedByControlledFrameEmbedder in the drag-and-drop and clipboard handlers before calling FileInfosToDataTransferFiles.
  2. 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.

View on issue tracker
Links in the report