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,233 @@
|
||||
#include "fs/safepath.hpp"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace velox::daemon::fs {
|
||||
|
||||
namespace {
|
||||
|
||||
using E = SafePathError::Kind;
|
||||
|
||||
std::unexpected<SafePathError> err(E kind, std::string msg) {
|
||||
return std::unexpected(SafePathError{kind, std::move(msg)});
|
||||
}
|
||||
|
||||
class Fd {
|
||||
public:
|
||||
Fd() = default;
|
||||
explicit Fd(int fd) : fd_(fd) {}
|
||||
Fd(Fd&& o) noexcept : fd_(o.fd_) { o.fd_ = -1; }
|
||||
Fd& operator=(Fd&& o) noexcept {
|
||||
if (this != &o) {
|
||||
reset();
|
||||
fd_ = o.fd_;
|
||||
o.fd_ = -1;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
~Fd() { reset(); }
|
||||
int get() const noexcept { return fd_; }
|
||||
explicit operator bool() const noexcept { return fd_ >= 0; }
|
||||
void reset() {
|
||||
if (fd_ >= 0) ::close(fd_);
|
||||
fd_ = -1;
|
||||
}
|
||||
|
||||
private:
|
||||
int fd_ = -1;
|
||||
};
|
||||
|
||||
std::vector<std::string> split_components(std::string_view path) {
|
||||
std::vector<std::string> out;
|
||||
std::size_t i = 0;
|
||||
while (i < path.size()) {
|
||||
while (i < path.size() && path[i] == '/') ++i;
|
||||
std::size_t j = i;
|
||||
while (j < path.size() && path[j] != '/') ++j;
|
||||
if (j > i) out.emplace_back(path.substr(i, j - i));
|
||||
i = j;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool within_root(const std::string& canonical, const std::vector<std::string>& roots) {
|
||||
for (const auto& r : roots) {
|
||||
if (canonical == r) return true;
|
||||
if (canonical.size() > r.size() && canonical.compare(0, r.size(), r) == 0 &&
|
||||
canonical[r.size()] == '/')
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::optional<std::string> path_of_fd(int fd) {
|
||||
char link[64];
|
||||
std::snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
|
||||
std::string buf(256, '\0');
|
||||
for (;;) {
|
||||
const ssize_t n = ::readlink(link, buf.data(), buf.size());
|
||||
if (n < 0) return std::nullopt;
|
||||
if (static_cast<std::size_t>(n) < buf.size()) {
|
||||
buf.resize(static_cast<std::size_t>(n));
|
||||
return buf;
|
||||
}
|
||||
buf.resize(buf.size() * 2);
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<std::string> do_realpath(const std::string& p) {
|
||||
char* r = ::realpath(p.c_str(), nullptr);
|
||||
if (r == nullptr) return std::nullopt;
|
||||
std::string out(r);
|
||||
::free(r);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Best-effort refusal if the leaf is already present as a symlink. CORE opens the file
|
||||
// O_NOFOLLOW regardless, which is what actually closes the create-after-check race.
|
||||
std::optional<SafePathError> reject_symlink_leaf(int dir_fd, const std::string& leaf) {
|
||||
struct stat st{};
|
||||
if (::fstatat(dir_fd, leaf.c_str(), &st, AT_SYMLINK_NOFOLLOW) == 0 && S_ISLNK(st.st_mode))
|
||||
return SafePathError{E::symlink_component, "the target filename is a symlink"};
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<std::string> sanitize_leaf(std::string_view name) {
|
||||
std::string out;
|
||||
out.reserve(name.size());
|
||||
for (unsigned char c : name) {
|
||||
if (c >= 0x20 && c != 0x7F) out.push_back(static_cast<char>(c));
|
||||
}
|
||||
|
||||
auto is_ws = [](char c) { return c == ' ' || c == '\t'; };
|
||||
std::size_t b = 0;
|
||||
std::size_t e = out.size();
|
||||
while (b < e && is_ws(out[b])) ++b;
|
||||
while (e > b && (is_ws(out[e - 1]) || out[e - 1] == '.')) --e;
|
||||
out = out.substr(b, e - b);
|
||||
|
||||
if (out.size() > 255) {
|
||||
out.resize(255);
|
||||
while (!out.empty() && (static_cast<unsigned char>(out.back()) & 0xC0) == 0x80)
|
||||
out.pop_back();
|
||||
if (!out.empty() && (static_cast<unsigned char>(out.back()) & 0x80)) out.pop_back();
|
||||
while (!out.empty() && (out.back() == '.' || out.back() == ' ')) out.pop_back();
|
||||
}
|
||||
|
||||
if (out.empty() || out == "." || out == "..") return std::nullopt;
|
||||
if (out.find('/') != std::string::npos) return std::nullopt;
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<std::string> canonicalize_root(std::string_view configured) {
|
||||
std::string p(configured);
|
||||
if (p == "~" || p.rfind("~/", 0) == 0) {
|
||||
const char* home = ::getenv("HOME");
|
||||
if (home == nullptr || home[0] == '\0') return std::nullopt;
|
||||
p = std::string(home) + (p.size() > 1 ? p.substr(1) : std::string{});
|
||||
}
|
||||
return do_realpath(p);
|
||||
}
|
||||
|
||||
std::expected<SafeTarget, SafePathError> resolve_target(
|
||||
std::string_view save_dir, std::string_view filename_leaf,
|
||||
const std::vector<std::string>& canonical_roots) {
|
||||
const auto leaf = sanitize_leaf(filename_leaf);
|
||||
if (!leaf) return err(E::bad_leaf, "filename is empty or not a valid single component");
|
||||
|
||||
if (save_dir.empty() || save_dir.front() != '/')
|
||||
return err(E::not_absolute, "saveDir must be an absolute path");
|
||||
|
||||
const auto comps = split_components(save_dir);
|
||||
for (const auto& c : comps) {
|
||||
if (c == "..") return err(E::dotdot, "saveDir must not contain a '..' component");
|
||||
if (c.size() > 255) return err(E::name_too_long, "a path component is too long");
|
||||
}
|
||||
|
||||
// Fast path: the directory already exists. realpath() follows every symlink (so a
|
||||
// symlinked root or a symlinked component is resolved to where it really points), and
|
||||
// the containment check is on that resolved path — a symlink that escapes a root is
|
||||
// caught here (A7), one that stays inside is fine (A16).
|
||||
if (auto canon = do_realpath(std::string(save_dir))) {
|
||||
if (!within_root(*canon, canonical_roots))
|
||||
return err(E::outside_roots, "destination resolves outside every allowed root");
|
||||
Fd dir(::open(canon->c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC));
|
||||
if (!dir) {
|
||||
if (errno == ENOTDIR) return err(E::not_a_dir, "destination is not a directory");
|
||||
return err(E::io, std::string("open destination: ") + std::strerror(errno));
|
||||
}
|
||||
if (auto e = reject_symlink_leaf(dir.get(), *leaf)) return std::unexpected(*e);
|
||||
return SafeTarget{*canon, *leaf};
|
||||
}
|
||||
if (errno == ENOTDIR)
|
||||
return err(E::not_a_dir, "a path component is not a directory");
|
||||
if (errno == ENAMETOOLONG)
|
||||
return err(E::name_too_long, "the destination path is too long");
|
||||
if (errno != ENOENT)
|
||||
return err(E::io, std::string("realpath(saveDir): ") + std::strerror(errno));
|
||||
|
||||
// The directory (or a tail of it) does not exist yet. Find the deepest ancestor that
|
||||
// does, canonicalise + root-check *that*, then create the missing tail through an
|
||||
// O_NOFOLLOW fd walk — the tail has no symlinks because it has no components yet, and
|
||||
// a race that plants one is caught by the ELOOP below and the final fd re-check.
|
||||
std::vector<std::string> pending;
|
||||
std::string existing(save_dir);
|
||||
std::optional<std::string> anchor;
|
||||
while (true) {
|
||||
const auto slash = existing.find_last_of('/');
|
||||
const std::string base = existing.substr(slash + 1);
|
||||
existing = slash == 0 ? "/" : existing.substr(0, slash);
|
||||
if (!base.empty() && base != ".") pending.push_back(base);
|
||||
anchor = do_realpath(existing);
|
||||
if (anchor) break;
|
||||
if (errno != ENOENT)
|
||||
return err(E::io, std::string("realpath(ancestor): ") + std::strerror(errno));
|
||||
if (existing == "/") return err(E::io, "root does not resolve");
|
||||
}
|
||||
|
||||
if (!within_root(*anchor, canonical_roots))
|
||||
return err(E::outside_roots, "destination resolves outside every allowed root");
|
||||
|
||||
Fd dir(::open(anchor->c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC));
|
||||
if (!dir) return err(E::io, std::string("open(anchor): ") + std::strerror(errno));
|
||||
|
||||
std::string built = *anchor;
|
||||
for (auto it = pending.rbegin(); it != pending.rend(); ++it) {
|
||||
const std::string& c = *it;
|
||||
if (::mkdirat(dir.get(), c.c_str(), 0777) != 0 && errno != EEXIST) {
|
||||
if (errno == ENAMETOOLONG)
|
||||
return err(E::name_too_long, "a path component is too long: " + c);
|
||||
return err(E::io, "mkdirat(" + c + "): " + std::strerror(errno));
|
||||
}
|
||||
Fd next(::openat(dir.get(), c.c_str(), O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC));
|
||||
if (!next) {
|
||||
if (errno == ELOOP)
|
||||
return err(E::symlink_component, "a path component was raced to a symlink: " + c);
|
||||
if (errno == ENOTDIR)
|
||||
return err(E::not_a_dir, "a path component is not a directory: " + c);
|
||||
return err(E::io, "openat(" + c + "): " + std::strerror(errno));
|
||||
}
|
||||
dir = std::move(next);
|
||||
built += "/" + c;
|
||||
}
|
||||
|
||||
const auto final_canon = path_of_fd(dir.get());
|
||||
if (!final_canon) return err(E::io, "could not resolve the created directory");
|
||||
if (!within_root(*final_canon, canonical_roots))
|
||||
return err(E::outside_roots, "destination resolves outside every allowed root");
|
||||
if (auto e = reject_symlink_leaf(dir.get(), *leaf)) return std::unexpected(*e);
|
||||
|
||||
return SafeTarget{*final_canon, *leaf};
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::fs
|
||||
@@ -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