Files
vdm/daemon/src/fs/safepath.hpp
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

64 lines
3.0 KiB
C++

#pragma once
// Turns an untrusted (saveDir, filename) into a verified filesystem destination, or a
// -32011. This is the process's one path-traversal boundary: the string can come from a
// web page via capture.offer, or from any same-UID process via download.add.
//
// The rules and every adversarial case are in daemon/docs/safepath-adversarial.md, which
// was written before this header. In short: sanitize the leaf in isolation; require an
// absolute saveDir with no ".." component; walk it component-by-component with
// openat(O_NOFOLLOW) (never stat-then-open), creating missing tail dirs with mkdirat;
// then re-derive the final directory's canonical path from its fd and assert it is inside
// a canonical allowed root, component-wise.
#include <expected>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace velox::daemon::fs {
struct SafePathError {
enum class Kind {
not_absolute, // saveDir is relative or empty
dotdot, // saveDir contains a ".." component
outside_roots, // resolves outside every allowed root
symlink_component, // a path component (or the leaf) is a symlink
not_a_dir, // a component exists and is not a directory
bad_leaf, // filename empty / "." / ".." / contains '/' / all control bytes
name_too_long, // a component exceeds the filesystem limit
io, // any other errno from the walk
};
Kind kind = Kind::io;
std::string message;
};
// A verified destination. `dir` is absolute, canonical (symlink-free), exists as a
// directory, and is inside an allowed root. `leaf` is a sanitized single component.
struct SafeTarget {
std::string dir;
std::string leaf;
std::string full() const { return dir + "/" + leaf; }
};
// Sanitize one filename component: drop bytes < 0x20 and 0x7F, trim surrounding
// whitespace, strip trailing dots and spaces, reject ""/"."/".."/contains-'/', and cap at
// 255 bytes of UTF-8 without splitting a codepoint. Returns nullopt on reject.
std::optional<std::string> sanitize_leaf(std::string_view name);
// Expand a leading "~" (to $HOME) and realpath() a configured root. Call once per entry in
// saveTo.allowedRoots at startup / on settings.set; the result is what resolve_target
// compares against. nullopt if the path does not currently resolve.
std::optional<std::string> canonicalize_root(std::string_view configured);
// The gate. `save_dir` must be absolute and free of ".."; it is created (like `mkdir -p`)
// if missing, but only ever inside a canonical root and only via an O_NOFOLLOW walk.
// `filename_leaf` is sanitized here. `canonical_roots` is canonicalize_root() applied to
// every allowed root (empty => nothing is permitted).
std::expected<SafeTarget, SafePathError> resolve_target(
std::string_view save_dir, std::string_view filename_leaf,
const std::vector<std::string>& canonical_roots);
} // namespace velox::daemon::fs