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
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,70 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## [1.1.17] — 2026-06-10

**Security: close three cross-tenant holes in the privops authz gate.**
A self-audit of the 1.1.12 → 1.1.15 per-tenant gate series found three
verbs whose ownership check was incomplete or missing. In the strict
multi-tenant (rootless + `path_master_prefix`) model these let one
operator reach another tenant's resources. (In the single-trust-domain
model — privops access = root-equivalent — these are gate-completeness
fixes, not new escalation; but they were holes in gates documented as
closed.)

### Fixed

- **`mount_nullfs` source was unchecked.** 1.1.14 gated the mount
`target` (must be inside an owned jail) but never the `source`. An
operator could bind-mount **another tenant's** path prefix into their
own jail and read it. Now the source is gated cross-tenant: a source
inside `path_master_prefix/<other-uid>` is denied `403`
(`DenyForeignSource`). Host paths (`/etc`, …) and GUI runtime sockets
(`/tmp/.X11-unix`, the host Wayland/PulseAudio sockets) fall outside
every tenant prefix and stay allowed — privops is a single trust
domain w.r.t. the host; the gate only enforces tenant-vs-tenant.
- **`configure_iface` was in the host-global Allow arm** despite
carrying a `jid` and `jexec`-ing `ifconfig` **inside** that jail (plus
moving a host iface into its vnet). Naming a foreign `jid` was a
cross-tenant operation. Now gated by `jid` against the jid→owner
registry (like `signal_jail`).
- **`reclaim_iface_from_vnet` was ungated** despite naming a jail
(`jail_name`) and pulling an iface out of its vnet — a foreign name
could steal/DoS another tenant's networking. Now gated by `jail_name`
(like `destroy_jail`).

### Mechanism

- `PerUserEnvPure::Env` gains `pathMasterPrefix` (the bare tenant root,
no uid) so the authz layer can distinguish "inside another tenant's
space" from "a host path".
- `PrivOpsAuthzPure`: new `Decision::DenyForeignSource`,
`Request::source`, a `sourceForeign()` helper, and the three verbs
moved to their correct gated groups.
- Dispatcher fills `req.source` (from `source`) for `mount_nullfs` and
`req.jailName` (from `jail_name`) for `reclaim_iface_from_vnet`.

### Safety / compatibility

The gate only **denies on a confirmed-foreign target**; own and
registry-unknown targets always Allow (the bootstrap concession), so
legitimate `crate run` flows — which operate on the operator's own
freshly-created (and registered) jail — are unaffected regardless of
operation ordering. Deployments that didn't configure
`path_master_prefix` get the unchanged opt-in behavior (source gate
off). New `privops_authz_pure_test` cases cover own/host/GUI-socket
Allow, foreign-tenant Deny, foreign-target precedence, unconfigured
opt-in, the two interface verbs' own/foreign/unknown cases, and a
host-global-verbs-still-allowed regression. `per_user_env_pure_test`
asserts `pathMasterPrefix` composition.

### Still by design

Genuinely host-global verbs (`teardown_iface`, `set_iface_up`,
`bridge_*`, `add_pf_rule`, `add_ipfw_rule`, `configure_ipfw_nat`,
`create_epair`) remain ungated — they act on shared host state with no
tenant-specific target. See `docs/trust-model.md`.

## [1.1.16] — 2026-06-10

