Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## [1.1.23] — 2026-07-07

**Security: decide daemon auth locality from the accepting socket, not
a client-supplyable `REMOTE_ADDR` header.**

`crated` grants full trust (bypassing the bearer token AND the pool
ACL) to "Unix-socket peers", and detected them with
`req.get_header_value("REMOTE_ADDR").empty()`. cpp-httplib stores
request headers in a multimap and `get_header_value` returns the first
match, so a remote client on the TCP listener that sent its own empty
`REMOTE_ADDR:` header could shadow the server-set value and be mistaken
for a local socket peer — a potential complete bypass of the token
layer (including `POST /api/v1/privops/:verb`, uid=0) when the TCP
listener is enabled. The same header keyed the rate-limit bucket, so a
client could also rotate its own bucket key.

The daemon already runs two separate `httplib::Server` instances (TCP
and Unix socket). `registerRoutes` now takes an `isUnixListener` flag
and installs a pre-routing handler that, on every request, **erases any
client-supplied `X-Crated-Listener` header and stamps the authoritative
value** (`unix` / `tcp`) from the server instance that accepted the
connection. `isUnixSocketPeer` reads that marker instead of
`REMOTE_ADDR`; `getClientId` keys the rate limiter on the marker plus
`req.remote_addr` (the address httplib fills from the accepted socket,
not a header). A missing marker reads as untrusted TCP — fail-closed.

This is a structural change on the httplib-coupled auth path (no pure
unit surface); it is compile-gated by the FreeBSD build and warrants a
runtime check against a live TCP+UDS daemon.

## [1.1.22] — 2026-07-07

**Security & robustness: four fixes from a fresh project-wide audit.**
Expand Down
29 changes: 10 additions & 19 deletions TODO
Original file line number Diff line number Diff line change
Expand Up @@ -43,27 +43,18 @@ unit suite can't provide, so they are NOT being fixed blind):
full FreeBSD workflow (or a self-hosted runner) to validate.

Deferred from the 2026-07 second-pass audit (its four clear, testable
findings shipped as 1.1.22; these three remain because they touch the
auth and concurrency paths and want on-hardware validation before
findings shipped as 1.1.22; the auth-locality finding was fixed in
1.1.23 — see below; these two remain because they touch the
concurrency/confinement paths and want on-hardware validation before
shipping — do NOT fix blind):

