Files
vdm/daemon/docs/safepath-adversarial.md
T
samiandClaude Sonnet 5 7514f5a4c4 daemon: correct an unverified cross-lane claim in safepath-adversarial.md
The residual section claimed the leaf/component TOCTOU is "closed in
practice by CORE's O_NOFOLLOW open of the final file". Verified: it is
not — core/src/io/sparse_file.cpp:77 opens O_WRONLY|O_CREAT|O_CLOEXEC,
no O_NOFOLLOW, no O_EXCL. Requested the flags from CORE via PKG/QA.

Doc now states the residual is currently OPEN, names the file:line and
flags checked and the date, says what actually limits exposure today
(0700 parent dirs), and flags this as the boundary where a reader
stops checking. Step 5 reworded the same way. Re-verify the flags when
the CORE change lands.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig
2026-09-10 19:49:51 +04:00

8.4 KiB
Raw Blame History

saveDir / filename → filesystem destination: the adversarial table

veloxd is the only process that turns an untrusted string into a place bytes get written. capture.offer means that string can originate from a web page, and download.add over the Unix socket is reachable by any same-UID process. CLAUDE.md §4 ("paths are canonicalized and checked against allowed roots before any write") and the M1 DoD ("no path traversal in saveDir … → -32011") make this a security boundary, not a formatting nicety.

This table is written before fs/safepath.cpp, the way EXT did for shouldCapture. Every row is a test in daemon/tests/safepath_test.cpp.

Roots for the examples: allowedRoots = ["/home/u/Downloads", "/data/dl"], already realpath-resolved and stored canonical at load time. $HOME = /home/u.

