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,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()
|
||||
Reference in New Issue
Block a user