**Hygiene release: CI, build, and process fixes after the 1.1.12 →
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.16" << std::endl;
std::cout << "crate 1.1.17" << 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.16" << std::endl;
std::cout << "crate 1.1.17" << std::endl;
exit(0);
default:
err("unsupported short option '%s'", argv[a]);
Expand Down
6 changes: 6 additions & 0 deletions daemon/privops_handlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1025,13 +1025,19 @@ DispatchResult dispatchPrivOpFromMap(const PrivOpsNvPure::FieldMap &m,
// path arg — checkOwnedPath then short-circuits to Allow.
switch (v) {
case Verb::MountNullfs:
req.path = field("target");
req.source = field("source"); // 1.1.17: gate the source end too
break;
case Verb::UnmountNullfs: req.path = field("target"); break;
case Verb::ApplyDevfsRuleset:
case Verb::AddDevfsUnhideRule: req.path = field("mount_path"); break;
// 1.1.15: create_jail's path argument is checked against the
// caller's per-user path prefix (env.pathPrefix); not a registry
// lookup (the jail doesn't exist yet).
case Verb::CreateJail: req.path = field("path"); break;
// 1.1.17: reclaim_iface_from_vnet names its jail in "jail_name"
// (not "name"); it's gated by checkOwnedName like destroy_jail.
case Verb::ReclaimIfaceFromVnet: req.jailName = field("jail_name"); break;
default: break;
}
PrivOpsAuthzPure::OwnerLookup lookup = g_jidOwnerRegistry
Expand Down
48 changes: 30 additions & 18 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.16 (rootless model + per-tenant authz series 1.1.12 →
**Applies to:** 1.1.17 (rootless model + per-tenant authz series 1.1.12 →
1.1.15 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.16 means reasoning about who can reach
Reasoning about isolation on 1.1.17 means reasoning about who can reach
**privops**, not who can run `crate(1)`.

---
Expand Down Expand Up @@ -119,25 +119,37 @@ differs by transport:
narrow item: `create_jail`'s brand-new `path` argument is matched
against the caller's per-user `pathPrefix` (composed from
`path_master_prefix:` in crated.conf); a foreign target is denied
`403` (`DenyForeignCreatePath`).

The remaining verbs still pass the gate: **host-global** verbs
(iface/pf/ipfw/nat/epair) cannot be pool-scoped and stay host-wide by
design. With 1.1.12+1.1.13+1.1.14+1.1.15 the per-tenant gate covers
**every** privops verb that carries an operator-controlled ownership
signal in its request. The per-verb handlers remain uid-blind; the
gate runs ahead of them.
`403` (`DenyForeignCreatePath`). 1.1.17 closes the last cross-tenant
holes: `mount_nullfs`'s **source** end (a source inside another
tenant's `path_master_prefix/<uid>` is denied `403`
`DenyForeignSource`; host paths and GUI runtime sockets stay
allowed), and the two interface verbs that operate on a *specific*
jail rather than shared host state — `configure_iface` (it `jexec`s
into the named `jid` and moves an iface into its vnet → gated by
`jid`) and `reclaim_iface_from_vnet` (pulls an iface out of a named
jail → gated by `jail_name`).

The remaining verbs still pass the gate: **genuinely host-global**
verbs (`teardown_iface`, `set_iface_up`, `bridge_*`, `add_pf_rule`,
`add_ipfw_rule`, `configure_ipfw_nat`, `create_epair`) act on shared
host state with no tenant-specific target, so they cannot be
pool-scoped and stay host-wide by design. With
1.1.12+1.1.13+1.1.14+1.1.15+1.1.17 the per-tenant gate covers **every**
privops verb that carries an operator-controlled ownership signal in
its request. The per-verb handlers remain uid-blind; the gate runs
ahead of them.

> Consequence: whoever can reach privops — an `admin` bearer token, or
> membership in the privops socket's group — still has host-wide control
> over the **un-gated** surface (firewall, interfaces, other host-global
> verbs that touch shared state). The 1.1.12 + 1.1.13 + 1.1.14 + 1.1.15
> gates close cross-tenant ZFS-dataset, RCTL-umbrella, jid-, name-,
> path-scoped, and create-jail-path access on the libnv path — every
> verb that carries an operator-controlled ownership signal in its
> request is now gated. The shared host-global verbs remain, by design,
> a single trust domain — handing an operator privops access is still
> close to handing them the old setuid `crate(1)` for those.
> over the **un-gated** surface (firewall, host interface plumbing,
> other host-global verbs that touch shared state). The
> 1.1.12 → 1.1.17 gates close cross-tenant ZFS-dataset, RCTL-umbrella,
> jid-, name-, path-scoped, create-jail-path, mount-source, and
> per-jail interface access on the libnv path — every verb that carries
> an operator-controlled ownership signal in its request is now gated.
> The shared host-global verbs remain, by design, a single trust
> domain — handing an operator privops access is still close to handing
> them the old setuid `crate(1)` for those.

### Per-user namespacing is convenience, not a boundary

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

**Стосується:** 1.1.16 (rootless-модель + серія per-tenant authz 1.1.12 →
**Стосується:** 1.1.17 (rootless-модель + серія per-tenant authz 1.1.12 →
1.1.15 покриває кожен 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.16 — це міркувати про те, хто має доступ
Міркувати про ізоляцію на 1.1.17 — це міркувати про те, хто має доступ
до **privops**, а не хто може запустити `crate(1)`.

---
Expand Down Expand Up @@ -117,24 +117,36 @@ privileged operations to crated(8)»*).
відбивається `403` (`DenyForeignPath`). 1.1.15 закриває останній
вузький залишок: новенький `path`-аргумент `create_jail` зіставляється
з пер-user `pathPrefix` викликача (виводиться з `path_master_prefix:`
у crated.conf); чужа ціль → `403` (`DenyForeignCreatePath`).

Решта вербів проходить гейт: **host-global** верби
(iface/pf/ipfw/nat/epair) не можна розпулити — лишаються host-wide за
задумом. З 1.1.12+1.1.13+1.1.14+1.1.15 пер-тенант-гейт покриває
**кожен** privops-верб, що несе operator-controlled ownership-сигнал у
запиті. Обробники лишаються uid-сліпими; гейт іде попереду них.
у crated.conf); чужа ціль → `403` (`DenyForeignCreatePath`). 1.1.17
закриває останні крос-тенантні діри: **source**-кінець `mount_nullfs`
(source усередині `path_master_prefix/<uid>` іншого тенанта → `403`
`DenyForeignSource`; host-шляхи та GUI-сокети лишаються дозволені) і
два interface-верби, що оперують **конкретним** jail, а не спільним
host-станом — `configure_iface` (`jexec`-иться в названий `jid` і
переносить iface у його vnet → гейт за `jid`) та
`reclaim_iface_from_vnet` (виштовхує iface з названого jail → гейт за
`jail_name`).

Решта вербів проходить гейт: **справді host-global** верби
(`teardown_iface`, `set_iface_up`, `bridge_*`, `add_pf_rule`,
`add_ipfw_rule`, `configure_ipfw_nat`, `create_epair`) діють на
спільний host-стан без тенант-специфічної цілі — їх не розпулити,
лишаються host-wide за задумом. З 1.1.12+1.1.13+1.1.14+1.1.15+1.1.17
пер-тенант-гейт покриває **кожен** privops-верб, що несе
operator-controlled ownership-сигнал у запиті. Обробники лишаються
uid-сліпими; гейт іде попереду них.

> Наслідок: будь-хто, хто має доступ до privops — `admin` bearer-токен
> або членство в групі privops-сокета — досі має host-wide контроль над
> **не-гейтнутою** поверхнею (фаєрвол, інтерфейси, інші host-global
> верби спільного стану). Гейти 1.1.12 + 1.1.13 + 1.1.14 + 1.1.15
> **не-гейтнутою** поверхнею (фаєрвол, host-плюмбінг інтерфейсів, інші
> host-global верби спільного стану). Гейти 1.1.12 1.1.17
> закривають крос-тенантний доступ до ZFS-датасетів, RCTL-umbrella,
> jid-, name-, path-scoped та create-jail-path поверхні на libnv-шляху —
> кожен верб, що несе operator-controlled ownership-сигнал, тепер
> гейтнутий. Спільні host-global верби лишаються, за задумом, єдиним
> доменом довіри — для них видати оператору privops все ще близько до
> видачі старого setuid `crate(1)`.
> jid-, name-, path-scoped, create-jail-path, mount-source та
> per-jail-interface поверхні на libnv-шляху — кожен верб, що несе
> operator-controlled ownership-сигнал, тепер гейтнутий. Спільні
> host-global верби лишаються, за задумом, єдиним доменом довіри — для
> них видати оператору privops все ще близько до видачі старого setuid
> `crate(1)`.

### Per-user namespacing — це зручність, а не межа

Expand Down
3 changes: 2 additions & 1 deletion lib/per_user_env_pure.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ Result composeForUid(const Config &cfg, uint32_t uid) {
if (!cfg.pathMasterPrefix.empty()) {
std::string base = cfg.pathMasterPrefix;
if (base.back() == '/') base.pop_back();
r.env.pathPrefix = base + "/" + std::to_string(uid);
r.env.pathMasterPrefix = base; // 1.1.17
r.env.pathPrefix = base + "/" + std::to_string(uid);
}

// IPv4 sub-CIDR — opt-in via non-empty master.
Expand Down
9 changes: 8 additions & 1 deletion lib/per_user_env_pure.h
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,16 @@ struct Env {
// ZFS dataset prefix. Empty if `cfg.zfsMasterPrefix` was empty.
std::string zfsPrefix;

// 1.1.15: jail-path prefix. Empty if `cfg.pathMasterPrefix` was empty.
// 1.1.15: jail-path prefix `<master>/<uid>`. Empty if
// `cfg.pathMasterPrefix` was empty.
std::string pathPrefix;

// 1.1.17: the bare master jail-path prefix (no uid), carried so the
// authz layer can tell "inside ANOTHER tenant's space" from "a host
// path". Empty if `cfg.pathMasterPrefix` was empty. Trailing slash
// stripped, same as pathPrefix composition.
std::string pathMasterPrefix;

// Network sub-CIDRs. Empty if the corresponding master CIDR was
// empty in the config.
std::string ipv4SubCidr;
Expand Down
Loading
Loading