Skip to content

fix(hardening): Look for TOCTOU issues in the code base - #2420

Open
hunger wants to merge 5 commits into
mainfrom
push-nuymupuwtomz
Open

fix(hardening): Look for TOCTOU issues in the code base#2420
hunger wants to merge 5 commits into
mainfrom
push-nuymupuwtomz

Conversation

@hunger

@hunger hunger commented May 10, 2026

Copy link
Copy Markdown
Collaborator

Description

I want to get to a point where I can share package caches between users. For that the backend code needs to be more hardened to all kinds of mischief people can come up with.

Here is a first round of TOCTOU and similar issues in Claude found in rattler, trying to close the window where an attacker could potentially sneak in a symlink into unexpected places or get a file descriptor to something that will then get more restrictive permissions applied.

The biggest addition is a rattler_fs_safety crate that tries to have some common and safe filesystem helpers.

The rest is pretty small and and self-contained changes, often using rattler_safe_fs.

How Has This Been Tested?

Tests

AI Disclosure

  • This PR contains AI-generated content.
    • I have tested any AI-generated content in my PR.
    • I take responsibility for any AI-generated content in my PR.

Tools: Claude

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I have added sufficient tests to cover my changes.

@hunger
hunger requested review from baszalmstra and pavelzw May 10, 2026 22:18
@hunger

hunger commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author

Looks like I need to apply more windows-love :-)

@pavelzw pavelzw left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haven't looked at everything so far. only a small review from my phone

Comment on lines -194 to +199
let mut writer = HashingWriter::<_, Sha256>::new(File::create(path)?);
writer.write_all(bytes)?;
let (_, hash) = writer.finalize();
atomic_write(path, bytes, mode)?;
let hash = compute_bytes_digest::<Sha256>(bytes);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this result in performance degradation?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I missing a trick here? I thought it would be the same in this case where we write all data in one go anyway.

Comment thread crates/rattler_fs_safety/Cargo.toml Outdated
Comment thread crates/rattler_fs_safety/Cargo.toml Outdated
@baszalmstra
baszalmstra marked this pull request as draft May 12, 2026 11:46
Tobias Hunger added 5 commits May 13, 2026 14:24
A small workspace crate of symlink-resistant filesystem
helpers, built on cap-std. The shared theme is that every
operation routes through a `cap_std::fs::Dir` capability
opened on a trusted parent, so path-component traversal can
never escape that parent and the only remaining attack
surface — the final component — is handled with explicit
symlink rejection.

Three helpers, covering the common shapes that came out of
the TOCTOU audit:

* `open_no_follow(parent, name, opts)` — opens or creates
  `name` inside `parent`, returning `PermissionDenied` if
  `name` is a symlink. For `.lock` files and similar
  short-lived metadata files in shared cache roots, where a
  co-tenant could otherwise redirect the open via a
  same-directory symlink swap.
* `atomic_write_in_dir(parent, name, bytes, mode)` — writes
  via `tempfile::NamedTempFile` in the same parent, applies
  `mode` (Unix only) via `fchmod` on the still-open temp-
  file fd, then renames over `name`. The final path never
  exists with the wrong permissions, the chmod can't race a
  path-symlink swap, and a concurrent reader never sees a
  half-written file.
* `validate_relative_inside(root, candidate)` — lexical
  normaliser that refuses absolute paths, `..` escapes, and
  empty/curdir-only inputs. For pre-validating attacker-
  influenced relative paths (archive
  `paths.json::relative_path`, menuinst manifest dest, …)
  before they're handed to a higher-trust opener.

On Windows the path-based primitives leave small TOCTOU
windows reachable by a co-tenant with write access to the
parent of the cache root, so a `windows_sec` module replaces
them with fd-anchored NT-level equivalents:

* `create_owner_only_subdir(parent, name)` and
  `create_owner_only_file(parent, name)` — both call
  `NtCreateFile` with `OBJECT_ATTRIBUTES.RootDirectory =
  parent.HANDLE`, `FILE_CREATE`, and an explicit `PROTECTED`
  owner-only `SECURITY_DESCRIPTOR` carrying a single
  allow-ACE for the current user's SID. Placement is
  fd-anchored and the DACL is applied atomically at create —
  no inherit-then-harden window.
* `rename_via_handle(src, dest_dir, dest_name)` — the
  publish step. Uses
  `SetFileInformationByHandle(FileRenameInfoEx)` with
  `RootDirectory = dest_dir.HANDLE` and POSIX semantics. The
  destination is identified entirely by the caller's handle
  plus a single-component name; no path resolution.
* `verify_owner(dir)` — defense-in-depth check that the
  kernel object's owner SID matches the process token's user
  SID. Catches filesystems that ignore security descriptors
  (FAT) and any future ownership-semantics regression.
* `is_symlink_refusal` matches `ERROR_STOPPED_ON_SYMLINK`
  and `ERROR_CANT_ACCESS_FILE`, so `open_no_follow_in`
  returns `PermissionDenied` on Windows the same way it
  does on Unix.

