Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in ControlledFrame
DescriptionMissing authorization in ControlledFrame
ComponentControlledFrame
Sub-componentFSA
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker511773417
Fix commit029d1d53ede2 (chromium/src) +199/-52
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-10 (per vendor advisory)
Fix landed2026-08-05 (public git)
Disclosed2026-09-08
Patch gap34 days — fix public in source before the advisory

Background

`DataTransferItem.getAsFileSystemHandle()`
a web API that turns a file delivered via drag-and-drop or clipboard paste into a File System Access handle (FileSystemFileHandle/FileSystemDirectoryHandle) that can then read the underlying local file.
`CanShowFilePicker()`
an embedder permission check on permission_context_ that decides whether a given RenderFrameHost is allowed to obtain local file handles (the same gate that governs showOpenFilePicker()); it returns a value on allow and an error on deny.
`<controlledframe>` guest
an embedded frame that, along with other non-default-StoragePartition http(s) frames, is deliberately denied direct file-picker access per the advisory (Missing authorization in ControlledFrame).
`HandleType::kFile`
the enum value distinguishing a file handle from a directory handle in the data-transfer resolution path.

Root Cause Analysis

When a data-transfer token was resolved into a file handle in content/browser/file_system_access/file_system_access_manager_impl.cc, the code guarded only against a missing permission_context_ and against directory-vs-file handling; it never consulted the embedder’s CanShowFilePicker() authorization check. The pre-patch condition if (!permission_context_ || file_type == HandleType::kFile) short-circuited straight to DidVerifySensitiveDirectoryAccessForDataTransfer(..., SensitiveEntryResult::kAllowed) for any file, meaning frames that are forbidden from calling showOpenFilePicker() could still receive a working local-file handle through paste or drop. The violated invariant is that a frame denied file-picker access must not be able to obtain a local file handle by any route.

The fix adds, before the file/directory branch, a lookup of the RenderFrameHost via RenderFrameHost::FromID(binding_context.frame_id) and a call to permission_context_->CanShowFilePicker(rfh); if the frame is unknown it returns kOperationAborted, and if CanShowFilePicker returns no value it returns kPermissionDenied, so unauthorized frames now fail with NotAllowedError. This works because the same authorization gate that blocks showOpenFilePicker() is now enforced on the paste/drop path, closing the alternate route.

The patch only adds an authorization check — it does not change how the file is later read.

Key insight
The single mistake is an incomplete authorization surface: getAsFileSystemHandle() reached the handle-resolution path without ever calling CanShowFilePicker(), the check that gates the equivalent showOpenFilePicker() API. The fix simply inserts that missing CanShowFilePicker() consultation (and a supporting RenderFrameHost null check) ahead of the existing file/directory branch so denied frames are rejected before any handle is produced.

Attack Path

  1. Embed a restricted guest An attacker page hosts a <controlledframe> guest (or another non-default-StoragePartition http(s) frame) that is denied showOpenFilePicker() by CanShowFilePicker().
  2. Get a local file onto the transfer channel A user pastes or drags a local file into the guest; the regression test models this with ui::ScopedClipboardWriter::WriteFilenames referencing a real file, then web_contents->Paste() after focusing the guest and granting user activation.
  3. Resolve the data-transfer token to a handle In the guest’s onpaste handler the attacker calls event.clipboardData.items[0].getAsFileSystemHandle(); pre-patch the resolution path took the file_type == HandleType::kFile branch to SensitiveEntryResult::kAllowed without checking CanShowFilePicker(), returning a usable FileSystemFileHandle.
  4. Read the local file The attacker calls fileHandle.getFile() then file.text() to exfiltrate the file’s contents (the PoC asserts a SECRET-HOST-FILE-CONTENTS-42 read), which post-patch instead throws NotAllowedError.

Impact Assessment

An attacker-controlled restricted frame gains the ability to obtain a local-file FileSystemFileHandle and read the file’s contents from within the guest/renderer web context, bypassing the file-picker authorization that should apply to it. Preconditions are that a local file be present on the clipboard or dropped into the frame and (per the PoC comment referencing ChromeContentBrowserClient::IsClipboardPasteAllowed) that the guest be focused with user activation, so it depends on user interaction rather than being fully silent. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5. The disclosure is limited to local file contents that reach the transfer channel, and any escalation beyond that read is not demonstrated by the patch (inferred).

Changed Functions

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

Files Changed

  • content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
  • chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.cc
  • chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc
  • chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.h
  • content/browser/file_system_access/file_system_access_manager_impl.cc

