Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect reference resolution in FileSystem
DescriptionIncorrect reference resolution in FileSystem
ComponentFileSystem
Bug ClassLogic Error
Tracker497111188
Fix commit9ab0e2155012 (chromium/src) +123/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
TEST_F
content/browser/file_system_access/file_system_access_directory_handle_impl_unittest.cc
modified
for
content/browser/file_system_access/file_system_access_directory_handle_impl_unittest.cc
modified
if
content/browser/file_system_access/file_system_access_directory_handle_impl_unittest.cc
modified

Files Changed

  • content/browser/file_system_access/file_system_access_directory_handle_impl.cc
  • content/browser/file_system_access/file_system_access_directory_handle_impl.h
  • content/browser/file_system_access/file_system_access_directory_handle_impl_unittest.cc
From 9ab0e2155012b77f284a6c1f88df50fbcfd8eaa4 Mon Sep 17 00:00:00 2001
From: Eriko Kurimoto <elkurin@chromium.org>
Date: Mon, 03 Aug 2026 20:55:18 -0700
Subject: [PATCH] Prevent path traversal via content-URIs in FileSystemAccessDirectoryHandleImpl

GetFile(), GetDirectory(), and RemoveEntry() in
FileSystemAccessDirectoryHandleImpl did not validate that the child
basename was a safe path component when the handle was backed by a
content-URI, because they returned early before reaching GetChildURL() (which performs the check).

This allowed path traversal sequences (like "..") to bypass safety checks
on Android.

This CL adds IsSafePathComponent() checks at the entry points of these
methods before the content-URI early-return blocks.

Bug: 497111188
Change-Id: I1a8cb659953ec64c0dbeeed7e937777975d1c948
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8172946
Auto-Submit: Eriko Kurimoto <elkurin@chromium.org>
Commit-Queue: Mingyu Lei <leimy@chromium.org>
Reviewed-by: Mingyu Lei <leimy@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1673079}
---