`TempDir::new_in` dispatches to `create_owner_only_subdir`
on Windows; `atomic_write_in` opens the payload via
`create_owner_only_file` and publishes via
`rename_via_handle`. cap-std stays the type vocabulary
(`Dir`, `File`) — handles returned by NtCreateFile are
wrapped via `Dir::from_std_file`. A Windows-only
`windows-sys` dependency adds eight features covering the
Wdk and Win32 namespaces used.

cap-std covers every rattler CI target — Linux (incl. musl/
aarch64/arm/riscv64/ppc64), macOS x86_64+aarch64, Windows
x86_64+aarch64. The helpers re-export `cap_std::fs::Dir`,
`File`, `OpenOptions`, `Permissions`, etc. so callers don't
take a direct cap-std dep.

13 unit tests cover symlink refusal, atomic-replace
semantics, mode application via `fchmod`, and the
normaliser's edge cases (absolute, escaping `..`, empty,
curdir-only, balanced `..`). Three Windows-only tests
verify the tempdir is owner-owned and `SE_DACL_PROTECTED`,
the published file inherits the same properties, and the
reparse-point error codes map to `PermissionDenied`.

This commit only adds the crate; the drop-in callers that
exercise these helpers land in the follow-up commit.
Open `<prefix>/.guard` via `rattler_fs_safety::open_no_follow`
instead of plain `OpenOptions::new().open(&guard_path)`. The
new opener resolves the file through a `cap_std::fs::Dir`
capability rooted at the prefix directory and refuses to
follow a symlink at the final component, so a co-tenant on a
shared install root who plants `.guard` as a symlink to e.g.
`~/.bashrc` can no longer steer the locking opener at that
target.

The guard struct now stores the prefix directory rather than
the assembled `.guard` path, and `AsyncPrefixGuard::new`
ensures the prefix exists rather than the guard's parent
(same effect — the prefix *is* the guard's parent — but
keeps the path-construction local to `write`).
`LockFile::to_path` now serializes to bytes and routes the
write through `rattler_fs_safety::atomic_write_in_dir`, which
opens a `tempfile::NamedTempFile` in the same parent
directory, fsyncs it, and renames over the final path. Two
benefits over the previous `File::create` + `to_writer`:

* A concurrent reader never sees a torn YAML document — the
  lockfile either has the previous version or the new one,
  not a half-written mix.
* The final-path open isn't redirected by a final-component
  symlink swap, since the rename target is resolved through
  `tempfile::persist` and the kernel fails the rename rather
  than overwriting the symlink target.
…_path

A package shipping `paths.json::relative_path = "../../etc/cron.d/x"`
could previously have `link_file` hardlink (or write through)
into the user's `cron.d`, since `relative_path` is joined to
the package directory without lexical checking and
`destination_relative_path` is similarly attacker-controllable
through the clobber registry's renaming.

`link_file` now lexically validates both paths via
`rattler_fs_safety::validate_relative_inside` before any
filesystem operation, returning a new
`LinkFileError::RelativePathEscapesPrefix` variant. The check
runs on both the source side (against the package directory)
and the destination side (against the target prefix); each
rejects `..`-escapes, absolute paths, and prefix components
ahead of any `File::open` / `hard_link` / `reflink_copy`
call, so a malicious channel can't even probe the host
filesystem through error-shape side channels.

A new `test_link_file_rejects_relative_path_escape` test pins
both directions of the rejection.
…try-point scripts

`write_and_hash` no longer truncates the final path with
`File::create` and then chmods it via path. Both moves are
now done atomically:

* The bytes go to a `tempfile::NamedTempFile` in the same
  parent directory via `rattler_fs_safety::atomic_write_in_dir`,
  which fsyncs and renames over the final path. A concurrent
  reader never sees a half-written script.
* On Unix, the `0o775` mode is applied via `fchmod` on the
  still-open temp-file fd before the rename, so the script
  never exists at the final path with the wrong permissions
  and the chmod can't race a path-symlink swap. The previous
  `std::fs::set_permissions(script_path, ...)` call left a
  small window where a co-tenant could redirect the chmod via
  a symlink at `script_path`.

The Windows `.exe` launcher write (an unconditional
`std::fs::write`) is now atomic too, closing the same
torn-write window for that path.

`write_and_hash` no longer constructs a `HashingWriter`;
the digest is computed from the in-memory bytes via
`compute_bytes_digest::<Sha256>` after the atomic write,
which is equivalent for a single `write_all` call and avoids
keeping the file fd alive past the rename.
@hunger
hunger force-pushed the push-nuymupuwtomz branch from 356bc08 to 0577336 Compare May 13, 2026 15:10
@hunger
hunger marked this pull request as ready for review May 20, 2026 09:08
@hunger

hunger commented May 20, 2026

Copy link
Copy Markdown
Collaborator Author

Most of this has been covered upstream and I need to merge the two somehow.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants