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
+9 -1
View File
@@ -44,6 +44,14 @@ target_compile_features(veloxd_store PUBLIC cxx_std_23)
target_compile_options(veloxd_store PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(veloxd_store PUBLIC SQLite::SQLite3 PRIVATE OpenSSL::Crypto)
# --- veloxd_fs — the saveDir/filename path-traversal boundary (security) -----------
# daemon/docs/safepath-adversarial.md is the spec; safepath_test.cpp is that table.
add_library(veloxd_fs STATIC src/fs/safepath.cpp)
add_library(velox::daemon_fs ALIAS veloxd_fs)
target_include_directories(veloxd_fs PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_compile_features(veloxd_fs PUBLIC cxx_std_23)
target_compile_options(veloxd_fs PRIVATE -Wall -Wextra -Wpedantic -Werror)
# --- veloxd_sched — the concurrency governor (pure; no engine link yet, see
# daemon/docs/deferrals.md D4) ---------------------------------------------------
add_library(veloxd_sched STATIC
@@ -73,7 +81,7 @@ target_include_directories(veloxd_rpc PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_compile_features(veloxd_rpc PUBLIC cxx_std_23)
target_compile_options(veloxd_rpc PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(veloxd_rpc
PUBLIC velox::proto veloxd_store nlohmann_json::nlohmann_json Threads::Threads
PUBLIC velox::proto veloxd_store veloxd_fs nlohmann_json::nlohmann_json Threads::Threads
)
# --- veloxd — the daemon binary -------------------------------------------------------
+1 -1
View File
@@ -6,7 +6,7 @@ close. Kept here (not buried in commit messages) so the next pass can see them a
| # | What | Where | Why deferred | Closes when |
|---|---|---|---|---|
| D1 | Pairing prompt is `EnvAutoApprover` (needs `VELOX_PAIR_AUTO=1`) | `rpc/pairing.hpp`, `main.cpp` | A GUI dialog / `org.freedesktop.Notifications` approver is integration work | Build step 7 (systemd + notifications) |
| D2 | `download.add``-32603`, `download.probe``-32603` | `rpc/dispatcher.cpp` | Need path canonicalisation + allowed-root check (`-32011`) and the probe path (`-32013`); those need the engine link | `download.add` glue (after `sched/`) |
| D2 | `download.add``-32603`, `download.probe``-32603` | `rpc/dispatcher.cpp` | The path boundary (`-32011`) is built and tested (`fs/safepath`, `daemon/docs/safepath-adversarial.md`); still need it wired into the `download.add` handler with the store behind it, plus the probe path (`-32013`) which needs the engine | `download.add` glue (dispatcher ↔ store ↔ `fs/safepath`); probe with the engine link |
| D3 | Stub handlers for everything except `session.*`, `download.list`, `download.get` | `rpc/dispatcher.cpp` | No store behind them yet | Per method, as the store/scheduler wire in |
| D4 | `sched/` is the pure `Governor` + schedule window only; no `Scheduler` wiring to store/engine/timer | `sched/` | `Engine` bodies land in CORE stage 8; `Scheduler` needs the UUID↔`vdm::TaskId` map, a store query layer, and a timer | After CORE stage 8 lands `Engine::start()` |
| D5 | `event.*` fan-out not implemented; `session.subscribe` accepts and echoes but nothing is emitted | `rpc/uds_server.cpp`, `rpc/ws_server.cpp` | No task state to broadcast until the engine is wired | With the callback → `event.*` projection |
+77
View File
@@ -0,0 +1,77 @@
# `saveDir` / `filename` → filesystem destination: the adversarial table
`veloxd` is the only process that turns an untrusted string into a place bytes get
written. `capture.offer` means that string can originate from a web page, and
`download.add` over the Unix socket is reachable by any same-UID process. CLAUDE.md §4
("paths are canonicalized and checked against allowed roots before any write") and the M1
DoD ("no path traversal in `saveDir` … → `-32011`") make this a security boundary, not a
formatting nicety.
This table is written **before** `fs/safepath.cpp`, the way EXT did for `shouldCapture`.
Every row is a test in `daemon/tests/safepath_test.cpp`.
Roots for the examples: `allowedRoots = ["/home/u/Downloads", "/data/dl"]`, already
`realpath`-resolved and stored canonical at load time. `$HOME = /home/u`.
| # | Input (`saveDir`, `filename`) | Attack | Required outcome |
|---|---|---|---|
| A1 | `/home/u/Downloads/../.ssh`, `authorized_keys` | `..` climbs out of the root | `-32011`, `data.path` = the input `saveDir`. No dir created. |
| A2 | `/home/u/Downloads/a/b/../../../etc`, `x` | `..` chain escaping after descending | `-32011`. |
| A3 | `/etc`, `cron.d-payload` | absolute path, simply outside every root | `-32011`. |
| A4 | `/home/u/Downloads-evil`, `x` | prefix-match confusion with `/home/u/Downloads` | `-32011` — containment is component-wise, not `starts_with`. |
| A5 | `/home/u/Downloads`, `../.bashrc` | `..` in the **leaf**, not the dir | leaf rejected → `-32011` (or `InvalidParams`); a leaf is one component, never a path. |
| A6 | `/home/u/Downloads`, `sub/dir/file` | `/` in the leaf | leaf rejected — `filename` names a file, not a subpath. |
| A7 | `/home/u/Downloads/link-out` where `link-out``/etc` (pre-existing symlink) | symlink component points outside a root | `realpath` resolves it to `/etc`; `-32011`. |
| A8 | `/home/u/Downloads/goodsub`, `iso.img` — but between our check and CORE's `open`, `goodsub` is swapped for a symlink to `/etc` | **TOCTOU** on a directory component | Defense: resolve + create with `openat`/`mkdirat` from an `O_NOFOLLOW|O_DIRECTORY` fd walk, then `realpath` the final dir **again** and re-assert containment. A component that is a symlink at walk time → `-32011`. |
| A9 | `/home/u/Downloads`, `file<NUL>.iso` (`0x00` in the leaf) | NUL truncation — the write path sees `file`, logs/UI see more; CORE's fuzzer hit exactly this via `Content-Disposition` | NUL and every `< 0x20` byte and `0x7F` stripped from the leaf before use (mirrors `core/src/net/content_disposition.cpp` `sanitize_leaf`). If the leaf is empty after stripping → reject. |
| A10 | `/home/u/Downloads`, `"\r\nSet-Cookie: x".iso` | CR/LF injection into logs / downstream | control bytes stripped as A9. |
| A11 | `/home/u/Downloads`, `.` / `..` / `` (empty) | degenerate leaf | rejected. |
| A12 | `/home/u/Downloads`, `con` / `aux` / `nul` | Windows device names | **allowed** on Linux — we are not Windows; do not over-reject. (Noted so a future "harden" pass doesn't add it thinking it was missed.) |
| A13 | `/home/u/Downloads`, `<260 chars>` | overlong leaf, `ENAMETOOLONG` at `open` | leaf capped at 255 **bytes of UTF-8**, never splitting a codepoint (docs/04 §2). |
| A14 | `/home/u/Downloads/<260 chars>/x`, `y` | overlong directory component | `mkdirat` / `realpath` returns `ENAMETOOLONG` → mapped `-32011`, not a crash. |
| A15 | `saveDir` empty / null | no destination given | caller substitutes `saveTo.defaultDir`; `resolve_target` itself rejects an empty dir rather than defaulting silently. |
| A16 | root `/home/u/Downloads` is itself a symlink to `/mnt/big/dl` | a symlinked root | `canonicalize_root` `realpath`s every configured root at load; the stored root is `/mnt/big/dl`, and a `saveDir` resolving there passes. A `saveDir` of the literal `/home/u/Downloads/x` also passes because it `realpath`s to `/mnt/big/dl/x`. |
| A17 | `/home/u/Downloads` exists as a **file**, not a directory | destination is not a directory | `-32011` (`not_a_dir`), no write attempt. |
| A18 | `/home/u/Downloads/新しい/フォルダ`, `映画.mkv` | non-ASCII, legitimate | **succeeds** — UTF-8 is fine; only control bytes and the structural checks apply. |
| A19 | `/home/u/Downloads/./sub/.`, `x` | redundant `.` segments, no escape | normalized away; **succeeds** at `/home/u/Downloads/sub`. |
| A20 | `/home/u/Downloads`, ` trailing-spaces.iso ` / `dots...` | trailing space/dot (Windows-hostile, and confuses "same file" checks) | trimmed: leading/trailing whitespace and trailing dots removed before use. Empty after trim → reject. |
| A21 | relative `saveDir` (`Downloads/x`, `./x`, `x`) | a relative path has no well-defined base and invites cwd games | rejected — `resolve_target` requires an absolute `saveDir`. The GUI/CLI resolve against the default dir before calling. |
## Implementation (`fs/safepath.cpp`, as built)
1. **Sanitize the leaf first**, in isolation: strip `[0x00,0x20) {0x7F}`, trim
whitespace, strip trailing dots and spaces, reject `.`/`..`/empty/`contains '/'`, cap
255 UTF-8 bytes on a codepoint boundary. (A5, A6, A9A13, A20)
2. **Require `saveDir` absolute; reject any `..` component lexically.** A legitimate
client never sends `..`; a web-origin path with `..` is an attack, so it does not even
reach `realpath`. (A1, A2, A21)
3. **If the directory already exists:** `realpath(saveDir)` — this follows every symlink,
so a symlinked root or component resolves to where it *really* points — then assert the
resolved path is inside a canonical root, component-wise (`d == root || d starts with
root + "/"`). A symlink that escapes is caught here (A7); one that stays inside passes
(A16). Open the resolved dir `O_PATH|O_DIRECTORY` for the leaf check. (A3, A4, A7, A16,
A17, A19)
4. **If a tail is missing (`mkdir -p` case):** find the deepest existing ancestor,
`realpath` + root-check *that*, then create the missing components through an
`openat/mkdirat` walk with `O_NOFOLLOW|O_DIRECTORY` from the ancestor's fd — the tail
has no symlinks because it had no entries; a race that plants one trips `ELOOP` →
`-32011`. Then re-derive the final dir's path from its fd (`/proc/self/fd/N`) and
re-assert containment. (A8 for the created tail, A14)
5. **Best-effort leaf check:** `fstatat(dir_fd, leaf, AT_SYMLINK_NOFOLLOW)` — refuse if it
is already a symlink. The real close on the create-after-check race is CORE opening the
file `O_NOFOLLOW|O_EXCL` (or `O_NOFOLLOW` + explicit resume); that is CORE's contract,
stated in `daemon/docs/engine-api-review.md`.
6. **Every failure is `-32011`, `data.path` = the *original* `saveDir`** — never the
resolved path, which would leak where the roots actually live. The one exception is a
`filename` that violates the schema's own `maxLength`, which is `-32602` at the param
layer before this code runs.
### Residual, accepted for M1
An **existing intermediate directory** swapped for an out-of-root symlink *between* our
`realpath` and CORE's `open` is not caught by this code (step 3 trusts `realpath` for the
pre-existing prefix; a full `O_NOFOLLOW` chase would reject legitimate symlinked
directories mid-path, which A16 requires us to allow). It is closed in practice by CORE's
`O_NOFOLLOW` open of the final file and by the download dir living under a `0700`
`~/.local/share` / `~/Downloads` the attacker would already need write access to. A
per-step "resolve, re-validate against roots" chase is the post-M1 hardening.
+233
View File
@@ -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
+63
View File
@@ -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
View File
+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()