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
7.4 KiB
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)
- 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, A9–A13, A20) - Require
saveDirabsolute; reject any..component lexically. A legitimate client never sends..; a web-origin path with..is an attack, so it does not even reachrealpath. (A1, A2, A21) - 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 dirO_PATH|O_DIRECTORYfor the leaf check. (A3, A4, A7, A16, A17, A19) - If a tail is missing (
mkdir -pcase): find the deepest existing ancestor,realpath+ root-check that, then create the missing components through anopenat/mkdiratwalk withO_NOFOLLOW|O_DIRECTORYfrom the ancestor's fd — the tail has no symlinks because it had no entries; a race that plants one tripsELOOP→-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) - 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 fileO_NOFOLLOW|O_EXCL(orO_NOFOLLOW+ explicit resume); that is CORE's contract, stated indaemon/docs/engine-api-review.md. - Every failure is
-32011,data.path= the originalsaveDir— never the resolved path, which would leak where the roots actually live. The one exception is afilenamethat violates the schema's ownmaxLength, which is-32602at 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.