diff --git a/content/browser/file_system_access/file_system_access_directory_handle_impl.cc b/content/browser/file_system_access/file_system_access_directory_handle_impl.cc
index 2de16b33..74be365e 100644
--- a/content/browser/file_system_access/file_system_access_directory_handle_impl.cc
+++ b/content/browser/file_system_access/file_system_access_directory_handle_impl.cc
@@ -140,6 +140,13 @@
   // and create the document. DidGetFile() will then update the child path
   // before creating the returned handle.
   if (url().virtual_path().IsContentUri()) {
+    if (!IsSafePathComponent(basename)) {
+      std::move(callback).Run(
+          file_system_access_error::FromStatus(
+              FileSystemAccessStatus::kInvalidArgument, "Name is not allowed."),
+          mojo::NullRemote());
+      return;
+    }
     std::string mime_type;
     if (!net::GetWellKnownMimeTypeFromFile(base::FilePath(basename),
                                            &mime_type)) {
@@ -281,6 +288,13 @@
   // and create the document. DidGetDirectory() will then update the child path
   // before creating the returned handle.
   if (url().virtual_path().IsContentUri()) {
+    if (!IsSafePathComponent(basename)) {
+      std::move(callback).Run(
+          file_system_access_error::FromStatus(
+              FileSystemAccessStatus::kInvalidArgument, "Name is not allowed."),
+          mojo::NullRemote());
+      return;
+    }
     base::ThreadPool::PostTaskAndReplyWithResult(
         FROM_HERE, {base::MayBlock(), base::TaskPriority::USER_VISIBLE},
         base::BindOnce(&base::ContentUriGetChildDocumentOrQuery,
@@ -458,6 +472,11 @@
 #if BUILDFLAG(IS_ANDROID)
   // Lookup content-URI by display-name.
   if (url().virtual_path().IsContentUri()) {
+    if (!IsSafePathComponent(basename)) {
+      std::move(callback).Run(file_system_access_error::FromStatus(
+          FileSystemAccessStatus::kInvalidArgument, "Name is not allowed."));
+      return;
+    }
     base::ThreadPool::PostTaskAndReplyWithResult(
         FROM_HERE, {base::MayBlock(), base::TaskPriority::USER_VISIBLE},
         base::BindOnce(&base::ContentUriGetChildDocumentOrQuery,
@@ -913,18 +932,23 @@
                                               more_batches_are_expected);
 }
 
+bool FileSystemAccessDirectoryHandleImpl::IsSafePathComponent(
+    const std::string& basename) const {
+  return manager()->IsSafePathComponent(url().type(), basename);
+}
+
 blink::mojom::FileSystemAccessErrorPtr
 FileSystemAccessDirectoryHandleImpl::GetChildURL(
     const std::string& basename,
     storage::FileSystemURL* result) {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
 
-  const storage::FileSystemURL& parent = url();
-  if (!manager()->IsSafePathComponent(parent.type(), basename)) {
+  if (!IsSafePathComponent(basename)) {
     return file_system_access_error::FromStatus(
         FileSystemAccessStatus::kInvalidArgument, "Name is not allowed.");
   }
 
+  const storage::FileSystemURL& parent = url();
 #if BUILDFLAG(IS_ANDROID)
   base::FilePath child_path =
       parent.virtual_path().IsContentUri()
diff --git a/content/browser/file_system_access/file_system_access_directory_handle_impl.h b/content/browser/file_system_access/file_system_access_directory_handle_impl.h
index 39f71540..f0770dc 100644
--- a/content/browser/file_system_access/file_system_access_directory_handle_impl.h
+++ b/content/browser/file_system_access/file_system_access_directory_handle_impl.h
@@ -180,6 +180,8 @@
           std::vector<blink::mojom::FileSystemAccessEntryPtr>)> final_callback,
       std::vector<blink::mojom::FileSystemAccessEntryPtr> entries);
 
+  bool IsSafePathComponent(const std::string& basename) const;
+
   storage::FileSystemURL CreateChildURL(const base::FilePath& child_path);
 
   // Helper to create a blink::mojom::FileSystemAccessEntry struct.
diff --git a/content/browser/file_system_access/file_system_access_directory_handle_impl_unittest.cc b/content/browser/file_system_access/file_system_access_directory_handle_impl_unittest.cc
index 1874eb8c..7556b8d7 100644
--- a/content/browser/file_system_access/file_system_access_directory_handle_impl_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_directory_handle_impl_unittest.cc
@@ -42,6 +42,10 @@
 #include "third_party/blink/public/common/storage_key/storage_key.h"
 #include "url/gurl.h"
 
+#if BUILDFLAG(IS_ANDROID)
+#include "base/test/android/content_uri_test_utils.h"
+#endif
+
 namespace content {
 namespace {
 using storage::FileSystemURL;
@@ -471,6 +475,95 @@
   EXPECT_TRUE(entries.empty());
 }
 
+TEST_F(FileSystemAccessDirectoryHandleImplTest, InvalidPathComponent) {
+  constexpr const char* kInvalidNames[] = {"", "..", "../a", "a/b", "a\\b"};
+  for (const char* name : kInvalidNames) {
+    SCOPED_TRACE(name);
+    {
+      base::test::TestFuture<
+          blink::mojom::FileSystemAccessErrorPtr,
+          mojo::PendingRemote<blink::mojom::FileSystemAccessFileHandle>>
+          future;
+      handle_->GetFile(name, /*create=*/false, future.GetCallback());
+      EXPECT_EQ(future.Get<0>()->status,
+                blink::mojom::FileSystemAccessStatus::kInvalidArgument);
+      EXPECT_FALSE(future.Get<1>().is_valid());
+    }
+    {
+      base::test::TestFuture<
+          blink::mojom::FileSystemAccessErrorPtr,
+          mojo::PendingRemote<blink::mojom::FileSystemAccessDirectoryHandle>>
+          future;
+      handle_->GetDirectory(name, /*create=*/false, future.GetCallback());
+      EXPECT_EQ(future.Get<0>()->status,
+                blink::mojom::FileSystemAccessStatus::kInvalidArgument);
+      EXPECT_FALSE(future.Get<1>().is_valid());
+    }
+    {
+      base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr> future;
+      handle_->RemoveEntry(name, /*recurse=*/false, future.GetCallback());
+      EXPECT_EQ(future.Get()->status,
+                blink::mojom::FileSystemAccessStatus::kInvalidArgument);
+    }
+  }
+}
+
+#if BUILDFLAG(IS_ANDROID)
+// Verifies that `GetFile`, `GetDirectory` and `RemoveEntry` reject names that
+// are not valid path components when the directory handle is backed by a
+// content URI.
+TEST_F(FileSystemAccessDirectoryHandleImplTest, ContentUri_InvalidName) {
+  std::optional<base::FilePath> content_uri =
+      base::test::android::GetInMemoryContentTreeUriFromCacheDirDirectory(
+          dir_.GetPath());
+  EXPECT_TRUE(content_uri.has_value());
+  if (!content_uri.has_value()) {
+    return;
+  }
+  EXPECT_TRUE(content_uri->IsContentUri());
+  if (!content_uri->IsContentUri()) {
+    return;
+  }
+  auto handle =
+      GetHandleWithPermissions(*content_uri, /*read=*/true, /*write=*/true);
+  EXPECT_TRUE(handle);
+  if (!handle) {
+    return;
+  }
+
+  constexpr const char* kInvalidNames[] = {"", "..", "../a", "a/b", "a\\b"};
+  for (const char* name : kInvalidNames) {
+    SCOPED_TRACE(name);
+    {
+      base::test::TestFuture<
+          blink::mojom::FileSystemAccessErrorPtr,
+          mojo::PendingRemote<blink::mojom::FileSystemAccessFileHandle>>
+          future;
+      handle->GetFile(name, /*create=*/false, future.GetCallback());
+      EXPECT_EQ(future.Get<0>()->status,
+                blink::mojom::FileSystemAccessStatus::kInvalidArgument);
+      EXPECT_FALSE(future.Get<1>().is_valid());
+    }
+    {
+      base::test::TestFuture<
+          blink::mojom::FileSystemAccessErrorPtr,
+          mojo::PendingRemote<blink::mojom::FileSystemAccessDirectoryHandle>>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/file_system_access/file_system_access_directory_handle_impl_unittest.cc b/content/browser/file_system_access/file_system_access_directory_handle_impl_unittest.cc
index 1874eb8c..7556b8d7 100644
--- a/content/browser/file_system_access/file_system_access_directory_handle_impl_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_directory_handle_impl_unittest.cc
@@ -42,6 +42,10 @@
 #include "third_party/blink/public/common/storage_key/storage_key.h"
 #include "url/gurl.h"
 
+#if BUILDFLAG(IS_ANDROID)
+#include "base/test/android/content_uri_test_utils.h"
+#endif
+
 namespace content {
 namespace {
 using storage::FileSystemURL;
@@ -471,6 +475,95 @@
   EXPECT_TRUE(entries.empty());
 }
 
+TEST_F(FileSystemAccessDirectoryHandleImplTest, InvalidPathComponent) {
+  constexpr const char* kInvalidNames[] = {"", "..", "../a", "a/b", "a\\b"};
+  for (const char* name : kInvalidNames) {
+    SCOPED_TRACE(name);
+    {
+      base::test::TestFuture<
+          blink::mojom::FileSystemAccessErrorPtr,
+          mojo::PendingRemote<blink::mojom::FileSystemAccessFileHandle>>
+          future;
+      handle_->GetFile(name, /*create=*/false, future.GetCallback());
+      EXPECT_EQ(future.Get<0>()->status,
+                blink::mojom::FileSystemAccessStatus::kInvalidArgument);
+      EXPECT_FALSE(future.Get<1>().is_valid());
+    }
+    {
+      base::test::TestFuture<
+          blink::mojom::FileSystemAccessErrorPtr,
+          mojo::PendingRemote<blink::mojom::FileSystemAccessDirectoryHandle>>
+          future;
+      handle_->GetDirectory(name, /*create=*/false, future.GetCallback());
+      EXPECT_EQ(future.Get<0>()->status,
+                blink::mojom::FileSystemAccessStatus::kInvalidArgument);
+      EXPECT_FALSE(future.Get<1>().is_valid());
+    }
+    {
+      base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr> future;
+      handle_->RemoveEntry(name, /*recurse=*/false, future.GetCallback());
+      EXPECT_EQ(future.Get()->status,
+                blink::mojom::FileSystemAccessStatus::kInvalidArgument);
+    }
+  }
+}
+
+#if BUILDFLAG(IS_ANDROID)
+// Verifies that `GetFile`, `GetDirectory` and `RemoveEntry` reject names that
+// are not valid path components when the directory handle is backed by a
+// content URI.
+TEST_F(FileSystemAccessDirectoryHandleImplTest, ContentUri_InvalidName) {
+  std::optional<base::FilePath> content_uri =
+      base::test::android::GetInMemoryContentTreeUriFromCacheDirDirectory(
+          dir_.GetPath());
+  EXPECT_TRUE(content_uri.has_value());
+  if (!content_uri.has_value()) {
+    return;
+  }
+  EXPECT_TRUE(content_uri->IsContentUri());
+  if (!content_uri->IsContentUri()) {
+    return;
+  }
+  auto handle =
+      GetHandleWithPermissions(*content_uri, /*read=*/true, /*write=*/true);
+  EXPECT_TRUE(handle);
+  if (!handle) {
+    return;
+  }
+
+  constexpr const char* kInvalidNames[] = {"", "..", "../a", "a/b", "a\\b"};
+  for (const char* name : kInvalidNames) {
+    SCOPED_TRACE(name);
+    {
+      base::test::TestFuture<
+          blink::mojom::FileSystemAccessErrorPtr,
+          mojo::PendingRemote<blink::mojom::FileSystemAccessFileHandle>>
+          future;
+      handle->GetFile(name, /*create=*/false, future.GetCallback());
+      EXPECT_EQ(future.Get<0>()->status,
+                blink::mojom::FileSystemAccessStatus::kInvalidArgument);
+      EXPECT_FALSE(future.Get<1>().is_valid());
+    }
+    {
+      base::test::TestFuture<
+          blink::mojom::FileSystemAccessErrorPtr,
+          mojo::PendingRemote<blink::mojom::FileSystemAccessDirectoryHandle>>
+          future;
+      handle->GetDirectory(name, /*create=*/false, future.GetCallback());
+      EXPECT_EQ(future.Get<0>()->status,
+                blink::mojom::FileSystemAccessStatus::kInvalidArgument);
+      EXPECT_FALSE(future.Get<1>().is_valid());
+    }
+    {
+      base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr> future;
+      handle->RemoveEntry(name, /*recurse=*/false, future.GetCallback());
+      EXPECT_EQ(future.Get()->status,
+                blink::mojom::FileSystemAccessStatus::kInvalidArgument);
+    }
+  }
+}
+#endif  // BUILDFLAG(IS_ANDROID)
+
 // Tests for `FileSystemAccessDirectoryHandleImpl::Remove()`.
 class FileSystemAccessDirectoryHandleImplRemoveTest
     : public FileSystemAccessDirectoryHandleImplPermissionTestBase,
Loading diff…

Original Bug Report

reported by rj...@google.com

Potential path validation bypass in FileSystemAccess API for Android Content URIs

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: The File System Access API on Android fails to validate the child entry name (basename) when interacting with Content URI-backed directories. This potential flaw allows a malicious renderer to pass path traversal sequences directly to external Android DocumentsProviders. If the provider lacks its own sanitization, this bypasses directory scoping guarantees and escapes the sandbox.

Affected files:

  • content/browser/file_system_access/file_system_access_directory_handle_impl.cc

Estimated timestamp from git blame: 2025-03-17

Summary There is a potential path validation bypass in the File System Access API implementation on Android. When a renderer requests a file, directory, or deletion using a basename, the browser-side implementation is supposed to validate this component using manager()->IsSafePathComponent(...) to ensure it does not contain dangerous characters or traversal sequences (e.g., .., /, \). However, on Android, this validation is skipped for Content URIs, and the unvalidated string is passed directly via JNI to Android’s DocumentsProvider IPC.

Technical Analysis In content/browser/file_system_access/file_system_access_directory_handle_impl.cc, the methods GetFile, GetDirectory, and RemoveEntry check if the directory handle’s path is a Content URI.

// content/browser/file_system_access/file_system_access_directory_handle_impl.cc
void FileSystemAccessDirectoryHandleImpl::GetFile(...) {
#if BUILDFLAG(IS_ANDROID)
  if (url().virtual_path().IsContentUri()) {
    // ...
    base::ThreadPool::PostTaskAndReplyWithResult(
        FROM_HERE, {base::MayBlock(), base::TaskPriority::USER_VISIBLE},
        base::BindOnce(&base::ContentUriGetChildDocumentOrQuery,
                       url().virtual_path(), basename, mime_type,
                       /*is_directory=*/false, create),
        // ...
    return; // <-- BYPASS: Early return skips validation
  }
#endif

  storage::FileSystemURL child_url;
  blink::mojom::FileSystemAccessErrorPtr get_child_url_result =
      GetChildURL(basename, &child_url); // <-- VALIDATION OCCURS HERE
  // ...
}

When url().virtual_path().IsContentUri() is true, the code immediately posts a task to the ThreadPool and returns. It never reaches the subsequent call to GetChildURL(basename, &child_url), which is where the critical validation occurs via manager()->IsSafePathComponent(...).

By bypassing this check, a compromised renderer (or even standard JavaScript) can pass arbitrary strings as the basename. This string flows through base::ContentUriGetChildDocumentOrQuery into Java (ContentUriUtils.java), where it is eventually passed as the displayName parameter to DocumentsContract.createDocument(resolver, parentUri, mimeType, displayName).

While some system-provided DocumentsProviders may sanitize inputs internally, relying on external third-party providers (such as cloud storage apps or custom file managers) to handle path traversal sequences safely is a “confused deputy” risk that breaks the API’s fundamental directory scoping guarantees.

Suggested Steps to Reproduce (Note: These are suggested/potential steps as our tooling agent does not currently have the ability to run code and verify the exploit end-to-end.)

  1. An attacker hosts a malicious webpage that invokes await self.showDirectoryPicker().
  2. The user is prompted and grants access to an Android directory backed by a Content URI (e.g., an external SD card or a third-party DocumentProvider).
  3. The webpage receives the FileSystemDirectoryHandle.
  4. The webpage executes await dirHandle.getFileHandle('../malicious_file', {create: true}).
  5. The ../malicious_file payload is sent unvalidated to the browser, bypassing IsSafePathComponent, and is passed directly to the external DocumentsProvider.
  6. If the provider is vulnerable, it creates a file outside the user-approved directory scope.

Suggested Fix The manager()->IsSafePathComponent(...) check should be decoupled from GetChildURL() or explicitly called at the very beginning of GetFile, GetDirectory, and RemoveEntry before the #if BUILDFLAG(IS_ANDROID) Content URI early-return blocks.

Evaluated with Chrome root at commit: 876d480da1f794d87813cfa2e6ff4fcf9771e939


Results from 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