Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Filesystems
DescriptionInappropriate implementation in Filesystems
ComponentFilesystems
Bug ClassLogic Error
Tracker411544197
Fix commiteea1c4bbbf14 (chromium/src) +292/-18
CISA KEVNot listed
CreditedHuuuuu
Disclosed2025-08-05

Changed Functions

FunctionChangeNotes
if
chrome/browser/file_system_access/chrome_file_system_access_permission_context_browsertest.cc
modified
FileSystemChromeAppTest
chrome/browser/file_system_access/chrome_file_system_access_permission_context_browsertest.cc
modified
FileSystemChromeAppTest
chrome/browser/file_system_access/chrome_file_system_access_permission_context_browsertest.cc
modified
FileSystemAccessFileHandleImplTest
content/browser/file_system_access/file_system_access_file_handle_impl_unittest.cc
modified

Files Changed

  • chrome/browser/file_system_access/chrome_file_system_access_permission_context_browsertest.cc
  • content/browser/file_system_access/file_system_access_file_handle_impl_unittest.cc
From eea1c4bbbf146e39d4ef0599ec05a733cb4a1aab Mon Sep 17 00:00:00 2001
From: Ming-Ying Chung <[email protected]>
Date: Tue, 10 Jun 2025 06:34:30 -0700
Subject: [PATCH] Add safe browsing check to [`FileSystemHandle.move()`][1]

When the API is used together with `window.showSaveFilePicker()`,
e.g.

```js
(await window.showSaveFilePicker({suggestedName: `hello`})).move(`hello.swf`);
```

It may create a potentially ambiguous scenario that misleads users about
what the actual file is downloaded. In the above example, the final
downloaded file will be `hello.swf` (not `hello` from file picker UI).
Per [discussion][2], It's better to ask explicitly for confirmation.

This CL adds a call to `ConfirmSensitiveEntryAccess()` before calling
`FileSystemAccessSafeMoveHelper`, i.e. `DoFileSystemOperation()`. The
call in chrome embedder will check the destination path against
blocklist and [show prompt UI][3] for user permissions. Example screenshot: [4].


[1]: https://chromestatus.com/feature/5640802622504960
[2]: http://crbug.com/411544197#comment7
[3]: https://crsrc.org/c/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc;l=1982-2003;drc=1d737975521c1d4191937c2c659bd78d9f1681f4
[4]: http://screen/AUFXTfXZxvKufEJ

Bug: 411544197
Change-Id: I9a377b42495ef1f36bb02eb0be0fdfb6055d9d83
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6475317
Reviewed-by: Austin Sullivan <[email protected]>
Commit-Queue: Ming-Ying Chung <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1471747}
---

diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_browsertest.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_browsertest.cc
index a5bc0a8..7db6c489c 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_browsertest.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_browsertest.cc
@@ -72,8 +72,40 @@
     run_loop.Run();
   }
 
+  void ConfirmSensitiveEntryAccess(
+      const url::Origin& origin,
+      const content::PathInfo& path_info,
+      HandleType handle_type,
+      UserAction user_action,
+      content::GlobalRenderFrameHostId frame_id,
+      base::OnceCallback<void(SensitiveEntryResult)> callback) override {
+    confirm_sensitive_entry_access_ = true;
+    if (auto_abort_on_confirm_sensitive_entry_access_) {
+      std::move(callback).Run(SensitiveEntryResult::kAbort);
+      return;
+    }
+    ChromeFileSystemAccessPermissionContext::ConfirmSensitiveEntryAccess(
+        origin, path_info, handle_type, user_action, frame_id,
+        std::move(callback));
+  }
+
+  bool confirm_sensitive_entry_access() const {
+    return confirm_sensitive_entry_access_;
+  }
+
+  void set_auto_abort_on_confirm_sensitive_entry_access() {
+    auto_abort_on_confirm_sensitive_entry_access_ = true;
+  }
+
+  void reset() {
+    performed_after_write_checks_ = false;
+    confirm_sensitive_entry_access_ = false;
+  }
+
  private:
   bool performed_after_write_checks_ = false;
