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:
2026-09-10 19:37:01 +04:00
co-authored by Claude Sonnet 5
parent 1914eed7db
commit ab479e7885
8 changed files with 528 additions and 2 deletions
+1
View File
@@ -18,3 +18,4 @@ veloxd_test(ws_frame LIBS veloxd_rpc)
veloxd_test(ws_server LIBS veloxd_rpc)
veloxd_test(sched_window LIBS veloxd_sched)
veloxd_test(sched_governor LIBS veloxd_sched)
veloxd_test(safepath LIBS veloxd_fs)
+144
View File
@@ -0,0 +1,144 @@
// The path-traversal boundary. Every row here is a case from
// daemon/docs/safepath-adversarial.md. Uses a real temp tree as the allowed root.
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <cstdlib>
#include <string>
#include <vector>
#include "check.hpp"
#include "fs/safepath.hpp"
using namespace velox::daemon::fs;
using K = SafePathError::Kind;
namespace {
std::string g_root; // canonical allowed root (a temp dir)
std::string g_outside; // a canonical dir outside the root
std::vector<std::string> roots() { return {g_root}; }
bool is_err(const std::expected<SafeTarget, SafePathError>& r, K k) {
return !r.has_value() && r.error().kind == k;
}
} // namespace
void run() {
char t1[] = "/tmp/velox-sp-root-XXXXXX";
char t2[] = "/tmp/velox-sp-out-XXXXXX";
g_root = ::mkdtemp(t1);
g_outside = ::mkdtemp(t2);
CHECK(!g_root.empty() && !g_outside.empty());
// realpath the root the way canonicalize_root would (⦅/tmp⦆ is a symlink on some distros).
g_root = canonicalize_root(g_root).value_or(g_root);
g_outside = canonicalize_root(g_outside).value_or(g_outside);
// --- happy paths -------------------------------------------------------------
{
auto r = resolve_target(g_root, "iso.img", roots());
CHECK(r.has_value());
if (r) {
CHECK_EQ(r->dir, g_root);
CHECK_EQ(r->leaf, std::string("iso.img"));
}
}
{ // A19: redundant "." and a fresh subdir created within the root
auto r = resolve_target(g_root + "/./sub/.", "x", roots());
CHECK(r.has_value());
if (r) CHECK_EQ(r->dir, g_root + "/sub");
}
{ // A18: non-ASCII is fine
auto r = resolve_target(g_root + "/新しい", "映画.mkv", roots());
CHECK(r.has_value());
}
// --- traversal / containment ------------------------------------------------
CHECK(is_err(resolve_target(g_root + "/../etc", "x", roots()), K::dotdot)); // A1
CHECK(is_err(resolve_target(g_root + "/a/b/../../../etc", "x", roots()), K::dotdot)); // A2
CHECK(is_err(resolve_target("/etc", "x", roots()), K::outside_roots)); // A3
CHECK(is_err(resolve_target(g_root + "-evil", "x", roots()), K::outside_roots)); // A4
CHECK(is_err(resolve_target("relative/path", "x", roots()), K::not_absolute)); // A21
CHECK(is_err(resolve_target("", "x", roots()), K::not_absolute)); // A15/A21
// --- symlink component points outside (A7) --------------------------------
{
const std::string link = g_root + "/link-out";
::symlink(g_outside.c_str(), link.c_str());
auto r = resolve_target(link, "x", roots());
CHECK(is_err(r, K::symlink_component) || is_err(r, K::outside_roots));
// Even naming a path *through* the symlink must not escape.
auto r2 = resolve_target(link + "/deeper", "x", roots());
CHECK(is_err(r2, K::symlink_component) || is_err(r2, K::outside_roots));
::unlink(link.c_str());
}
// --- destination is a file, not a dir (A17) -----------------------------
{
const std::string f = g_root + "/a-file";
::close(::open(f.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0644));
CHECK(is_err(resolve_target(f, "x", roots()), K::not_a_dir));
::unlink(f.c_str());
}
// --- leaf sanitization ------------------------------------------------
CHECK(is_err(resolve_target(g_root, "../.bashrc", roots()), K::bad_leaf)); // A5
CHECK(is_err(resolve_target(g_root, "sub/dir/file", roots()), K::bad_leaf)); // A6
CHECK(is_err(resolve_target(g_root, std::string("f\0.iso", 6), roots()), K::bad_leaf) ||
resolve_target(g_root, std::string("f\0.iso", 6), roots()).value().leaf == "f.iso"); // A9: NUL stripped
CHECK(is_err(resolve_target(g_root, ".", roots()), K::bad_leaf)); // A11
CHECK(is_err(resolve_target(g_root, "", roots()), K::bad_leaf)); // A11
// sanitize_leaf directly for the finicky cases
CHECK(!sanitize_leaf(std::string("\r\nSet-Cookie: x").append(".iso")).has_value() ||
sanitize_leaf(std::string("\r\nSet-Cookie: x").append(".iso")).value().find('\n') ==
std::string::npos); // A10
CHECK_EQ(sanitize_leaf(" spaced.iso ").value(), std::string("spaced.iso")); // A20
CHECK_EQ(sanitize_leaf("dots...").value(), std::string("dots")); // A20
CHECK(!sanitize_leaf("...").has_value());
CHECK_EQ(sanitize_leaf("con").value(), std::string("con")); // A12: allowed on Linux
{
std::string huge(300, 'a');
auto s = sanitize_leaf(huge);
CHECK(s.has_value());
if (s) CHECK(s->size() <= 255); // A13
}
{
// A13: a multibyte codepoint straddling the 255-byte cut is not split.
std::string s(253, 'a');
s += "\xE2\x82\xAC"; // euro sign, 3 bytes -> ends at 256, must be dropped whole
auto out = sanitize_leaf(s);
CHECK(out.has_value());
if (out) {
CHECK(out->size() <= 255);
// no trailing partial sequence
CHECK((static_cast<unsigned char>(out->back()) & 0x80) == 0);
}
}
// --- an empty root list permits nothing --------------------------------
CHECK(is_err(resolve_target(g_root, "x", {}), K::outside_roots));
// --- a symlinked root canonicalizes to its target (A16) --------------
{
char t3[] = "/tmp/velox-sp-realroot-XXXXXX";
const std::string real_root = ::mkdtemp(t3);
const std::string link_root = g_outside + "/root-link";
::symlink(real_root.c_str(), link_root.c_str());
const auto canon = canonicalize_root(link_root);
CHECK(canon.has_value());
if (canon) {
CHECK_EQ(*canon, canonicalize_root(real_root).value());
auto r = resolve_target(link_root + "/movies", "a.mkv", {*canon});
CHECK(r.has_value());
}
::unlink(link_root.c_str());
::rmdir(real_root.c_str());
}
}
TEST_MAIN()