CVE-2026-78959
Overview
Files Changed
chrome/browser/file_system_access/chrome_file_system_access_permission_context.ccchrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
Patch
From 1e7fffffd78aadb5e6edd31293ceb1823d074540 Mon Sep 17 00:00:00 2001 From: Anna Sato <[email protected]> Date: Thu, 23 Jul 2026 22:13:54 -0700 Subject: [PATCH] [FSA] Downgrade case-variant descendants in NotifyEntryRemoved NotifyEntryRemoved downgrades read grants for the removed entry and its descendants, but FilePath::IsParent() compares path components case-sensitively. On case-insensitive filesystems, native pickers can return paths for the same on-disk location that differ only in case, so a descendant grant stored with different casing is missed by the descendant walk and left granted after a recursive remove(). Extend the descendant predicate to also match component-wise using FilePath::CompareEqualIgnoreCase() so such grants are downgraded together with the directory. This is harmless on case-sensitive filesystems where it can only cause an extra re-prompt. TAG=agy CONV=22e50b50-6195-44a3-8e63-8d7e59c76184 Bug: 518084889 Change-Id: I2e2a9a34a8179f613f849fdea2918d7083c25b22 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8029721 Commit-Queue: Anna Sato <[email protected]> Reviewed-by: Ming-Ying Chung <[email protected]> Cr-Commit-Position: refs/heads/main@{#1667647} --- diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc index d16db9f9..f07c02e 100644 --- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc +++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc @@ -681,6 +681,31 @@ return true; } +// Returns true if `child_path` is the same as or a descendant of +// `parent_path`, ignoring case differences. Unlike +// `base::FilePath::IsParent()`, this handles case-variant paths returned by +// native pickers on case-insensitive filesystems. +bool IsPathOrDescendantIgnoreCase( + const base::FilePath& parent_path, + const std::vector<base::FilePath::StringType>& parent_components, + const base::FilePath& child_path) { + // Fast path: Exact match or case-sensitive parent match. + if (child_path == parent_path || parent_path.IsParent(child_path)) { + return true; + } + + const std::vector<base::FilePath::StringType> child_components = + child_path.GetComponents(); + if (parent_components.empty() || + parent_components.size() > child_components.size()) { + return false; + } + + return std::equal(parent_components.begin(), parent_components.end(), + child_components.begin(), + base::FilePath::CompareEqualIgnoreCase); +} + #if BUILDFLAG(SAFE_BROWSING_AVAILABLE) void DoSafeBrowsingCheckOnUIThread( content::GlobalRenderFrameHostId frame_id, @@ -2689,8 +2714,11 @@ return; } + const std::vector<base::FilePath::StringType> removed_components = + path.path.GetComponents(); auto is_path_or_descendant = [&](const base::FilePath& file_path) { - return file_path == path.path || path.path.IsParent(file_path); + return IsPathOrDescendantIgnoreCase(path.path, removed_components, + file_path); }; bool updated = false; diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc index 50d7a6d9..73250979 100644 --- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc +++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc @@ -3771,6 +3771,78 @@ PermissionStatus::ASK); } +// Tests that calling NotifyEntryRemoved with a directory path also revokes +// read permission grants for descendants whose stored path differs only in +// case from the removed directory. Native file pickers on case-insensitive +// filesystems can return such case-variant paths for the same on-disk entry. +TEST_F(ChromeFileSystemAccessPermissionContextTest, + NotifyEntryRemoved_RecursiveDir_CaseInsensitiveDescendantDowngraded) { + base::test::ScopedFeatureList feature_list; + feature_list.InitAndEnableFeature( + blink::features::kFileSystemAccessRevokeReadOnRemove); + FileSystemAccessPermissionRequestManager::FromWebContents(web_contents()) + ->set_auto_response_for_test(PermissionAction::GRANTED); + + // Set up a directory path and a child file path that differ only in the + // case of one component. + const auto dir_info = PathInfo(FILE_PATH_LITERAL("/foo/project")); + const auto file_info = + PathInfo(FILE_PATH_LITERAL("/foo/Project/config.json")); + const auto sibling_info = + PathInfo(FILE_PATH_LITERAL("/foo/projects/config.json")); + + // Grant a standalone read permission for the case-variant child file. + auto file_read_grant = permission_context()->GetReadPermissionGrant( + kTestOrigin, file_info, HandleType::kFile, UserAction::kOpen); + ASSERT_EQ(file_read_grant->GetStatus(), PermissionStatus::GRANTED); + + // Grant a standalone read permission for an unrelated sibling whose path + // shares a case-insensitive prefix string but is not actually a descendant. + auto sibling_read_grant = permission_context()->GetReadPermissionGrant( + kTestOrigin, sibling_info, HandleType::kFile, UserAction::kOpen); + ASSERT_EQ(sibling_read_grant->GetStatus(), PermissionStatus::GRANTED); + + // Grant read and write permission to the directory. + auto dir_read_grant = permission_context()->GetReadPermissionGrant( + kTestOrigin, dir_info, HandleType::kDirectory, UserAction::kOpen); + { + base::test::TestFuture<PermissionRequestOutcome> f; + dir_read_grant->RequestPermission( + frame_id(), UserActivationState::kNotRequired, f.GetCallback()); + ASSERT_EQ(f.Get(), PermissionRequestOutcome::kUserGranted); + } + auto dir_write_grant = permission_context()->GetWritePermissionGrant( + kTestOrigin, dir_info, HandleType::kDirectory, UserAction::kOpen); + { + base::test::TestFuture<PermissionRequestOutcome> f; + dir_write_grant->RequestPermission( + frame_id(), UserActivationState::kNotRequired, f.GetCallback()); + ASSERT_EQ(f.Get(), PermissionRequestOutcome::kUserGranted); + } + ASSERT_EQ(dir_read_grant->GetStatus(), PermissionStatus::GRANTED); + ASSERT_EQ(dir_write_grant->GetStatus(), PermissionStatus::GRANTED); + + // Revoke permissions for the directory. This represents a recursive removal + // of the directory. + permission_context()->NotifyEntryRemoved(kTestOrigin, dir_info); + + // Verify that the directory's own read permission is downgraded. + EXPECT_EQ(dir_read_grant->GetStatus(), PermissionStatus::DENIED); + EXPECT_TRUE(permission_context()->IsPathInDowngradedReadPathsForTesting( + kTestOrigin, dir_info.path)); + + // Verify that the case-variant descendant file's read permission is also + // downgraded. + EXPECT_EQ(file_read_grant->GetStatus(), PermissionStatus::DENIED); + EXPECT_TRUE(permission_context()->IsPathInDowngradedReadPathsForTesting( + kTestOrigin, file_info.path)); + + // Verify that the unrelated sibling is not affected. + EXPECT_EQ(sibling_read_grant->GetStatus(), PermissionStatus::GRANTED); + EXPECT_FALSE(permission_context()->IsPathInDowngradedReadPathsForTesting( + kTestOrigin, sibling_info.path)); +} + // Tests that moving a file to a destination with a pre-existing permission // grant works correctly. TEST_F(ChromeFileSystemAccessPermissionContextTest,
Regression Test / PoC
diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
index 50d7a6d9..73250979 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
@@ -3771,6 +3771,78 @@
PermissionStatus::ASK);
}
+// Tests that calling NotifyEntryRemoved with a directory path also revokes
+// read permission grants for descendants whose stored path differs only in
+// case from the removed directory. Native file pickers on case-insensitive
+// filesystems can return such case-variant paths for the same on-disk entry.
+TEST_F(ChromeFileSystemAccessPermissionContextTest,
+ NotifyEntryRemoved_RecursiveDir_CaseInsensitiveDescendantDowngraded) {
+ base::test::ScopedFeatureList feature_list;
+ feature_list.InitAndEnableFeature(
+ blink::features::kFileSystemAccessRevokeReadOnRemove);
+ FileSystemAccessPermissionRequestManager::FromWebContents(web_contents())
+ ->set_auto_response_for_test(PermissionAction::GRANTED);
+
+ // Set up a directory path and a child file path that differ only in the
+ // case of one component.
+ const auto dir_info = PathInfo(FILE_PATH_LITERAL("/foo/project"));
+ const auto file_info =
+ PathInfo(FILE_PATH_LITERAL("/foo/Project/config.json"));
+ const auto sibling_info =
+ PathInfo(FILE_PATH_LITERAL("/foo/projects/config.json"));
+
+ // Grant a standalone read permission for the case-variant child file.
+ auto file_read_grant = permission_context()->GetReadPermissionGrant(
+ kTestOrigin, file_info, HandleType::kFile, UserAction::kOpen);
+ ASSERT_EQ(file_read_grant->GetStatus(), PermissionStatus::GRANTED);
+
+ // Grant a standalone read permission for an unrelated sibling whose path
+ // shares a case-insensitive prefix string but is not actually a descendant.
+ auto sibling_read_grant = permission_context()->GetReadPermissionGrant(
+ kTestOrigin, sibling_info, HandleType::kFile, UserAction::kOpen);
+ ASSERT_EQ(sibling_read_grant->GetStatus(), PermissionStatus::GRANTED);
+
+ // Grant read and write permission to the directory.
+ auto dir_read_grant = permission_context()->GetReadPermissionGrant(
+ kTestOrigin, dir_info, HandleType::kDirectory, UserAction::kOpen);
+ {
+ base::test::TestFuture<PermissionRequestOutcome> f;
+ dir_read_grant->RequestPermission(
+ frame_id(), UserActivationState::kNotRequired, f.GetCallback());
+ ASSERT_EQ(f.Get(), PermissionRequestOutcome::kUserGranted);
+ }
+ auto dir_write_grant = permission_context()->GetWritePermissionGrant(
+ kTestOrigin, dir_info, HandleType::kDirectory, UserAction::kOpen);
+ {
+ base::test::TestFuture<PermissionRequestOutcome> f;
+ dir_write_grant->RequestPermission(
+ frame_id(), UserActivationState::kNotRequired, f.GetCallback());
+ ASSERT_EQ(f.Get(), PermissionRequestOutcome::kUserGranted);
+ }
+ ASSERT_EQ(dir_read_grant->GetStatus(), PermissionStatus::GRANTED);
+ ASSERT_EQ(dir_write_grant->GetStatus(), PermissionStatus::GRANTED);
+
+ // Revoke permissions for the directory. This represents a recursive removal
+ // of the directory.
+ permission_context()->NotifyEntryRemoved(kTestOrigin, dir_info);
+
+ // Verify that the directory's own read permission is downgraded.
+ EXPECT_EQ(dir_read_grant->GetStatus(), PermissionStatus::DENIED);
+ EXPECT_TRUE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+ kTestOrigin, dir_info.path));
+
+ // Verify that the case-variant descendant file's read permission is also
+ // downgraded.
+ EXPECT_EQ(file_read_grant->GetStatus(), PermissionStatus::DENIED);
+ EXPECT_TRUE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+ kTestOrigin, file_info.path));
+
+ // Verify that the unrelated sibling is not affected.
+ EXPECT_EQ(sibling_read_grant->GetStatus(), PermissionStatus::GRANTED);
+ EXPECT_FALSE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+ kTestOrigin, sibling_info.path));
+}
+
// Tests that moving a file to a destination with a pre-existing permission
// grant works correctly.
TEST_F(ChromeFileSystemAccessPermissionContextTest,
Original Bug Report
Potential File System Access permission context bypass on case-insensitive filesystems
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: On Windows and macOS, case-insensitive filesystems can allow origins to potentially bypass permission revocation checks in the File System Access API. Because base::FilePath::IsParent performs case-sensitive checks on non-drive components, case differences or 8.3 aliased prefixes in paths prevent correct descendant grant downgrades during recursive directory removal. This potential bypass can allow origins to retain active and persisted read permissions for files inside recursively deleted directories.
Affected files:
chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
Estimated timestamp from git blame: 2026-05-14
Summary
A potential logical security bypass has been identified in the File System Access API’s permission context on case-insensitive filesystems (such as NTFS on Windows and APFS/FAT on macOS). Due to case-sensitive path comparisons used during recursive directory removal, active and persisted read grants for deleted sub-elements or descendant paths may fail to be downgraded or revoked.
File Affected: chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
Root Cause Analysis
Under the kFileSystemAccessRevokeReadOnRemove security mitigation, when a directory is recursively removed (e.g., via dirHandle.remove({recursive: true})), the browser calls NotifyEntryRemoved to downgrade and revoke all active and persisted read grants for the deleted entry and any of its descendants.
The validation helper is defined as:
auto is_path_or_descendant = [&](const base::FilePath& file_path) {
return file_path == path.path || path.path.IsParent(file_path);
};
However, both base::FilePath::operator== and base::FilePath::IsParent (defined in base/files/file_path.cc) perform strictly case-sensitive string comparisons for all non-drive/non-UNC path components across all platforms (including Windows and macOS).
Since active read grants are stored verbatim as selected via the file picker without case normalization, any case discrepancy (e.g., Project vs project) or 8.3 short-name alias discrepancy (e.g., PROGRA~1 vs Program Files) between the originally registered read grant and the subsequent deletion event causes IsParent and operator== to evaluate to false. Consequently, active and persistent permission grants for the deleted sub-elements are not revoked or downgraded.
Suggested Attack Scenario
Note: These are suggested/potential steps, as our analysis is static and our tooling agent does not have the ability to run code.
- An origin calls
showOpenFilePicker()to let the user select a file on a case-insensitive filesystem, registering a read grant verbatim (e.g.,C:\\Users\\Alice\\Project\\config.json). The origin saves the serializedFileSystemFileHandlein IndexedDB. - The parent directory is renamed with a case modification outside of the browser (e.g.,
C:\\Users\\Alice\\ProjecttoC:\\Users\\Alice\\project). - The origin calls
showDirectoryPicker({mode: 'readwrite'})and the user selects the renamed directoryC:\\Users\\Alice\\project, giving the origin write access to the folder. - The origin calls
dirHandle.remove({recursive: true})on the directory handle, triggering a recursive deletion ofC:\\Users\\Alice\\project. - The browser processes the removal notification under
C:\\Users\\Alice\\project. When evaluating descendants, the case-sensitiveIsParentcheck fails to match the active grant pathC:\\Users\\Alice\\Project\\config.jsonagainst the removed path prefix. - The read grant for
config.jsonis not downgraded. - If a new file is subsequently created at that physical disk path, the origin can re-hydrate its stored file handle from IndexedDB and call
getFile()to read the new contents without a user prompt.
Potential Remediation
To resolve this issue, the descendant walk in NotifyEntryRemoved should handle case-insensitivity and path normalization correctly for platforms with case-insensitive filesystems (such as Windows and macOS). Alternatively, paths should be normalized prior to comparing parent-descendant relationships or looking up active/persisted grants.
Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.