Audit Directions

  • Parallel handle-acquisition routes
    Audit every path that yields a File System Access handle (drag-and-drop, clipboard paste, serialized/postMessage transfer tokens) to confirm each consults the same CanShowFilePicker()/embedder authorization used by showOpenFilePicker(), rather than only the picker entry point.
  • Guards conditioned on `permission_context_` or handle type
    Look for early-return branches like the old if (!permission_context_ || file_type == HandleType::kFile) that conflate “no permission context” with “allowed” or skip authorization for a specific HandleType, which can silently allow access when a real context exists.
  • `<controlledframe>` / non-default-`StoragePartition` restrictions
    Review other capabilities intended to be denied to guest and non-default-partition http(s) frames to ensure the restriction is enforced at the browser-process resolution point for all delivery mechanisms, not just the primary API surface.
From 029d1d53ede2782b4fbb7de057ff9e2c05a318d5 Mon Sep 17 00:00:00 2001
From: Simon Hangl <simonha@google.com>
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 <simonha@google.com>
Reviewed-by: Mingyu Lei <leimy@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1674064}
---
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());
-
-    // Verify that mouse click gave user activation.
-    EXPECT_TRUE(
-        content::EvalJs(controlled_frame, "navigator.userActivation.isActive")
-            .ExtractBool());
-  }
-}
-
 }  // namespace
 
 PermissionRequestTestCase::PermissionRequestTestCase() = default;
@@ -234,6 +184,62 @@
                                                       handle_request_str)));
 }
 
+void ControlledFramePermissionRequestTestBase::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.focus();
+        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 &&
+             content::EvalJs(controlled_frame,
+                             "document.hasFocus() && "
+                             "navigator.userActivation.isActive")
+                 .ExtractBool();
+    });
+
+    // Verify document focused.
+    EXPECT_TRUE(
+        content::EvalJs(controlled_frame, "document.hasFocus()").ExtractBool());
+
+    // Verify that mouse click gave user activation.
+    EXPECT_TRUE(
+        content::EvalJs(controlled_frame, "navigator.userActivation.isActive")
+            .ExtractBool());
+  }
+}
+
 void ControlledFramePermissionRequestTestBase::VerifyEnabledPermission(
     const PermissionRequestTestCase& test_case,
     const PermissionRequestTestParam& test_param,
diff --git a/chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.h b/chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.h
index f5027e49..0929df59 100644
--- a/chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.h
+++ b/chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.h
@@ -119,7 +119,11 @@
   SetUpControlledFrame(const DisabledPermissionTestCase& test_case,
                        const DisabledPermissionTestParam& test_param);
 
- private:
+  void FocusControlledFrame(content::RenderFrameHost* app_frame,
+                            content::RenderFrameHost* controlled_frame,
+                            bool must_wait_document_focus = true);
+
+ protected:
   void SetUpPermissionRequestEventListener(
       content::RenderFrameHost* app_frame,
       const std::string& expected_permission_name,
diff --git a/content/browser/file_system_access/file_system_access_manager_impl.cc b/content/browser/file_system_access/file_system_access_manager_impl.cc
index e8d22d6..ad279ee 100644
--- a/content/browser/file_system_access/file_system_access_manager_impl.cc
+++ b/content/browser/file_system_access/file_system_access_manager_impl.cc
@@ -957,8 +957,35 @@
     HandleType file_type) {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
 
+  if (!permission_context_) {
+    DidVerifySensitiveDirectoryAccessForDataTransfer(
+        binding_context, path_info, url, file_type,
+        std::move(token_resolved_callback), SensitiveEntryResult::kAllowed);
+    return;
+  }
+
+  RenderFrameHost* rfh = RenderFrameHost::FromID(binding_context.frame_id);
+  if (!rfh) {
+    std::move(token_resolved_callback)
+        .Run(file_system_access_error::FromStatus(
+                 blink::mojom::FileSystemAccessStatus::kOperationAborted),
+             blink::mojom::FileSystemAccessEntryPtr());
+    return;
+  }
+
+  auto can_show_file_picker_result =
+      permission_context_->CanShowFilePicker(rfh);
+  if (!can_show_file_picker_result.has_value()) {
+    std::move(token_resolved_callback)
+        .Run(file_system_access_error::FromStatus(
+                 blink::mojom::FileSystemAccessStatus::kPermissionDenied,
+                 can_show_file_picker_result.error()),
+             blink::mojom::FileSystemAccessEntryPtr());
+    return;
+  }
+
   // Don't perform sensitive entry access checks on D&D files.
-  if (!permission_context_ || file_type == HandleType::kFile) {
+  if (file_type == HandleType::kFile) {
     DidVerifySensitiveDirectoryAccessForDataTransfer(
         binding_context, path_info, url, file_type,
         std::move(token_resolved_callback), SensitiveEntryResult::kAllowed);
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…

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 vm...@google.com

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