* (security, HIGH if TCP listener enabled) daemon/auth.cpp decides
connection locality — and thus full trust — from a header-map
lookup, `req.get_header_value("REMOTE_ADDR").empty()`, instead of
the connection itself. A UDS peer is treated as unauthenticated-but-
fully-trusted (bypasses bearer token AND pool ACL); the empty-string
signal is what a remote TCP client could shadow by sending its own
empty `REMOTE_ADDR:` header (cpp-httplib stores request headers in a
multimap; get_header_value returns the first match, which may be the
client's). Exploitability depends on the pkg-vendored cpp-httplib
version's handling of client-supplied reserved headers, which is not
in-repo. The daemon already runs SEPARATE TCP and UDS `httplib::Server`
instances (daemon/server.cpp), so the robust fix is to thread an
explicit `isUnixListener` flag into the auth path (or read the
non-spoofable `req.remote_addr` struct field), never a header lookup.
Also fix getClientId() (daemon/routes.cpp) which keys the rate-limit
bucket on the same header. Touches the auth path — validate against a
live TCP+UDS daemon before shipping.
[FIXED in 1.1.23] daemon/auth.cpp connection-locality decided from a
client-supplyable REMOTE_ADDR header. Replaced with a non-spoofable
X-Crated-Listener marker stamped per server instance (TCP vs UDS) by
registerRoutes' pre-routing handler; isUnixSocketPeer and getClientId
now read that marker / req.remote_addr instead of the header. Still
worth exercising against a live TCP+UDS daemon on a FreeBSD host to
confirm the pre-routing handler fires on every path.

* (correctness/security, MED) lib/network_lease.cpp + network_lease6.cpp
take the exclusive flock on the lease FILE's inode (openLocked opens
Expand Down
4 changes: 2 additions & 2 deletions cli/args.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -753,7 +753,7 @@ Args parseArguments(int argc, char** argv, unsigned &processed) {
args.noColor = true;
break;
} else if (strEq(argv[a], "--version")) {
std::cout << "crate 1.1.22" << std::endl;
std::cout << "crate 1.1.23" << std::endl;
exit(0);
} else if (auto argShort = isShort(argv[a])) {
switch (argShort) {
Expand All @@ -764,7 +764,7 @@ Args parseArguments(int argc, char** argv, unsigned &processed) {
args.logProgress = true;
break;
case 'V':
std::cout << "crate 1.1.22" << std::endl;
std::cout << "crate 1.1.23" << std::endl;
exit(0);
default:
err("unsupported short option '%s'", argv[a]);
Expand Down
26 changes: 17 additions & 9 deletions daemon/auth.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,25 @@ static std::string sha256hex(const std::string &input) {
return "sha256:" + ss.str();
}

// Check if request comes from a Unix socket. cpp-httplib leaves
// REMOTE_ADDR empty for unix-socket peers; that's our signal.
// Check if the request arrived on the Unix-socket listener (local,
// trusted) rather than the TCP listener (remote, token-gated).
//
// Tightening to actually verify the peer's uid/gid via
// getpeereid(2) requires the connection's underlying fd, which
// httplib doesn't expose. Until we fork that code path, the unix
// socket file's mode (default 0660 owned by root:wheel) is the
// effective access control.
// 1.1.23: this is decided from the `X-Crated-Listener` marker that
// registerRoutes' pre-routing handler stamps from the accepting server
// instance — AFTER erasing any client-supplied copy — so it is
// authoritative. The previous check keyed on `REMOTE_ADDR.empty()`,
// which a remote TCP client could shadow by sending its own empty
// `REMOTE_ADDR:` header (cpp-httplib stores request headers in a
// multimap; get_header_value returns the first match, the client's).
// A missing marker (pre-routing handler somehow skipped) reads as NOT a
// socket peer → token required → fail-closed.
//
// The unix socket file's mode (default 0660 owned by root:wheel)
// remains the access control for who may reach that listener; a
// getpeereid(2) uid/gid check would need the connection fd, which
// httplib doesn't expose (see the libnv privops socket for that).
static bool isUnixSocketPeer(const httplib::Request &req) {
auto remoteAddr = req.get_header_value("REMOTE_ADDR");
return remoteAddr.empty();
return req.get_header_value("X-Crated-Listener") == "unix";
}

bool isAuthorized(const httplib::Request &req, const Config &config,
Expand Down
31 changes: 28 additions & 3 deletions daemon/routes.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,14 @@ static bool checkRateLimit(const std::string &clientId, const std::string &endpo
}

static std::string getClientId(const httplib::Request &req) {
auto addr = req.get_header_value("REMOTE_ADDR");
return addr.empty() ? "unix" : addr;
// 1.1.23: key the rate-limit bucket on trustworthy signals, not a
// client-supplyable REMOTE_ADDR header (which let a client rotate its
// own bucket key). Unix-socket peers share one "unix" bucket; TCP
// peers key on req.remote_addr — the authoritative address httplib
// fills from the accepted socket, which a client cannot spoof.
if (req.get_header_value("X-Crated-Listener") == "unix")
return "unix";
return req.remote_addr.empty() ? "tcp" : req.remote_addr;
}

// Cap constants live in daemon/rate_limit.h since 0.7.15.
Expand Down Expand Up @@ -1023,7 +1029,26 @@ static void handlePrivOp(const httplib::Request &req, httplib::Response &res,

// --- Route registration ---

void registerRoutes(httplib::Server &srv, const Config &config) {
void registerRoutes(httplib::Server &srv, const Config &config,
bool isUnixListener) {
// 1.1.23: stamp a non-spoofable listener marker on EVERY request
// before it reaches a handler. Trust locality — "this peer arrived on
// the root-owned Unix socket, treat as local admin" — must be decided
// from which server instance accepted the connection, never from a
// client-supplyable REMOTE_ADDR header (a remote TCP client could send
// its own empty REMOTE_ADDR and be mistaken for a socket peer). We
// erase any client-sent copy of the marker first, then set the
// authoritative value; auth (isUnixSocketPeer) and getClientId read
// it. If the pre-routing handler ever fails to run, the marker is
// absent → treated as untrusted TCP → fail-closed.
srv.set_pre_routing_handler(
[isUnixListener](const httplib::Request &req, httplib::Response &) {
auto &headers = const_cast<httplib::Request &>(req).headers;
headers.erase("X-Crated-Listener");
headers.emplace("X-Crated-Listener", isUnixListener ? "unix" : "tcp");
return httplib::Server::HandlerResponse::Unhandled;
});

// Health check (no rate limit)
srv.Get("/healthz", [](const httplib::Request &, httplib::Response &res) {
res.set_content("{\"status\":\"ok\"}", "application/json");
Expand Down
9 changes: 8 additions & 1 deletion daemon/routes.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ namespace httplib { class Server; }

namespace Crated {

void registerRoutes(httplib::Server &srv, const Config &config);
// 1.1.23: isUnixListener says which server instance this is — the
// Unix-socket listener (local, trusted) or the TCP listener (remote,
// token-gated). It is stamped onto every request as a non-spoofable
// marker so auth locality is decided from the accepting socket, not a
// client-supplyable REMOTE_ADDR header. Defaults to false (fail-closed:
// an un-flagged caller is treated as untrusted TCP).
void registerRoutes(httplib::Server &srv, const Config &config,
bool isUnixListener = false);

}
9 changes: 6 additions & 3 deletions daemon/server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ Server::Server(const Config &config)
impl_->httpSrv = std::make_unique<httplib::Server>();
}

// Register all REST routes
registerRoutes(*impl_->httpSrv, config_);
// Register all REST routes. This is the TCP listener (remote,
// token-gated) — isUnixListener = false.
registerRoutes(*impl_->httpSrv, config_, /*isUnixListener=*/false);
}

Server::~Server() {
Expand All @@ -66,7 +67,9 @@ void Server::start() {

impl_->unixThread = std::thread([this]() {
httplib::Server udsSrv;
registerRoutes(udsSrv, config_);
// This is the Unix-socket listener (local, root-owned socket) —
// isUnixListener = true, so its peers are treated as trusted.
registerRoutes(udsSrv, config_, /*isUnixListener=*/true);
udsSrv.set_address_family(AF_UNIX);
udsSrv.listen(config_.unixSocket, 0);
});
Expand Down
4 changes: 2 additions & 2 deletions docs/trust-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
operators on one machine) and contributors extending the privileged
surface.

**Applies to:** 1.1.22 (rootless model + per-tenant authz series 1.1.12 →
**Applies to:** 1.1.23 (rootless model + per-tenant authz series 1.1.12 →
1.1.17 covering every privops verb that carries an operator-controlled
ownership signal). For the ≤ 0.9.x setuid model and the migration, see
[`rootless-migration.md`](rootless-migration.md).
Expand Down Expand Up @@ -62,7 +62,7 @@ surface; 1.0.0 removed the setuid bit (`Makefile`, comment at the
operator and delegates privileged operations to crated(8)"*).

The single-trust-domain property did **not** disappear — it relocated.
Reasoning about isolation on 1.1.22 means reasoning about who can reach
Reasoning about isolation on 1.1.23 means reasoning about who can reach
**privops**, not who can run `crate(1)`.

---
Expand Down
4 changes: 2 additions & 2 deletions docs/trust-model.uk.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
(кілька операторів на одній машині), і контрибʼютори, які розширюють
привілейовану поверхню.

**Стосується:** 1.1.22 (rootless-модель + серія per-tenant authz 1.1.12 →
**Стосується:** 1.1.23 (rootless-модель + серія per-tenant authz 1.1.12 →
1.1.17 покриває кожен privops-верб з operator-controlled ownership-
сигналом). Про ≤ 0.9.x setuid-модель і міграцію див.
[`rootless-migration.md`](rootless-migration.md).
Expand Down Expand Up @@ -62,7 +62,7 @@ privops-сокета, ніколи admin-токен і ніколи Unix-сок
privileged operations to crated(8)»*).

Властивість «єдиний домен довіри» **не зникла** — вона переїхала.
Міркувати про ізоляцію на 1.1.22 — це міркувати про те, хто має доступ
Міркувати про ізоляцію на 1.1.23 — це міркувати про те, хто має доступ
до **privops**, а не хто може запустити `crate(1)`.

---
Expand Down
Loading