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 c16562290e2f1..dc95aa740445b 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 @@ -3339,6 +3339,104 @@ TEST_F(ChromeFileSystemAccessPermissionContextTest, kTestOrigin, file_path_info.path)); } +// POC: Stale downgraded_read_paths survives RevokeGrants() and enables +// write-only -> read permission escalation. +// +// Scenario: an origin obtains a readwrite handle, calls remove() (which +// populates downgraded_read_paths), then the USER explicitly revokes all +// permissions for the origin via the Page Info UI (RevokeGrants). Because +// RevokeGrants/RevokeActiveGrants never clear downgraded_read_paths, the +// stale entry persists. When the origin later re-loads the handle from +// IndexedDB and the user approves a WRITE-ONLY prompt, the first close() +// triggers MaybeRestoreReadPermission which silently upgrades the read grant +// to GRANTED -- even though the user only approved write in this session. +TEST_F(ChromeFileSystemAccessPermissionContextTest, + POC_StaleDowngradedReadPathSurvivesRevokeGrants) { + base::test::ScopedFeatureList feature_list; + feature_list.InitAndEnableFeature( + blink::features::kFileSystemAccessRevokeReadOnRemove); + FileSystemAccessPermissionRequestManager::FromWebContents(web_contents()) + ->set_auto_response_for_test(PermissionAction::GRANTED); + + const auto file_path_info = + PathInfo(kTestPathInfo.path.AppendASCII("victim.txt")); + + // === Session 1 === + // (1) User picks the file via showOpenFilePicker / showSaveFilePicker. + // UserAction::kSave grants both read and write. + auto s1_read = permission_context()->GetReadPermissionGrant( + kTestOrigin, file_path_info, HandleType::kFile, UserAction::kSave); + auto s1_write = permission_context()->GetWritePermissionGrant( + kTestOrigin, file_path_info, HandleType::kFile, UserAction::kSave); + ASSERT_EQ(s1_read->GetStatus(), PermissionStatus::GRANTED); + ASSERT_EQ(s1_write->GetStatus(), PermissionStatus::GRANTED); + + // (2) Origin calls handle.remove(). This populates downgraded_read_paths. + permission_context()->NotifyEntryRemoved(kTestOrigin, file_path_info); + ASSERT_TRUE(permission_context()->IsPathInDowngradedReadPathsForTesting( + kTestOrigin, file_path_info.path)); + + // (3) USER explicitly revokes all permissions for the origin via Page Info. + // This is the same call PageInfo / settings UI makes. + permission_context()->RevokeGrants(kTestOrigin); + EXPECT_EQ(s1_read->GetStatus(), PermissionStatus::ASK); + EXPECT_EQ(s1_write->GetStatus(), PermissionStatus::ASK); + + // *** BUG, part 1: downgraded_read_paths was NOT cleared by RevokeGrants. *** + // After explicit user revocation, no restoration state should remain, but + // the path is still tracked as "eligible for silent read restoration". + EXPECT_TRUE(permission_context()->IsPathInDowngradedReadPathsForTesting( + kTestOrigin, file_path_info.path)) + << "downgraded_read_paths must contain the stale entry for the " + "escalation to work; if this is false the bug is fixed"; + + // (4) Tab is closed / reloaded -> all renderer-held handles are dropped. + s1_read.reset(); + s1_write.reset(); + + // The OriginState (and its stale downgraded_read_paths) still exists because + // active_permissions_map_.erase() is never called. + ASSERT_TRUE(permission_context()->IsPathInDowngradedReadPathsForTesting( + kTestOrigin, file_path_info.path)); + + // === Session 2 === + // (5) Origin retrieves the handle from IndexedDB. DeserializeHandle uses + // UserAction::kLoadFromStorage, which creates fresh grants at ASK. + auto s2_read = permission_context()->GetReadPermissionGrant( + kTestOrigin, file_path_info, HandleType::kFile, + UserAction::kLoadFromStorage); + auto s2_write = permission_context()->GetWritePermissionGrant( + kTestOrigin, file_path_info, HandleType::kFile, + UserAction::kLoadFromStorage); + ASSERT_EQ(s2_read->GetStatus(), PermissionStatus::ASK); + ASSERT_EQ(s2_write->GetStatus(), PermissionStatus::ASK); + + // (6) Origin calls handle.createWritable(). With kFileSystemAccessWriteMode + // the effective mode is kWrite, so ONLY the write grant is prompted. + // The user sees a write-only prompt and approves it. + base::test::TestFuture write_prompt; + s2_write->RequestPermission(frame_id(), UserActivationState::kNotRequired, + write_prompt.GetCallback()); + ASSERT_EQ(write_prompt.Get(), PermissionRequestOutcome::kUserGranted); + ASSERT_EQ(s2_write->GetStatus(), PermissionStatus::GRANTED); + // Crucially, the user was NEVER prompted for read in this session: + ASSERT_EQ(s2_read->GetStatus(), PermissionStatus::ASK); + + // (7) Origin calls writer.close(). DidReplaceSwapFile -> + // MaybeNotifyEntryModified -> NotifyEntryModified -> + // MaybeRestoreReadPermission. + permission_context()->NotifyEntryModified(kTestOrigin, file_path_info); + + // *** BUG, part 2: read permission is silently escalated to GRANTED. *** + // The user explicitly revoked all permissions in step (3) and only approved + // WRITE in step (6), yet the origin now holds READ -- a permission the user + // never granted in this session. + EXPECT_EQ(s2_read->GetStatus(), PermissionStatus::GRANTED) + << "If this is ASK, the escalation is fixed."; + EXPECT_FALSE(permission_context()->IsPathInDowngradedReadPathsForTesting( + kTestOrigin, file_path_info.path)); +} + // Tests that removing a file within a directory that has a read-write // permission grant does not revoke the grants for the directory. TEST_F(ChromeFileSystemAccessPermissionContextTest,