# Input (saveDir, filename) Attack Required outcome
A1 /home/u/Downloads/../.ssh, authorized_keys .. climbs out of the root -32011, data.path = the input saveDir. No dir created.
A2 /home/u/Downloads/a/b/../../../etc, x .. chain escaping after descending -32011.
A3 /etc, cron.d-payload absolute path, simply outside every root -32011.
A4 /home/u/Downloads-evil, x prefix-match confusion with /home/u/Downloads -32011 — containment is component-wise, not starts_with.
A5 /home/u/Downloads, ../.bashrc .. in the leaf, not the dir leaf rejected → -32011 (or InvalidParams); a leaf is one component, never a path.
A6 /home/u/Downloads, sub/dir/file / in the leaf leaf rejected — filename names a file, not a subpath.
A7 /home/u/Downloads/link-out where link-out/etc (pre-existing symlink) symlink component points outside a root realpath resolves it to /etc; -32011.
A8 /home/u/Downloads/goodsub, iso.img — but between our check and CORE's open, goodsub is swapped for a symlink to /etc TOCTOU on a directory component Defense: resolve + create with openat/mkdirat from an `O_NOFOLLOW
A9 /home/u/Downloads, file<NUL>.iso (0x00 in the leaf) NUL truncation — the write path sees file, logs/UI see more; CORE's fuzzer hit exactly this via Content-Disposition NUL and every < 0x20 byte and 0x7F stripped from the leaf before use (mirrors core/src/net/content_disposition.cpp sanitize_leaf). If the leaf is empty after stripping → reject.
A10 /home/u/Downloads, "\r\nSet-Cookie: x".iso CR/LF injection into logs / downstream control bytes stripped as A9.
A11 /home/u/Downloads, . / .. / `` (empty) degenerate leaf rejected.
A12 /home/u/Downloads, con / aux / nul Windows device names allowed on Linux — we are not Windows; do not over-reject. (Noted so a future "harden" pass doesn't add it thinking it was missed.)
A13 /home/u/Downloads, <260 chars> overlong leaf, ENAMETOOLONG at open leaf capped at 255 bytes of UTF-8, never splitting a codepoint (docs/04 §2).
A14 /home/u/Downloads/<260 chars>/x, y overlong directory component mkdirat / realpath returns ENAMETOOLONG → mapped -32011, not a crash.
A15 saveDir empty / null no destination given caller substitutes saveTo.defaultDir; resolve_target itself rejects an empty dir rather than defaulting silently.
A16 root /home/u/Downloads is itself a symlink to /mnt/big/dl a symlinked root canonicalize_root realpaths every configured root at load; the stored root is /mnt/big/dl, and a saveDir resolving there passes. A saveDir of the literal /home/u/Downloads/x also passes because it realpaths to /mnt/big/dl/x.
A17 /home/u/Downloads exists as a file, not a directory destination is not a directory -32011 (not_a_dir), no write attempt.
A18 /home/u/Downloads/新しい/フォルダ, 映画.mkv non-ASCII, legitimate succeeds — UTF-8 is fine; only control bytes and the structural checks apply.
A19 /home/u/Downloads/./sub/., x redundant . segments, no escape normalized away; succeeds at /home/u/Downloads/sub.
A20 /home/u/Downloads, trailing-spaces.iso / dots... trailing space/dot (Windows-hostile, and confuses "same file" checks) trimmed: leading/trailing whitespace and trailing dots removed before use. Empty after trim → reject.
A21 relative saveDir (Downloads/x, ./x, x) a relative path has no well-defined base and invites cwd games rejected — resolve_target requires an absolute saveDir. The GUI/CLI resolve against the default dir before calling.

Implementation (fs/safepath.cpp, as built)

  1. Sanitize the leaf first, in isolation: strip [0x00,0x20) {0x7F}, trim whitespace, strip trailing dots and spaces, reject ./../empty/contains '/', cap 255 UTF-8 bytes on a codepoint boundary. (A5, A6, A9A13, A20)
  2. Require saveDir absolute; reject any .. component lexically. A legitimate client never sends ..; a web-origin path with .. is an attack, so it does not even reach realpath. (A1, A2, A21)
  3. If the directory already exists: realpath(saveDir) — this follows every symlink, so a symlinked root or component resolves to where it really points — then assert the resolved path is inside a canonical root, component-wise (d == root || d starts with root + "/"). A symlink that escapes is caught here (A7); one that stays inside passes (A16). Open the resolved dir O_PATH|O_DIRECTORY for the leaf check. (A3, A4, A7, A16, A17, A19)
  4. If a tail is missing (mkdir -p case): find the deepest existing ancestor, realpath + root-check that, then create the missing components through an openat/mkdirat walk with O_NOFOLLOW|O_DIRECTORY from the ancestor's fd — the tail has no symlinks because it had no entries; a race that plants one trips ELOOP-32011. Then re-derive the final dir's path from its fd (/proc/self/fd/N) and re-assert containment. (A8 for the created tail, A14)
  5. Best-effort leaf check: fstatat(dir_fd, leaf, AT_SYMLINK_NOFOLLOW) — refuse if it is already a symlink. This narrows, but does not close, the create-after-check race on the leaf: a symlink planted after this fstatat and before CORE opens the file is still followed. Closing it needs CORE to open with O_NOFOLLOW (plus O_EXCL on a fresh download). Verified 2026-09-10: it does not yetcore/src/io/sparse_file.cpp:77 is O_WRONLY | O_CREAT | O_CLOEXEC. The flag change has been raised with CORE; until it lands this race is open, see the residual below.
  6. Every failure is -32011, data.path = the original saveDir — never the resolved path, which would leak where the roots actually live. The one exception is a filename that violates the schema's own maxLength, which is -32602 at the param layer before this code runs.

Residual — currently OPEN, tracked

Two TOCTOU gaps this code does not close on its own:

  1. An existing intermediate directory swapped for an out-of-root symlink between our realpath (step 3) and the write. Step 3 trusts realpath for the pre-existing prefix; a full O_NOFOLLOW chase would reject the legitimate symlinked directories A16 requires us to allow.
  2. The leaf swapped for a symlink between our fstatat (step 5) and CORE's open.

Both are closed by CORE opening the file O_NOFOLLOW (and, for a fresh download, O_EXCL). As verified on 2026-09-10 that is not yet the casecore/src/io/sparse_file.cpp:77 opens O_WRONLY | O_CREAT | O_CLOEXEC. The flag change has been raised with CORE; when it lands, update step 5 and this paragraph and re-verify the flags at that line.

What limits the exposure today: the download directory lives under ~/.local/share / ~/Downloads, both 0700 — an attacker planting a symlink there already has write access to the user's account. The unqualified "you are covered" version of this claim does not hold until the CORE change lands; this is exactly the boundary where a reader stops checking, so it is spelled out.

The post-M1 hardening for gap 1 is a per-step "resolve one component, re-validate the running path against the roots" chase (systemd's chase_symlinks shape).