+  bool confirm_sensitive_entry_access_ = false;
+  bool auto_abort_on_confirm_sensitive_entry_access_ = false;
   base::OnceClosure quit_callback_;
 };
 
@@ -258,6 +290,77 @@
   ui::SelectFileDialog::SetFactory(nullptr);
 }
 
+// Tests that ConfirmSensitiveEntryAccess() is called by
+// 'FileSystemFileHandle.move()'.
+IN_PROC_BROWSER_TEST_F(
+    ChromeFileSystemAccessPermissionContextPrerenderingBrowserTest,
+    MoveFileAndConfirmSensitiveEntryAccess) {
+  const base::FilePath test_file = CreateTestFile("test.txt");
+  ui::SelectFileDialog::SetFactory(
+      std::make_unique<content::FakeSelectFileDialogFactory>(
+          std::vector<base::FilePath>{test_file}));
+
+  TestFileSystemAccessPermissionContext permission_context(
+      browser()->profile());
+  content::SetFileSystemAccessPermissionContext(browser()->profile(),
+                                                &permission_context);
+  FileSystemAccessPermissionRequestManager::FromWebContents(GetWebContents())
+      ->set_auto_response_for_test(permissions::PermissionAction::GRANTED);
+
+  // Initial navigation.
+  GURL initial_url = embedded_test_server()->GetURL("/empty.html");
+  ASSERT_NE(ui_test_utils::NavigateToURL(browser(), initial_url), nullptr);
+
+  // Expects no user interaction: showSaveFilePicker() automatically gets
+  // `test_file` from the fake file picker factory
+  // `FakeSelectFileDialogFactory` without the need for user interaction.
+  ASSERT_TRUE(ExecJs(GetWebContents(),
+                     R"(
+    var handle;
+    (async () =>{
+      handle = await self.showSaveFilePicker();
+    })()
+  )"));
+  EXPECT_EQ(test_file.BaseName().AsUTF8Unsafe(),
+            EvalJs(GetWebContents(), "handle.name"));
+  // Checks that PerformAfterWriteChecks() must not be called.
+  EXPECT_FALSE(permission_context.performed_after_write_checks());
+  // Checks that ConfirmSensitiveEntryAccess() is called within file picker,
+  // i.e. FileSystemAccessManagerImpl::DidChooseEntries.
+  EXPECT_TRUE(permission_context.confirm_sensitive_entry_access());
+
+  // Resets permission_context to receive new behavior.
+  permission_context.reset();
+
+  // Calling move() with '.swf' will trigger a SafeBrowsing check after calling
+  // `ConfirmSensitiveEntryAccess()`, which prompts the user to confirm saving
+  // such file.
+
+  // This line automatically aborts on calling ConfirmSensitiveEntryAccess() to
+  // bypass the SafeBrowsing dialog, as there is no way to accept the prompt
+  // in browser tests.
+  // Commenting this out will bring up the dialog and fail the test without a
+  // manual click.
+  permission_context.set_auto_abort_on_confirm_sensitive_entry_access();
+
+  EXPECT_THAT(
+      EvalJs(GetWebContents(),
+             R"(
+      handle.move("test.swf");
+  )"),
+      testing::Field(
+          &content::EvalJsResult::error,
+          testing::Eq(
+              "a JavaScript error: \"TypeError: Failed to execute 'move' on "
+              "'FileSystemFileHandle'\"\n")));
+  // Checks that ConfirmSensitiveEntryAccess() is called again to verify the
+  // move target file name.
+  EXPECT_TRUE(permission_context.confirm_sensitive_entry_access());
+
+  // Uninstall fake file picker factory.
+  ui::SelectFileDialog::SetFactory(nullptr);
+}
+
 class FileSystemChromeAppTest : public extensions::PlatformAppBrowserTest {
  public:
   FileSystemChromeAppTest() {
diff --git a/content/browser/file_system_access/file_system_access_file_handle_impl_unittest.cc b/content/browser/file_system_access/file_system_access_file_handle_impl_unittest.cc
index e96bd8c..174140ca 100644
--- a/content/browser/file_system_access/file_system_access_file_handle_impl_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_file_handle_impl_unittest.cc
@@ -6,6 +6,7 @@
 
 #include <limits>
 #include <memory>
+#include <optional>
 #include <string>
 #include <utility>
 #include <vector>
@@ -16,6 +17,7 @@
 #include "base/files/file_util.h"
 #include "base/files/scoped_temp_dir.h"
 #include "base/memory/scoped_refptr.h"
+#include "base/strings/strcat.h"
 #include "base/strings/stringprintf.h"
 #include "base/task/sequenced_task_runner.h"
 #include "base/task/single_thread_task_runner.h"
@@ -67,6 +69,13 @@
 using blink::mojom::PermissionStatus;
 using storage::FileSystemURL;
 
+// Test fixture for FileSystemAccessFileHandleImpl.
+//
+// The permission context in this fixture is always null, so the relevant
+// permission checks are skipped.
+//
+// Tests with mock permission context are in
+// FileSystemAccessFileHandleImplMovePermissionsTest.
 class FileSystemAccessFileHandleImplTest : public testing::Test {
  public:
   FileSystemAccessFileHandleImplTest() = default;
@@ -244,6 +253,7 @@
   scoped_refptr<storage::MockQuotaManagerProxy> quota_manager_proxy_;
   scoped_refptr<storage::FileSystemContext> file_system_context_;
   scoped_refptr<ChromeBlobStorageContext> chrome_blob_context_;
+  testing::NiceMock<MockFileSystemAccessPermissionContext> permission_context_;
   scoped_refptr<FileSystemAccessManagerImpl> manager_;
 
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_browsertest.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_browsertest.cc
index a5bc0a8..7db6c489c 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_browsertest.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_browsertest.cc
@@ -72,8 +72,40 @@
     run_loop.Run();
   }
 
+  void ConfirmSensitiveEntryAccess(
+      const url::Origin& origin,
+      const content::PathInfo& path_info,
+      HandleType handle_type,
+      UserAction user_action,
+      content::GlobalRenderFrameHostId frame_id,
+      base::OnceCallback<void(SensitiveEntryResult)> callback) override {
+    confirm_sensitive_entry_access_ = true;
+    if (auto_abort_on_confirm_sensitive_entry_access_) {
+      std::move(callback).Run(SensitiveEntryResult::kAbort);
+      return;
+    }
+    ChromeFileSystemAccessPermissionContext::ConfirmSensitiveEntryAccess(
+        origin, path_info, handle_type, user_action, frame_id,
+        std::move(callback));
+  }
+
+  bool confirm_sensitive_entry_access() const {
+    return confirm_sensitive_entry_access_;
+  }
+
+  void set_auto_abort_on_confirm_sensitive_entry_access() {
+    auto_abort_on_confirm_sensitive_entry_access_ = true;
+  }
+
+  void reset() {
+    performed_after_write_checks_ = false;
+    confirm_sensitive_entry_access_ = false;
+  }
+
  private:
   bool performed_after_write_checks_ = false;
+  bool confirm_sensitive_entry_access_ = false;
+  bool auto_abort_on_confirm_sensitive_entry_access_ = false;
   base::OnceClosure quit_callback_;
 };
 
@@ -258,6 +290,77 @@
   ui::SelectFileDialog::SetFactory(nullptr);
 }
 
+// Tests that ConfirmSensitiveEntryAccess() is called by
+// 'FileSystemFileHandle.move()'.
+IN_PROC_BROWSER_TEST_F(
+    ChromeFileSystemAccessPermissionContextPrerenderingBrowserTest,
+    MoveFileAndConfirmSensitiveEntryAccess) {
+  const base::FilePath test_file = CreateTestFile("test.txt");
+  ui::SelectFileDialog::SetFactory(
+      std::make_unique<content::FakeSelectFileDialogFactory>(
+          std::vector<base::FilePath>{test_file}));
+
+  TestFileSystemAccessPermissionContext permission_context(
+      browser()->profile());
+  content::SetFileSystemAccessPermissionContext(browser()->profile(),
+                                                &permission_context);
+  FileSystemAccessPermissionRequestManager::FromWebContents(GetWebContents())
+      ->set_auto_response_for_test(permissions::PermissionAction::GRANTED);
+
+  // Initial navigation.
+  GURL initial_url = embedded_test_server()->GetURL("/empty.html");
+  ASSERT_NE(ui_test_utils::NavigateToURL(browser(), initial_url), nullptr);
+
+  // Expects no user interaction: showSaveFilePicker() automatically gets
+  // `test_file` from the fake file picker factory
+  // `FakeSelectFileDialogFactory` without the need for user interaction.
+  ASSERT_TRUE(ExecJs(GetWebContents(),
+                     R"(
+    var handle;
+    (async () =>{
+      handle = await self.showSaveFilePicker();
+    })()
+  )"));
+  EXPECT_EQ(test_file.BaseName().AsUTF8Unsafe(),
+            EvalJs(GetWebContents(), "handle.name"));
+  // Checks that PerformAfterWriteChecks() must not be called.
+  EXPECT_FALSE(permission_context.performed_after_write_checks());
+  // Checks that ConfirmSensitiveEntryAccess() is called within file picker,
+  // i.e. FileSystemAccessManagerImpl::DidChooseEntries.
+  EXPECT_TRUE(permission_context.confirm_sensitive_entry_access());
+
+  // Resets permission_context to receive new behavior.
+  permission_context.reset();
+
+  // Calling move() with '.swf' will trigger a SafeBrowsing check after calling
+  // `ConfirmSensitiveEntryAccess()`, which prompts the user to confirm saving
+  // such file.
+
+  // This line automatically aborts on calling ConfirmSensitiveEntryAccess() to
+  // bypass the SafeBrowsing dialog, as there is no way to accept the prompt
+  // in browser tests.
+  // Commenting this out will bring up the dialog and fail the test without a
+  // manual click.
+  permission_context.set_auto_abort_on_confirm_sensitive_entry_access();
+
+  EXPECT_THAT(
+      EvalJs(GetWebContents(),
+             R"(
+      handle.move("test.swf");
+  )"),
+      testing::Field(
+          &content::EvalJsResult::error,
+          testing::Eq(
+              "a JavaScript error: \"TypeError: Failed to execute 'move' on "
+              "'FileSystemFileHandle'\"\n")));
+  // Checks that ConfirmSensitiveEntryAccess() is called again to verify the
+  // move target file name.
+  EXPECT_TRUE(permission_context.confirm_sensitive_entry_access());
+
+  // Uninstall fake file picker factory.
+  ui::SelectFileDialog::SetFactory(nullptr);
+}
+
 class FileSystemChromeAppTest : public extensions::PlatformAppBrowserTest {
  public:
   FileSystemChromeAppTest() {
diff --git a/content/browser/file_system_access/file_system_access_file_handle_impl_unittest.cc b/content/browser/file_system_access/file_system_access_file_handle_impl_unittest.cc
index e96bd8c..174140ca 100644
--- a/content/browser/file_system_access/file_system_access_file_handle_impl_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_file_handle_impl_unittest.cc
@@ -6,6 +6,7 @@
 
 #include <limits>
 #include <memory>
+#include <optional>
 #include <string>
 #include <utility>
 #include <vector>
@@ -16,6 +17,7 @@
 #include "base/files/file_util.h"
 #include "base/files/scoped_temp_dir.h"
 #include "base/memory/scoped_refptr.h"
+#include "base/strings/strcat.h"
 #include "base/strings/stringprintf.h"
 #include "base/task/sequenced_task_runner.h"
 #include "base/task/single_thread_task_runner.h"
@@ -67,6 +69,13 @@
 using blink::mojom::PermissionStatus;
 using storage::FileSystemURL;
 
+// Test fixture for FileSystemAccessFileHandleImpl.
+//
+// The permission context in this fixture is always null, so the relevant
+// permission checks are skipped.
+//
+// Tests with mock permission context are in
+// FileSystemAccessFileHandleImplMovePermissionsTest.
 class FileSystemAccessFileHandleImplTest : public testing::Test {
  public:
   FileSystemAccessFileHandleImplTest() = default;
@@ -244,6 +253,7 @@
   scoped_refptr<storage::MockQuotaManagerProxy> quota_manager_proxy_;
   scoped_refptr<storage::FileSystemContext> file_system_context_;
   scoped_refptr<ChromeBlobStorageContext> chrome_blob_context_;
+  testing::NiceMock<MockFileSystemAccessPermissionContext> permission_context_;
   scoped_refptr<FileSystemAccessManagerImpl> manager_;
 
   raw_ptr<WebContents> web_contents_ = nullptr;
@@ -986,6 +996,13 @@
 }
 #endif  // BUILDFLAG(IS_MAC)
 
+struct MovePermissionsTestCase {
+  // Is there a file to be overwritten?
+  bool target_present;
+  // Does the site have user activation?
+  bool gesture_present;
+};
+
 // Uses a mock permission context to ensure the correct permission grant for the
 // target file (and parent, for renames) is used, since moves retrieve the
 // target's permission grant via GetSharedHandleStateForNonSandboxedPath() which
@@ -997,7 +1014,7 @@
 // access to the destination directory.
 class FileSystemAccessFileHandleImplMovePermissionsTest
     : public FileSystemAccessFileHandleImplTest,
-      public testing::WithParamInterface<std::tuple<bool, bool>> {
+      public testing::WithParamInterface<MovePermissionsTestCase> {
  public:
   void SetUp() override {
     base::CommandLine::ForCurrentProcess()->AppendSwitch(
@@ -1008,8 +1025,8 @@
     manager_->SetPermissionContextForTesting(&permission_context_);
   }
 
-  bool target_present() const { return std::get<0>(GetParam()); }
-  bool gesture_present() const { return std::get<1>(GetParam()); }
+  bool target_present() const { return GetParam().target_present; }
+  bool gesture_present() const { return GetParam().gesture_present; }
 
   std::pair<base::FilePath, base::FilePath> CreateSourceAndMaybeTarget() {
     base::FilePath source;
@@ -1053,6 +1070,15 @@
                     FileSystemAccessPermissionContext::HandleType::kFile,
                     FileSystemAccessPermissionContext::UserAction::kNone))
         .WillOnce(testing::Return(target_grant));
+    EXPECT_CALL(
+        permission_context_,
+        ConfirmSensitiveEntryAccess_(
+            origin, content::PathInfo(target),
+            FileSystemAccessPermissionContext::HandleType::kFile,
+            FileSystemAccessPermissionContext::UserAction::kSave,
+            web_contents_->GetPrimaryMainFrame()->GetGlobalId(), testing::_))
+        .WillOnce(base::test::RunOnceCallback<5>(
+            FileSystemAccessPermissionContext::SensitiveEntryResult::kAllowed));
 
     // These checks should only be called if the file is successfully moved.
 
@@ -1093,7 +1119,9 @@
       const base::FilePath& target,
       scoped_refptr<FixedFileSystemAccessPermissionGrant> target_grant,
       blink::mojom::FileSystemAccessStatus result,
-      bool expects_safe_name = true) {
+      bool expects_safe_name = true,
+      std::optional<FileSystemAccessPermissionContext::SensitiveEntryResult>
+          expected_sensitive_entry_result = std::nullopt) {
     auto source_handle =
         GetHandleWithPermissions(source, /*read_grant=*/allow_grant_,
                                  /*write_grant=*/allow_grant_);
@@ -1105,7 +1133,18 @@
 
     EXPECT_CALL(permission_context_,
                 IsFileTypeDangerous_(target_basename, origin))
-        .WillOnce(testing::Return(!expects_safe_name));
+        .WillRepeatedly(testing::Return(!expects_safe_name));
+    if (expected_sensitive_entry_result.has_value()) {
+      EXPECT_CALL(
+          permission_context_,
+          ConfirmSensitiveEntryAccess_(
+              origin, content::PathInfo(target),
+              FileSystemAccessPermissionContext::HandleType::kFile,
+              FileSystemAccessPermissionContext::UserAction::kSave,
+              web_contents_->GetPrimaryMainFrame()->GetGlobalId(), testing::_))
+          .WillOnce(
+              base::test::RunOnceCallback<5>(*expected_sensitive_entry_result));
+    }
     if (expects_safe_name) {
       // If safe name check has passed, it should be expected to get grants.
       EXPECT_CALL(permission_context_,
@@ -1156,6 +1195,15 @@
     EXPECT_CALL(permission_context_,
                 IsFileTypeDangerous_(target_basename, origin))
         .WillRepeatedly(testing::Return(false));
+    EXPECT_CALL(
+        permission_context_,
+        ConfirmSensitiveEntryAccess_(
+            origin, content::PathInfo(target),
+            FileSystemAccessPermissionContext::HandleType::kFile,
+            FileSystemAccessPermissionContext::UserAction::kSave,
+            web_contents_->GetPrimaryMainFrame()->GetGlobalId(), testing::_))
+        .WillOnce(base::test::RunOnceCallback<5>(
+            FileSystemAccessPermissionContext::SensitiveEntryResult::kAllowed));
 
     // These checks should only be called if the file is successfully moved.
     if (source != target) {
@@ -1195,7 +1243,9 @@
       const base::FilePath& target,
       scoped_refptr<FixedFileSystemAccessPermissionGrant> target_grant,
       blink::mojom::FileSystemAccessStatus result,
-      bool expects_safe_name = true) {
+      bool expects_safe_name = true,
+      std::optional<FileSystemAccessPermissionContext::SensitiveEntryResult>
+          expected_sensitive_entry_result = std::nullopt) {
     base::FilePath target_parent = target.DirName();
     // The site has write access to the destination directory.
     auto dest_dir_handle = GetDirectoryHandleWithPermissions(
@@ -1208,7 +1258,18 @@
 
     EXPECT_CALL(permission_context_,
                 IsFileTypeDangerous_(target_basename, origin))
-        .WillOnce(testing::Return(!expects_safe_name));
+        .WillRepeatedly(testing::Return(!expects_safe_name));
+    if (expected_sensitive_entry_result.has_value()) {
+      EXPECT_CALL(
+          permission_context_,
+          ConfirmSensitiveEntryAccess_(
+              origin, content::PathInfo(target),
+              FileSystemAccessPermissionContext::HandleType::kFile,
+              FileSystemAccessPermissionContext::UserAction::kSave,
+              web_contents_->GetPrimaryMainFrame()->GetGlobalId(), testing::_))
+          .WillOnce(
+              base::test::RunOnceCallback<5>(*expected_sensitive_entry_result));
+    }
 
     // No after-write checks needed since the file should not have been moved.
 
@@ -1230,6 +1291,24 @@
       permission_context_;
 };
 
+INSTANTIATE_TEST_SUITE_P(
+    All,
+    FileSystemAccessFileHandleImplMovePermissionsTest,
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

A vulnerability in FileSystemFileHandle.move bypasses download restrictions.

Steps to reproduce the problem

On any HTTPS page, try the following JS code. It should correctly display a file save dialog prompt, asking you to save a file named hello.png. When you click “OK,” an evil.bat file will actually be generated in the actual directory.

try {
    // 1. Call showSaveFilePicker()
    const fileHandle = await window.showSaveFilePicker({
        suggestedName: `hello`,
        startIn: "desktop",
        // Filename prompt visible to the user
        types: [{
            description: [`ImageFile`],
            accept: {
                'image/png': ['.png']
            },
        }, ],
    });
    // 2. Get the FileSystemWritableFileStream
    const writableStream = await fileHandle.createWritable();
    // 3.1 Write malicious file data to download, saved as exe
    // const evilFile = await fetch(`evil.exe`)
    // await evilFile.body.pipeTo(writableStream)
    // fileHandle.move("evil.exe")
    // 3.2 Or write custom content and close the stream, e.g., .bat
    await writableStream.write(new TextEncoder().encode(`calc`));
    await writableStream.close();
    fileHandle.move("evil.bat")
} catch (error) {
    console.log(error)
}

Problem Description

In Chrome versions 112.0.5580.0 to 137.0.7115.0 (the latest Cannry release), after a user confirms a save via window.showSaveFilePicker, invoking the move method on the resulting FileSystemFileHandle object can download certain sensitive files under attacker control into “well known directories” ([“desktop”, “documents”, “downloads”, “music”, “pictures”, “videos”]). If the “desktop” is selected and the user does not change the save location, downloading a file like “Google Chrome.exe” or a similarly deceptive file designed to mimic a desktop shortcut could have a real impact on the user.

After a period of investigation, I have seemingly pinpointed the original commit that introduced the issue. By testing various versions of Chromium on the dev channel, I obtained the following results (The following versions were tested on Winx64.):

  • 112.0.5572.0 – Not working, position: 1099442
  • 112.0.5580.0 – Working, position: 1101520
  • 112.0.5592.0 – Working, position: 1104296
  • 112.0.5608.0 – Working, position: 1107400

This localizes the problematic change to somewhere between positions 1101520 and 1099442. By searching the history of changes regarding file_system_access for versions 112.0.5572.0 to 112.0.5580.0, I examined the URL:
https://source.chromium.org/chromium/chromium/src/+/refs/tags/112.0.5580.0:third_party/blink/renderer/modules/file_system_access/;bpv=1;bpt=0

I found several suspicious related commit histories:

6ad1f40 [email protected] 2023-02-04 05:07 FSA: Re-use the same task runner for each SAH.write()
c735f76 [email protected] 2023-01-25 11:24 FSA: Point SyncAccessHandle idl comment to spec
02799cd [email protected] 2023-01-19 00:30 FSA: Implement a cursor for SyncAccessHandles
7cd323e [email protected] 2023-01-18 02:58 [FSA] Clean up the deprecated code for async SyncAccessHandle interface.
56c5a8d [email protected] 2023-01-18 01:18 [FSA] Remove enterprise policy for deprecating async SyncAccessHandle interface.

Among these, the most likely commit is:

6ad1f40 [email protected] 2023-02-04 05:07

FSA: Re-use the same task runner for each SAH.write()

Currently, we create a new task runner for every write operation. This
is wasteful and hurts performance.

Also removes a task runner member that was used for async operations,
back when that was a thing.

Bug: https://bugs.chromium.org/p/chromium/issues/detail?id=1406246
Change-Id: I94ff69f023f0db60724659b0236710a09e8f132b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4219170
Reviewed-by: Daseul Lee <[email protected]>
Commit-Queue: Austin Sullivan <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1101185}

This commit seems to be the most likely cause of the issue.

Additional Comments

The following versions were tested on Winx64.

  • 112.0.5572.0 – Not working, position: 1099442

  • 112.0.5580.0 – Working, position: 1101520

  • 112.0.5592.0 – Working, position: 1104296

  • 112.0.5608.0 – Working, position: 1107400

  • 135.0.7049.86 works

  • 134.0.6998.89 works

Summary

A vulnerability in FileSystemFileHandle.move bypasses download restrictions.

Custom Questions

Type of crash:

Crash state:

Reporter credit:

Additional Data

Category: Security
Chrome Channel: Canary
Regression: Yes

View on issue tracker