diff --git a/CHANGELOG.md b/CHANGELOG.md index b40145a..68a9a24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/` 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 → diff --git a/cli/args.cpp b/cli/args.cpp index c1a8ca4..ce0cfac 100644 --- a/cli/args.cpp +++ b/cli/args.cpp @@ -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) { @@ -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]); diff --git a/daemon/privops_handlers.cpp b/daemon/privops_handlers.cpp index 79b5438..da0b0c3 100644 --- a/daemon/privops_handlers.cpp +++ b/daemon/privops_handlers.cpp @@ -1025,6 +1025,9 @@ 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; @@ -1032,6 +1035,9 @@ DispatchResult dispatchPrivOpFromMap(const PrivOpsNvPure::FieldMap &m, // 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 diff --git a/docs/trust-model.md b/docs/trust-model.md index 092e775..a9ea006 100644 --- a/docs/trust-model.md +++ b/docs/trust-model.md @@ -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). @@ -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)`. --- @@ -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/` 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 diff --git a/docs/trust-model.uk.md b/docs/trust-model.uk.md index f5da66b..6eea512 100644 --- a/docs/trust-model.uk.md +++ b/docs/trust-model.uk.md @@ -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). @@ -62,7 +62,7 @@ privops-сокета, ніколи admin-токен і ніколи Unix-сок privileged operations to crated(8)»*). Властивість «єдиний домен довіри» **не зникла** — вона переїхала. -Міркувати про ізоляцію на 1.1.16 — це міркувати про те, хто має доступ +Міркувати про ізоляцію на 1.1.17 — це міркувати про те, хто має доступ до **privops**, а не хто може запустити `crate(1)`. --- @@ -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/` іншого тенанта → `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 — це зручність, а не межа diff --git a/lib/per_user_env_pure.cpp b/lib/per_user_env_pure.cpp index a95f7ae..d3648ed 100644 --- a/lib/per_user_env_pure.cpp +++ b/lib/per_user_env_pure.cpp @@ -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. diff --git a/lib/per_user_env_pure.h b/lib/per_user_env_pure.h index 5dd428d..8721fbc 100644 --- a/lib/per_user_env_pure.h +++ b/lib/per_user_env_pure.h @@ -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 `/`. 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; diff --git a/lib/privops_authz_pure.cpp b/lib/privops_authz_pure.cpp index bd60d9b..dc4549e 100644 --- a/lib/privops_authz_pure.cpp +++ b/lib/privops_authz_pure.cpp @@ -66,6 +66,20 @@ Decision checkOwnedPath(const std::string &path, return o.uid == env.uid ? Decision::Allow : Decision::DenyForeignPath; } +// 1.1.17: a mount_nullfs SOURCE is "foreign" when it lies inside the +// per-user tenant root (env.pathMasterPrefix) but NOT inside the +// caller's own slice (env.pathPrefix). Host paths and GUI runtime +// sockets (/tmp/.X11-unix, the host Wayland/PulseAudio sockets, …) +// fall outside the tenant root entirely and are allowed — privops is +// a single trust domain w.r.t. the host; this gate only enforces +// tenant-vs-tenant isolation. Unconfigured deployment (empty +// pathMasterPrefix) -> nothing to gate. +bool sourceForeign(const std::string &source, const PerUserEnvPure::Env &env) { + if (env.pathMasterPrefix.empty()) return false; // opt-in + return pathOwned(source, env.pathMasterPrefix) // under the tenant root + && !pathOwned(source, env.pathPrefix); // but not the caller's +} + } // namespace Decision authorize(PrivOpsPure::Verb v, @@ -90,18 +104,28 @@ Decision authorize(PrivOpsPure::Verb v, : Decision::DenyForeignLoginclass; // jid-scoped verbs gated against the registry (1.1.13). + // 1.1.17: configure_iface joins the group — it carries a `jid` and + // jexec's ifconfig INSIDE that jail + moves a host iface into its + // vnet, so naming a foreign jid is a cross-tenant operation, not a + // host-global one (it was wrongly in the default Allow arm). case V::SetRctl: case V::ClearRctl: case V::SetJailCpuset: case V::QueryJailRctl: case V::SignalJail: + case V::ConfigureIface: return checkOwnedJid(req.jid, env, lookup); // Name-scoped verb. The jail name itself is brand-new and there's // nothing in the registry to compare against on create; the gate // for create_jail is below (path-prefix). destroy_jail gates on // the name — it must be one this caller created. + // 1.1.17: reclaim_iface_from_vnet joins the name-scoped group — it + // names a jail (`jail_name`) and pulls an iface OUT of that jail's + // vnet, so naming a foreign jail steals/DoSes its networking. Was + // wrongly in the default Allow arm. case V::DestroyJail: + case V::ReclaimIfaceFromVnet: return checkOwnedName(req.jailName, env, lookup); // 1.1.15: create_jail's brand-new path must fall inside the @@ -124,18 +148,31 @@ Decision authorize(PrivOpsPure::Verb v, // An unknown path (no registry hit) is allowed — same bootstrap // concession as the jid/name gate (jails predating 1.1.13 aren't // in the registry). - case V::MountNullfs: case V::UnmountNullfs: case V::ApplyDevfsRuleset: case V::AddDevfsUnhideRule: return checkOwnedPath(req.path, env, lookup); + // 1.1.17: mount_nullfs gates BOTH ends — the target (must be inside + // an owned jail, like 1.1.14) AND the source (must not reach into + // another tenant's space). Previously only the target was checked, + // so an operator could bind-mount another tenant's prefix into + // their own jail and read it. + case V::MountNullfs: { + Decision td = checkOwnedPath(req.path, env, lookup); + if (td != Decision::Allow) return td; + return sourceForeign(req.source, env) + ? Decision::DenyForeignSource + : Decision::Allow; + } + default: - // Remaining host-global verbs (interface / pf / ipfw / nat / - // epair) cannot be pool-scoped — they touch shared host state - // and stay host-wide by design. create_jail's path argument - // still needs validation against a per-user path prefix — - // tracked as the next open item once Env grows pathPrefix. + // Remaining host-global verbs (teardown_iface, set_iface_up, + // bridge_*, add_pf_rule, add_ipfw_rule, configure_ipfw_nat, + // create_epair, …) operate on shared host state with no + // tenant-specific target, so they cannot be pool-scoped and stay + // host-wide by design — consistent with privops being a single + // trust domain for the host. return Decision::Allow; } } @@ -166,6 +203,8 @@ const char *decisionReason(Decision d) { return "path lies inside a jail owned by a different operator"; case Decision::DenyForeignCreatePath: return "create_jail path is outside the caller's per-user path prefix"; + case Decision::DenyForeignSource: + return "mount source lies inside another operator's per-user path prefix"; } return "deny"; } diff --git a/lib/privops_authz_pure.h b/lib/privops_authz_pure.h index 637a4a8..3941b89 100644 --- a/lib/privops_authz_pure.h +++ b/lib/privops_authz_pure.h @@ -44,8 +44,12 @@ // request: SignalJail, SetRctl, ClearRctl, SetJailCpuset, // QueryJailRctl, DestroyJail. An unknown jid/name is allowed (jails // pre-dating 1.1.13 aren't in the registry); a *known* jid/name with -// the wrong owner is denied 403. Path-scoped verbs (devfs, mount) and -// create_jail path validation remain in the open gap. +// the wrong owner is denied 403. 1.1.14 added path-scoped verbs +// (mount/devfs target), 1.1.15 the create_jail path-prefix, and 1.1.17 +// closed the last cross-tenant holes: mount_nullfs's *source* end, +// configure_iface (jid → jexec into a jail), and reclaim_iface_from_vnet +// (named jail). The only ungated verbs left are genuinely host-global +// (iface/pf/ipfw/nat/epair on shared host state). // #include @@ -64,6 +68,7 @@ enum class Decision { DenyForeignJailName, // jail name is in the registry and owned by another uid DenyForeignPath, // path lies inside a registry-tracked jail owned by another uid DenyForeignCreatePath, // create_jail path is outside the caller's per-user path prefix + DenyForeignSource, // mount_nullfs source lies inside another tenant's path prefix }; // Result of looking a target up in the daemon's jid->owner registry. @@ -102,6 +107,12 @@ struct Request { // Dispatcher fills this from the right libnv field per verb; empty // when the verb doesn't carry a path argument. std::string path; + // 1.1.17: mount_nullfs *source* (the host path being bind-mounted). + // Gated cross-tenant: a source inside another operator's per-user + // path prefix is denied. Host paths and GUI runtime sockets (outside + // every tenant prefix) stay allowed — privops is a single trust + // domain for the host, the gate only enforces tenant isolation. + std::string source; }; // True when `dataset` is the caller's prefix itself or a descendant diff --git a/tests/unit/per_user_env_pure_test.cpp b/tests/unit/per_user_env_pure_test.cpp index 5e70ccf..950cb07 100644 --- a/tests/unit/per_user_env_pure_test.cpp +++ b/tests/unit/per_user_env_pure_test.cpp @@ -34,7 +34,8 @@ ATF_TEST_CASE_BODY(compose_full_config) { // ZFS ATF_REQUIRE_EQ(r.env.zfsPrefix, std::string("zroot/jails/1000")); // 1.1.15: jail path prefix - ATF_REQUIRE_EQ(r.env.pathPrefix, std::string("/jails-tenants/1000")); + ATF_REQUIRE_EQ(r.env.pathPrefix, std::string("/jails-tenants/1000")); + ATF_REQUIRE_EQ(r.env.pathMasterPrefix, std::string("/jails-tenants")); // 1.1.17 // Network ATF_REQUIRE_EQ(r.env.ipv4SubCidr, std::string("10.66.232.0/24")); ATF_REQUIRE_EQ(r.env.ipv6SubCidr, @@ -55,7 +56,8 @@ ATF_TEST_CASE_BODY(compose_empty_config_legacy_shape) { ATF_REQUIRE_EQ(r.env.runtimeRoot, std::string("/var/run/crate/1000")); ATF_REQUIRE_EQ(r.env.loginclass, std::string("crate-1000")); ATF_REQUIRE_EQ(r.env.zfsPrefix, std::string()); - ATF_REQUIRE_EQ(r.env.pathPrefix, std::string()); // 1.1.15: opt-in + ATF_REQUIRE_EQ(r.env.pathPrefix, std::string()); // 1.1.15: opt-in + ATF_REQUIRE_EQ(r.env.pathMasterPrefix, std::string()); // 1.1.17: opt-in ATF_REQUIRE_EQ(r.env.ipv4SubCidr, std::string()); ATF_REQUIRE_EQ(r.env.ipv6SubCidr, std::string()); } @@ -65,7 +67,8 @@ ATF_TEST_CASE_BODY(path_prefix_strips_trailing_slash) { // Operators may type "/jails/" or "/jails" — both compose the same. Config c; c.pathMasterPrefix = "/jails/"; auto r = composeForUid(c, 1000); - ATF_REQUIRE_EQ(r.env.pathPrefix, std::string("/jails/1000")); + ATF_REQUIRE_EQ(r.env.pathPrefix, std::string("/jails/1000")); + ATF_REQUIRE_EQ(r.env.pathMasterPrefix, std::string("/jails")); // 1.1.17: slash stripped } ATF_TEST_CASE_WITHOUT_HEAD(path_prefix_with_different_uids); diff --git a/tests/unit/privops_authz_pure_test.cpp b/tests/unit/privops_authz_pure_test.cpp index 63db023..809223a 100644 --- a/tests/unit/privops_authz_pure_test.cpp +++ b/tests/unit/privops_authz_pure_test.cpp @@ -18,10 +18,11 @@ namespace { // An Env scoped to uid 1000 with a per-user ZFS split configured. PerUserEnvPure::Env env1000() { PerUserEnvPure::Env e; - e.uid = 1000; - e.zfsPrefix = "zroot/crate-tenants/1000"; - e.pathPrefix = "/jails-tenants/1000"; // 1.1.15 - e.loginclass = "crate-1000"; + e.uid = 1000; + e.zfsPrefix = "zroot/crate-tenants/1000"; + e.pathMasterPrefix = "/jails-tenants"; // 1.1.17 + e.pathPrefix = "/jails-tenants/1000"; // 1.1.15 + e.loginclass = "crate-1000"; return e; } @@ -408,6 +409,90 @@ ATF_TEST_CASE_BODY(authorize_create_jail_unconfigured_split_allows) { == Decision::Allow); } +// --- 1.1.17: mount_nullfs source / configure_iface / reclaim gates --- + +namespace { +Request reqMount(std::string target, std::string source) { + Request r; r.path = std::move(target); r.source = std::move(source); return r; +} +} // namespace + +ATF_TEST_CASE_WITHOUT_HEAD(mount_source_own_and_host_allowed); +ATF_TEST_CASE_BODY(mount_source_own_and_host_allowed) { + auto e = env1000(); + // owner of jid/name/path 1000; target path owned -> Allow, then source. + auto l = fixedOwner(77, "web", 1000, "/jails-tenants/1000/web"); + // own source (inside caller prefix) + ATF_REQUIRE(authorize(Verb::MountNullfs, + reqMount("/jails-tenants/1000/web/data", "/jails-tenants/1000/share"), + e, l) == Decision::Allow); + // host path outside the tenant root — privops is single-trust-domain for host + ATF_REQUIRE(authorize(Verb::MountNullfs, + reqMount("/jails-tenants/1000/web/data", "/etc"), e, l) == Decision::Allow); + // GUI runtime socket — not under any tenant prefix + ATF_REQUIRE(authorize(Verb::MountNullfs, + reqMount("/jails-tenants/1000/web/data", "/tmp/.X11-unix"), e, l) + == Decision::Allow); +} + +ATF_TEST_CASE_WITHOUT_HEAD(mount_source_foreign_tenant_denied); +ATF_TEST_CASE_BODY(mount_source_foreign_tenant_denied) { + auto e = env1000(); + auto l = fixedOwner(77, "web", 1000, "/jails-tenants/1000/web"); + // source reaches into uid 1001's prefix -> deny + ATF_REQUIRE(authorize(Verb::MountNullfs, + reqMount("/jails-tenants/1000/web/x", "/jails-tenants/1001/secret"), + e, l) == Decision::DenyForeignSource); + // foreign TARGET still takes precedence over the source check + auto l2 = fixedOwner(77, "web", 1001, "/jails-tenants/1001/web"); + ATF_REQUIRE(authorize(Verb::MountNullfs, + reqMount("/jails-tenants/1001/web/x", "/etc"), e, l2) + == Decision::DenyForeignPath); +} + +ATF_TEST_CASE_WITHOUT_HEAD(mount_source_unconfigured_allows); +ATF_TEST_CASE_BODY(mount_source_unconfigured_allows) { + // No pathMasterPrefix -> source gate is opt-in, off. + PerUserEnvPure::Env e; e.uid = 1000; + ATF_REQUIRE(authorize(Verb::MountNullfs, + reqMount("/anything", "/jails-tenants/1001/secret"), e, + PrivOpsAuthzPure::nullLookup()) + == Decision::Allow); +} + +ATF_TEST_CASE_WITHOUT_HEAD(configure_iface_jid_scoped); +ATF_TEST_CASE_BODY(configure_iface_jid_scoped) { + auto e = env1000(); + ATF_REQUIRE(authorize(Verb::ConfigureIface, reqJid(77), e, fixedOwner(77,"web",1000)) + == Decision::Allow); // own jid + ATF_REQUIRE(authorize(Verb::ConfigureIface, reqJid(77), e, fixedOwner(77,"web",1001)) + == Decision::DenyForeignJid); // foreign jid + ATF_REQUIRE(authorize(Verb::ConfigureIface, reqJid(999), e, fixedOwner(77,"web",1000)) + == Decision::Allow); // unknown -> bootstrap +} + +ATF_TEST_CASE_WITHOUT_HEAD(reclaim_iface_name_scoped); +ATF_TEST_CASE_BODY(reclaim_iface_name_scoped) { + auto e = env1000(); + ATF_REQUIRE(authorize(Verb::ReclaimIfaceFromVnet, reqName("web"), e, fixedOwner(77,"web",1000)) + == Decision::Allow); // own jail + ATF_REQUIRE(authorize(Verb::ReclaimIfaceFromVnet, reqName("web"), e, fixedOwner(77,"web",1001)) + == Decision::DenyForeignJailName); // foreign jail + ATF_REQUIRE(authorize(Verb::ReclaimIfaceFromVnet, reqName("other"), e, fixedOwner(77,"web",1000)) + == Decision::Allow); // unknown -> bootstrap +} + +ATF_TEST_CASE_WITHOUT_HEAD(host_global_verbs_still_allowed); +ATF_TEST_CASE_BODY(host_global_verbs_still_allowed) { + auto e = env1000(); + auto foreign = fixedOwner(77, "web", 1001); // someone else owns everything + // genuinely host-global verbs are unaffected by the 1.1.17 tightening + for (Verb v : {Verb::TeardownIface, Verb::AddPfRule, Verb::AddIpfwRule, + Verb::SetIfaceUp, Verb::BridgeAddMember, Verb::CreateEpair}) { + ATF_REQUIRE(authorize(v, Request{}, e, foreign) == Decision::Allow); + } +} + // --- decisionReason --- ATF_TEST_CASE_WITHOUT_HEAD(decision_reason_non_empty); @@ -419,6 +504,7 @@ ATF_TEST_CASE_BODY(decision_reason_non_empty) { ATF_REQUIRE(!std::string(decisionReason(Decision::DenyForeignJailName)).empty()); ATF_REQUIRE(!std::string(decisionReason(Decision::DenyForeignPath)).empty()); ATF_REQUIRE(!std::string(decisionReason(Decision::DenyForeignCreatePath)).empty()); + ATF_REQUIRE(!std::string(decisionReason(Decision::DenyForeignSource)).empty()); } ATF_INIT_TEST_CASES(tcs) { @@ -451,5 +537,11 @@ ATF_INIT_TEST_CASES(tcs) { ATF_ADD_TEST_CASE(tcs, authorize_create_jail_inside_own_prefix); ATF_ADD_TEST_CASE(tcs, authorize_create_jail_outside_prefix_denied); ATF_ADD_TEST_CASE(tcs, authorize_create_jail_unconfigured_split_allows); + ATF_ADD_TEST_CASE(tcs, mount_source_own_and_host_allowed); + ATF_ADD_TEST_CASE(tcs, mount_source_foreign_tenant_denied); + ATF_ADD_TEST_CASE(tcs, mount_source_unconfigured_allows); + ATF_ADD_TEST_CASE(tcs, configure_iface_jid_scoped); + ATF_ADD_TEST_CASE(tcs, reclaim_iface_name_scoped); + ATF_ADD_TEST_CASE(tcs, host_global_verbs_still_allowed); ATF_ADD_TEST_CASE(tcs, decision_reason_non_empty); }