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
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
#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
|
||||
Reference in New Issue
Block a user