Files
vdm/daemon/docs/safepath-adversarial.md
T
samiandClaude Sonnet 5 ab479e7885 daemon: fs/safepath — the saveDir/filename path-traversal boundary (security)
veloxd is the one process that turns an untrusted string into a
filesystem destination, and via capture.offer that string can come
from a web page. CLAUDE.md §4 and the M1 DoD both name this.

daemon/docs/safepath-adversarial.md is the spec, written before the
code the way EXT did for shouldCapture: 21 rows — .. traversal
(A1/A2), absolute-outside-roots (A3), prefix-match confusion (A4),
symlink-out (A7), TOCTOU on a created tail (A8), NUL/control bytes in
the leaf that CORE's fuzzer hit through Content-Disposition (A9/A10),
degenerate and overlong leaves (A11/A13), overlong dir component
(A14), symlinked root (A16), destination-is-a-file (A17), and the
legitimate cases that must still pass — non-ASCII (A18), redundant "."
(A19), trailing space/dot trimming (A20).

fs/safepath.cpp:
- sanitize_leaf: strip <0x20 and 0x7F, trim ws, strip trailing dots,
  reject ""/"."/".."/contains-'/', cap 255 UTF-8 bytes on a codepoint
  boundary. Mirrors core/src/net/content_disposition.cpp.
- canonicalize_root: expand ~ and realpath each allowedRoots entry
  once, so a symlinked root resolves to its target.
- resolve_target: reject relative saveDir and any ".." component
  lexically; if the dir exists, realpath + component-wise containment
  (a symlink that escapes is caught, one that stays inside passes); if
  a tail is missing, realpath+check the deepest existing ancestor then
  create the tail via an openat/mkdirat O_NOFOLLOW walk and re-derive
  the final path from the fd. Every failure is -32011 with data.path =
  the *original* saveDir (never the resolved path). Residual TOCTOU on
  a pre-existing intermediate dir is documented and closed by CORE's
  O_NOFOLLOW open of the file.

veloxd_fs static lib; veloxd_rpc links it for the download.add wiring
next. Test veloxd.safepath is the adversarial table, on a real temp
tree. ASan+UBSan and TSan clean; 33 daemon/cli tests green.

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

7.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. The real close on the create-after-check race is CORE opening the file O_NOFOLLOW|O_EXCL (or O_NOFOLLOW + explicit resume); that is CORE's contract, stated in daemon/docs/engine-api-review.md.
  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, accepted for M1

An existing intermediate directory swapped for an out-of-root symlink between our realpath and CORE's open is not caught by this code (step 3 trusts realpath for the pre-existing prefix; a full O_NOFOLLOW chase would reject legitimate symlinked directories mid-path, which A16 requires us to allow). It is closed in practice by CORE's O_NOFOLLOW open of the final file and by the download dir living under a 0700 ~/.local/share / ~/Downloads the attacker would already need write access to. A per-step "resolve, re-validate against roots" chase is the post-M1 hardening.