Skip to content

Add an opt-in secure walker resistant to symlink races - #22

Merged
ririsoft merged 9 commits into
masterfrom
secure-walkdir
Sep 29, 2026
Merged

ririsoft merged 9 commits into
masterfrom
secure-walkdir

Conversation

@ririsoft

Copy link
Copy Markdown
Owner

Summary

WalkDir checks an entry's type without following symlinks, then opens the directory again by path, which does follow them. A concurrent process able to write to the walked tree can swap a directory for a symlink between the two calls and make the walk escape the root. This is a TOCTOU race of the same class as CVE-2022-21658 (std::fs::remove_dir_all) and BurntSushi/walkdir#209.

This PR:

  • Documents the race in the README and rustdoc: who is affected and how to mitigate it.
  • Adds an opt-in secure Cargo feature (off by default, Unix and Windows only) providing async_walkdir::secure::WalkDir, which walks with directory handles:
    • Unix: openat relative to the parent fd with O_NOFOLLOW | O_DIRECTORY (via rustix, no unsafe).
    • Windows: NtCreateFile relative to the parent handle with FILE_OPEN_REPARSE_POINT, then refuses symlinks and junctions by checking the opened handle (via windows-sys). This is the only module with unsafe: the crate root goes from forbid to deny(unsafe_code), and each unsafe block has a // SAFETY: comment.
    • A directory swapped for a link is reported as an error, not descended into, and the walk continues.
    • Entries expose open() and the parent handle (parent_fd / parent_handle). The crate owns its FileType and Metadata types, so no rustix or windows-sys type appears in the public API.
  • CI: clippy on all targets with and without secure, tests and coverage with secure, macos-latest added to the matrix.

The existing WalkDir is unchanged, so this is a semver-minor change. No version bump in this PR.

Review notes

The Windows backend was only type-checked locally; this CI run is the first real execution. Points to confirm on Windows:

  • reopening a directory with an empty relative name through NtCreateFile;
  • reading the reparse tag from EaSize in FileIdBothDirectoryInfo records;
  • coverage stays above the 90% threshold.

WalkDir checks an entry type without following symlinks, then opens the
directory again by path. A concurrent process able to write to the tree
can swap the directory for a symlink in between and make the walk escape
the root directory.

Document the race, who is affected and possible mitigations in the README
and crate documentation.
Add the secure module, behind the opt-in secure cargo feature. Its
WalkDir walks with directory file descriptors instead of paths: each
directory is opened relative to its already opened parent with
O_NOFOLLOW | O_DIRECTORY, and metadata is read with fstatat and
AT_SYMLINK_NOFOLLOW. A directory swapped for a symlink during the walk is
reported as an error instead of being followed, and swapping a parent
directory has no effect.

Entries expose their parent directory fd and an open() method acting
relative to it, so that callers do not have to resolve paths again.
FileType and Metadata are crate-owned types since the std ones cannot
be built from a directory fd. from_fd() starts a walk from an already
opened directory.

The existing WalkDir is unchanged.
Walk directories with NtCreateFile relative to the parent directory
handle and FILE_OPEN_REPARSE_POINT, then refuse symbolic links and
junctions by checking the opened handle. Listings use
GetFileInformationByHandleEx(FileIdBothDirectoryInfo) on a separate
file object and are parsed with bounds-checked code.

unsafe code is now denied instead of forbidden at the crate root so that
the Windows backend alone can call Windows APIs, each unsafe block
documenting why it is sound.
Cover the entry accessors, including timestamps before the Unix epoch,
so that the secure module meets the coverage threshold of the CI. Check
that every entry is yielded exactly once around the listing batch size,
and that a root handle which is not a directory yields a single error
with an empty path.
Lint all targets with and without the secure feature, run the tests with
the feature, include it in the coverage measurement, and add macOS to
the matrix to test the BSD flavor of the Unix backend.
Describe the secure feature in the README with an example, a migration
table from WalkDir and its limitations, and add it to the changelog.
Mark it as experimental and looking for feedback, and explain that it
will move to the std directory handle APIs once they are stable. Warn
about retained handles on large trees and about the parent directory
handle being outside of the walker guarantees.
Swap a directory for a symlink (a junction on Windows) between its type
check and its opening, and check that the walk escapes the root. This
demonstrates the documented limitation, and the README points to this
test and to its secure walker counterpart.
1.85 is already required by the dependencies. Avoid slice::as_chunks in
the Windows backend, which would require 1.88. Following the Cargo book
recommendation, check the MSRV in the CI with cargo check on a single
runner, type-checking the Windows and macOS backends through their
targets, and document the MSRV policy in the README.
@ririsoft
ririsoft merged commit b82e0c1 into master Sep 29, 2026
5 checks passed
@ririsoft
ririsoft deleted the secure-walkdir branch September 29, 2026 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant