Skip to content

feat(privops): close three cross-tenant authz holes (1.1.17, security)#219

Merged
click0 merged 1 commit into
mainfrom
claude/analyze-test-coverage-nCOJW
Jun 28, 2026
Merged

feat(privops): close three cross-tenant authz holes (1.1.17, security)#219
click0 merged 1 commit into
mainfrom
claude/analyze-test-coverage-nCOJW

Conversation

@click0

@click0 click0 commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Summary

A self-audit of the 1.1.12 → 1.1.15 per-tenant gate series (run as a project-wide bug analysis) found three verbs whose ownership check was incomplete or missing. In the strict multi-tenant model (rootless + path_master_prefix) these let one operator reach another tenant's resources — holes in gates that docs/trust-model.md documented as closed.

Framing: in the single-trust-domain model (privops access = root-equivalent) these are gate-completeness fixes, not new privilege escalation. But they contradicted the documented per-tenant guarantees, so they're worth a security release.

The three holes

# Verb Hole Fix
1 mount_nullfs 1.1.14 gated the target but never source → operator bind-mounts another tenant's prefix into their own jail and reads it gate source cross-tenant → 403 DenyForeignSource
2 configure_iface sat in the host-global Allow arm despite carrying a jid and jexec-ing ifconfig inside that jail (+ moving a host iface into its vnet) gate by jid (registry), like signal_jail
3 reclaim_iface_from_vnet ungated despite naming a jail (jail_name) and pulling an iface out of its vnet → steal/DoS a tenant's networking gate by jail_name, like destroy_jail

Source-gate semantics (the subtle one)

source is denied only when it lies inside another tenant's space (path_master_prefix/<other-uid>/…). Host paths (/etc, /usr, …) and GUI runtime sockets (/tmp/.X11-unix, the host Wayland/PulseAudio sockets that gui.mode binds) fall outside every tenant prefix and stay allowed — privops is a single trust domain w.r.t. the host; this gate enforces tenant-vs-tenant only. A naive "source must be under my prefix" check would have broken GUI mode.

Mechanism

  • PerUserEnvPure::Env gains pathMasterPrefix (the bare tenant root, no uid) so the authz layer can tell "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 never set path_master_prefix keep the unchanged opt-in behavior (source gate off).

Tests

  • privops_authz_pure_test: own/host/GUI-socket Allow, foreign-tenant DenyForeignSource, foreign-target precedence, unconfigured opt-in, configure_iface + reclaim_iface_from_vnet own/foreign/unknown, and a host-global-verbs-still-allowed regression.
  • per_user_env_pure_test: pathMasterPrefix composition (full / opt-in-empty / trailing-slash-strip).
  • Verified locally via a standalone E2E harness against the real authorize() (kyua/atf are absent in this re-provisioned env) → 1.1.17 AUTHZ CHECKS PASSED; FreeBSD + Linux CI run the full ATF suite. Both ATF test files syntax-checked via a shim header.

Test plan

  • g++ -fsyntax-only clean on the pure modules.
  • Standalone E2E passes (all three gates: own→Allow, foreign→Deny, host/GUI→Allow, unconfigured→Allow, host-global regression).
  • ATF test files parse clean.
  • Linux unit CI (kyua) + FreeBSD lite (gmake crate) run the real ATF cases.

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 — shared host state, no tenant-specific target.

https://claude.ai/code/session_01X6t6tzVypHye5bDGLxzmZK


Generated by Claude Code

A self-audit of the 1.1.12 -> 1.1.15 per-tenant gate series found three
verbs whose ownership check was incomplete or absent. In the strict
multi-tenant model (rootless + path_master_prefix) these let one
operator reach another tenant's resources — holes in gates documented
as closed.

1. mount_nullfs SOURCE was unchecked. 1.1.14 gated the mount `target`
   (must be inside an owned jail) but never `source`. An operator could
   bind-mount ANOTHER tenant's path prefix into their own jail and read
   it. Now gated cross-tenant: a source inside
   path_master_prefix/<other-uid> -> 403 DenyForeignSource. Host paths
   (/etc, ...) and GUI runtime sockets (/tmp/.X11-unix, host
   Wayland/PulseAudio sockets) fall outside every tenant prefix and stay
   allowed — privops is single-trust-domain w.r.t. the host; the gate
   only enforces tenant-vs-tenant.

2. configure_iface sat in the host-global Allow arm despite carrying a
   jid and jexec-ing ifconfig INSIDE that jail (+ moving a host iface
   into its vnet). Naming a foreign jid was cross-tenant. Now gated by
   jid against the jid->owner registry (like signal_jail).

3. 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 (bare tenant root,
no uid) so authz can tell "another tenant's space" from "a host path";
PrivOpsAuthzPure gains Decision::DenyForeignSource, Request::source, a
sourceForeign() helper, and moves the three verbs to their correct
groups; the dispatcher fills req.source ("source") for mount_nullfs and
req.jailName ("jail_name") for reclaim_iface_from_vnet.

Safety: the gate only DENIES on a confirmed-foreign target; own and
registry-unknown targets always Allow (bootstrap concession), so
legitimate crate-run flows (operating on the operator's own registered
jail) are unaffected regardless of operation ordering. Deployments
without path_master_prefix keep the unchanged opt-in behavior.

Tests: privops_authz_pure_test gains own/host/GUI-socket Allow,
foreign-tenant DenyForeignSource, foreign-target precedence,
unconfigured opt-in, configure_iface + reclaim own/foreign/unknown, and
a host-global-verbs-still-allowed regression; per_user_env_pure_test
asserts pathMasterPrefix composition. Verified via a standalone E2E
harness (kyua/atf unavailable in this env); FreeBSD + Linux CI run the
full ATF suite.

docs/trust-model.{md,uk.md} + CHANGELOG [1.1.17] + --version bumped.

https://claude.ai/code/session_01X6t6tzVypHye5bDGLxzmZK
@click0 click0 merged commit 1fc6a2e into main Jun 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants