Skip to content

Deduplicate deletes a file outside the scanned root when a subdirectory is replaced by a symlink during the "Proceed with deletion?" prompt #202

Closed

Description

@matt-edmondson

What's wrong

Deletion uses the path string recorded at scan time:

  • StillMatchesGroup (Deduplicator.cs:166) opens the path, following any directory symlink along it.
  • The only identity check (:176) compares the file against the kept copy.
  • TryDelete (:205) then calls File.Delete(file.WeakString), which removes whatever that path leads to at that moment.

Nothing confirms the path still lies inside the root that was scanned and approved. If any directory on the path became a symlink after the scan, the tool deletes a file elsewhere, as long as it holds the same content.

Reproduction (Linux, at e3d3d1c)

mkdir -p t/root/sub t/outside && cd t
echo "important config v1" > root/a.cfg
cp root/a.cfg root/sub/settings.cfg
cp root/a.cfg outside/settings.cfg
mkfifo in
FileDeduplicator Deduplicate -p "$PWD/root" < in > log &
exec 3>in
until grep -q 'Proceed with deletion' log; do sleep 0.05; done
rm -rf root/sub && ln -s ../outside root/sub
echo y >&3; exec 3>&-; wait
ls outside/
  • Observed: Deleted: .../root/sub/settings.cfg, exit 0, and outside/ is empty. outside/settings.cfg was never under the scanned root.
  • Expected: that copy is skipped and reported as no longer inside the scanned root.

Why it matters

The confirmation prompt can stay open for any length of time, so the window is wide. Without any attacker, the tool can delete a file the user never saw in the listing. Over a shared or world-writable tree, especially when run with elevated rights, this is a classic symlink race. Someone who can read a file elsewhere plants an identical copy with a shorter name, swaps a subdirectory for a symlink during the prompt, and gets the real file deleted.

Suggested fix / acceptance criteria

  • Immediately before each delete, resolve the real path of the file's parent directory and require it to be inside the real path of the scanned root. Otherwise skip the file with a reason.
  • Alternatively, check each path component and refuse when any of them is now a reparse point. On Linux, deleting relative to an opened root handle (openat/unlinkat with no-follow) closes the race fully.
  • A test replaces a subdirectory with a symlink to an outside directory between grouping and deletion, and asserts the outside file survives.

Activity

  1. matt-edmondson commented on Nov 3, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    Notes: The tool deletes a file outside the root the user approved, and the deleted file is never shown in the listing. You only need the confirmation prompt to stay open, which it can do for as long as anyone likes. Run with elevated rights over a shared tree, this is a classic TOCTOU symlink race. I rated it High rather than Critical because someone has to swap a directory for a symlink during the prompt. The fix is to check that the real path is still inside the scanned root right before each delete, or on Linux to delete relative to an open handle with openat/unlinkat. Add a regression test as the issue describes.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions