diff --git a/crates/nub-cli/src/cli.rs b/crates/nub-cli/src/cli.rs index 8fecb5ff5..0fd0d5681 100644 --- a/crates/nub-cli/src/cli.rs +++ b/crates/nub-cli/src/cli.rs @@ -496,6 +496,18 @@ pub enum Command { #[arg(long = "sandbox", value_name = "FILE", hide = true)] sandbox: Option, + /// INTERNAL, UNDOCUMENTED: administer the Windows dedicated sandbox account. + /// `setup`/`teardown` need an elevated prompt (they create a local account and + /// install network filters); `status`/`clean` do not. The one-time-elevated half + /// of the Windows agent-sandbox backend — see `nub-sandbox`'s `windows_account`. + #[arg( + long = "sandbox-admin", + value_name = "ACTION", + hide = true, + value_parser = ["setup", "teardown", "status", "clean"] + )] + sandbox_admin: Option, + /// Remaining arguments forwarded to the script. #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, @@ -1684,6 +1696,7 @@ fn value_consuming_flags(subcommand: &str) -> &'static [&'static str] { // Internal `--sandbox ` (hidden) takes a separate-token policy // path, so its value must not mis-bind as the script positional. "--sandbox", + "--sandbox-admin", ], // Exec's workspace value-flags must be listed so `nubx --filter @org/api // tsc` binds `@org/api` to the filter, not the bin positional. (Exec's @@ -1905,9 +1918,13 @@ fn dispatch_subcommand(rest: Vec) -> Result { aggregate_output, resume_from, sandbox, + sandbox_admin, mut args, }) => { args.extend(suffix); + if let Some(action) = sandbox_admin { + return sandbox_admin_action(&action); + } // INTERNAL test entry: `nub run --sandbox [args]` // compiles an explicit surface policy and launches under the // sandbox engine. Bypasses the whole script-run/workspace path (this @@ -2676,6 +2693,41 @@ fn run_sandboxed(policy_file: &str, program: Option<&str>, args: &[String]) -> R Ok(status.code().unwrap_or(1)) } +/// INTERNAL, UNDOCUMENTED: the Windows agent-sandbox backend's machine administration. +/// +/// This is the one-time-elevated half of that backend and the reason the per-run launch is +/// NOT elevated: `setup` creates the dedicated local account and installs the SID-keyed WFP +/// egress filters once, after which every sandboxed run is an ordinary unelevated process. +/// `clean` is unelevated and exists for crash residue (aces left behind by a run that died +/// between grant and teardown). +#[cfg(target_os = "windows")] +fn sandbox_admin_action(action: &str) -> Result { + match action { + "setup" => { + let sid = nub_sandbox::windows_admin::setup(None)?; + println!("nub sandbox account ready ({sid})"); + } + "teardown" => { + nub_sandbox::windows_admin::teardown()?; + println!("nub sandbox account and network filters removed"); + } + "status" => print!("{}", nub_sandbox::windows_admin::status()?), + "clean" => { + let n = nub_sandbox::windows_admin::clean()?; + println!("swept {n} recorded path(s)"); + } + other => anyhow::bail!("unknown --sandbox-admin action `{other}`"), + } + Ok(0) +} + +/// The sandbox account backend is Windows-only — no other OS needs a second principal to +/// express the policy grammar, so there is nothing to administer. +#[cfg(not(target_os = "windows"))] +fn sandbox_admin_action(_action: &str) -> Result { + anyhow::bail!("--sandbox-admin is Windows-only") +} + /// The per-OS home anchors the sandbox compiler expands symbolic roots against. /// Best-effort from the environment (the frontend-less engine takes them as /// host-provided data). diff --git a/crates/nub-sandbox/Cargo.toml b/crates/nub-sandbox/Cargo.toml index ce38cd997..ac186b24e 100644 --- a/crates/nub-sandbox/Cargo.toml +++ b/crates/nub-sandbox/Cargo.toml @@ -92,6 +92,24 @@ features = [ "Win32_System_JobObjects", "Win32_System_Memory", "Win32_NetworkManagement_WindowsFirewall", + # The dedicated-account + WFP backend (backend/windows_account/): the SID-keyed egress + # fence (WindowsFilteringPlatform; System_Rpc only because FwpmEngineOpen0's signature + # names SEC_WINNT_AUTH_IDENTITY_W), local-account lifecycle (NetManagement), the + # DPAPI-sealed credential (Security_Cryptography), the Winlogon user-picker hide + # (System_Registry), profile deletion (DeleteProfileW lives under UI_Shell), and the FILE_GENERIC_* access + # masks the grant/deny aces are built from (Storage_FileSystem). + "Win32_NetworkManagement_WindowsFilteringPlatform", + "Win32_System_Rpc", + "Win32_NetworkManagement_NetManagement", + "Win32_Security_Cryptography", + "Win32_System_Registry", + "Win32_UI_Shell", + "Win32_Storage_FileSystem", + # Window-station / desktop aces for the launch. A non-interactive caller (a service, an SSH + # session, a CI agent) runs on a per-logon `Service-0x0-…$` station, NOT `WinSta0`, and the + # Secondary Logon service's auto-grant does not cover it — without an explicit ace the child + # dies in loader init with STATUS_DLL_INIT_FAILED (VM-diagnosed 2026-07-24). + "Win32_System_StationsAndDesktops", ] [dev-dependencies] @@ -122,3 +140,16 @@ harness = false [[test]] name = "windows_residuals" harness = false + +# OPT-IN ONLY. This probe is not a unit test: on an unprovisioned machine it exits non-zero +# by design, and when run elevated it CREATES a local account, a local group, four persistent +# WFP filters and a Winlogon registry value. Left ungated, bare `cargo test` on a +# windows-latest runner would either turn the leg red or silently provision the runner. A +# human (or the VM harness) opts in with `--features windows-sandbox-probe`. +[features] +windows-sandbox-probe = [] + +[[test]] +name = "windows_account_enforcement" +harness = false +required-features = ["windows-sandbox-probe"] diff --git a/crates/nub-sandbox/LIMITATIONS.md b/crates/nub-sandbox/LIMITATIONS.md index e9181514c..c663eaa54 100644 --- a/crates/nub-sandbox/LIMITATIONS.md +++ b/crates/nub-sandbox/LIMITATIONS.md @@ -134,6 +134,222 @@ residuals: - **Port-agnostic broker scoping.** A literal broker host matches regardless of port — brokering configured for `api.example.com` applies to that host on any port. +## Windows dedicated-account backend (agent-sandbox) — bounded residuals + +The account backend (`backend/windows_account/`) runs the child as a dedicated local account +fenced by SID-keyed WFP filters. It carries the residuals below. Everything here is a +deliberate spike bound or an inherited platform property, not an unknown. + +### One-time elevation is required, and per-run is not + +`nub run --sandbox-admin setup` needs administrator: it creates a local account and installs +WFP filters, and Windows gates both. Every sandboxed run afterwards is unelevated. This is the +whole reason the WFP permit covers a loopback PORT WINDOW rather than the run's exact proxy +port — a filter tracking an ephemeral port would need a WFP write, hence a UAC prompt, on +every run. + +- **Cost of the window:** the permit admits any local principal to loopback on those ports for + as long as the filters are installed, rather than admitting only nub's proxy on one port. + Bounded by the window's width (10 ports by default, 64 max, enforced at install). +- **Where fixed:** an elevated helper service could add an exact-port filter per run. Not + built; the window is the deliberate trade. +- **The sandbox belongs to whoever ran setup.** `%PROGRAMDATA%\nub\sandbox` holds the + DPAPI-sealed account credential, and machine-scope DPAPI is explicitly not a boundary — any + local principal that can READ the ciphertext can decrypt it and then hold the sandbox + account's password. Setup therefore replaces that directory's DACL with a PROTECTED one + naming only SYSTEM, `BUILTIN\Administrators`, and the account that ran setup. Another + standard user on the same box reads neither the credential nor the provisioning marker and + fails closed with "not provisioned" — including the ordinary user of a machine where the UAC + prompt was satisfied with a DIFFERENT administrator account. Re-running setup as that user + re-points the lock at them. + +### Egress coverage is `ALE_AUTH_CONNECT` only + +The fence blocks outbound connects for the account on IPv4 and IPv6. It does NOT filter +inbound (`ALE_AUTH_RECV_ACCEPT_*`) or bind/listen (`ALE_RESOURCE_ASSIGNMENT_*`), so the +account may still open a listening socket. Neither of the reference implementations nub +mirrors (SRT, Codex) covers those layers either. + +- **Why bounded:** the policy grammar's net axis is about EGRESS. A listener the child opens + is reachable only by something already on the box. + +### DNS still resolves + +`getaddrinfo` is serviced by the `Dnscache` service running as `NETWORK SERVICE`, so name +resolution succeeds under a different token even though the child's own `connect()` is +blocked. No filter set can close this while keying on the connecting token. + +- **Why harmless here:** the child reaches nub's proxy by IP and the PROXY resolves. A blocked + host is blocked at connect regardless of whether its name resolved. + +### Per-user tool installs are unreachable without an explicit grant + +The child is a DIFFERENT principal, so anything under the invoking user's profile — an +nvm/fnm-managed Node, a Scoop or per-user winget package, `pip install --user`, +`%LOCALAPPDATA%\Programs\…` — resolves on `PATH` but cannot be opened. This lands +particularly hard on nub, whose premise is running the user's installed Node. + +- **What the engine does:** auto-grants the resolved program FILE (never its parent directory, + which would sweep in a neighbouring secret). +- **Launcher contract:** a program that loads SIBLING DLLs from its own directory needs the + front-end to put that toolchain directory in the read allow-set — the same contract the + macOS "toolchain read-confine for a non-system interpreter" residual defines. + +### Certificate revocation checks fail under schannel + +CryptoAPI's CRL/OCSP fetch goes out via WinHTTP under the caller's token and ignores proxy +environment variables, so it is blocked. `curl`, `git`, and `cargo` surface +`CRYPT_E_REVOCATION_OFFLINE` (`0x80092013`) unless revocation is disabled +(`curl --ssl-no-revoke`, `git -c http.schannelCheckRevoke=false`, +`CARGO_HTTP_CHECK_REVOKE=false`). `Invoke-WebRequest`, .NET `HttpClient`, and `gh` are +unaffected. + +### Glob denies are not enforceable as aces + +A deny whose matcher carries glob metacharacters (`**/.env`, `C:/proj/*.pem`) cannot be one +ACE. The engine reports `fs-deny-glob` as a LOST axis — distinct from the over-confinement +degradations, because a missed deny is a hole, not extra confinement. Literal deny paths are +enforced exactly. + +### A grant or deny target that does not exist at launch is skipped + +An ACE needs an object, so a policy path absent when the run starts gets none. Skipping rather +than failing is deliberate: the flagship agent policy denies `~/.ssh`, `~/.aws`, +`~/.docker/config.json` and `/.env`, most of which are absent on any given machine, and +aborting on the first missing one would kill the backend on its own headline shape. Denying a +path that does not exist denies nothing, which is correct — but a file created LATER at that +path, INSIDE a granted tree, inherits the tree's grant with no explicit deny to outrank it. +Every error other than not-found stays fatal. + +- **Why bounded:** it needs the denied path to sit under a grant AND to be created after the + aces land. A denied path outside every grant is unreachable to the account regardless. +- **Where fixed:** pre-create the deny target (as the Linux backend pre-creates write targets), + or re-apply denies when the child creates a matching path. Neither is built. + +### Junction/symlink TOCTOU on an ace target + +Every ace target is canonicalized before the ace is written, so a symlink or junction resolves +to its TARGET and the ace lands on the object an open actually reaches — a reparse point does +not gate content access. Nothing then re-checks that the resolved object is still inside the +path the policy named. A child holding DELETE on a nested grant target can replace it with a +junction between runs, so the NEXT run stamps an inheritable grant on wherever that junction +points. + +- **Why bounded:** it needs a prior run that granted write inside the tree, and it MOVES a + grant rather than widening one — the next run's confinement is wrong, the current run's is + not. +- **Where fixed:** open the target with `FILE_FLAG_OPEN_REPARSE_POINT` and ace the HANDLE, or + re-verify the canonicalized target against the policy's own prefix after resolution. + +### The child inherits the parent's real console handles + +`CreateProcessWithLogonW` has no `bInheritHandles` parameter and no `STARTUPINFOEX` overload, so +stdio must ride the three `STARTF_USESTDHANDLES` fields — and nub marks the parent's own +stdin/stdout/stderr permanently inheritable to supply them. A foreign-user child therefore holds +the parent shell's real console INPUT handle, which is a `WriteConsoleInput` keystroke-injection +path back into that shell. + +- **Where fixed:** give the child three anonymous pipes and relay, flipping nub's own ends + non-inheritable — what SRT does. Not built; it also costs the child a real console (no PTY + semantics, no `isatty`). + +### A policy that confines only the network still runs as a foreign principal + +Backend selection is per-POLICY, not per-axis: a policy with per-host egress rules and no +filesystem confinement still routes here, and its child then runs as the sandbox account — +which reaches nothing under the invoking user's profile. The user's own project files become +unreachable although they asked for nothing about the filesystem. + +- **Why it is a surprise, not a hole:** it is over-confinement, and the engine reports `fs-read` + as a degradation. It is listed because the SIZE of the behavioral change is easy to miss when + the policy names only the net axis. +- **Where fixed:** the launcher supplies the project directory in the read allow-set — the same + launcher contract as the per-user tool installs item above. + +### Window-station access is granted per run, and verified only in session 0 + +seclogon's window-station auto-grant covers `WinSta0` only. A NON-INTERACTIVE caller — SSH, a +service, a CI agent — runs on a per-logon `Service-0x0-…$` station instead, where the auto-grant +does not apply and the child dies in loader init with `0xC0000142 STATUS_DLL_INIT_FAILED`. The +launch therefore aces the caller's own window station and desktop for the sandbox SID before +spawning, and restores both DACLs when the run ends. `READ_CONTROL` in the station mask is +load-bearing: without it the child HANGS in loader init rather than failing. Setting `lpDesktop` +explicitly does not substitute for the aces. + +- **Verified in session 0 only.** The failure and the fix were both reproduced from a + non-interactive session. The interactive `WinSta0` path — where seclogon's auto-grant should + make the aces redundant — is untested; the aces are written there too and are expected to be + a no-op. +- **The granted rights are BROAD, and on an interactive session that matters.** The masks are + `WINSTA_ALL_ACCESS | READ_CONTROL` and the full documented `DESKTOP_*` union — what the VM + diagnosis established as working, not a bisected minimum. Against the caller's own + `WinSta0\Default` that hands the confined account `DESKTOP_JOURNALRECORD` and + `DESKTOP_HOOKCONTROL` (desktop-wide keystroke capture), `DESKTOP_JOURNALPLAYBACK` (input + injection into the user's session), `WINSTA_READSCREEN` (screen capture) and + `WINSTA_ACCESSCLIPBOARD` — for the run's duration, at the same integrity level as the user's + own apps, so UIPI does not block hooks against them. On a `Service-0x0-…$` station there is + no user session to reach and the cost is nil. +- **Where fixed:** bisect the real floor on a VM and narrow both masks — plausibly station + `READ_CONTROL | ENUMDESKTOPS | READATTRIBUTES | CREATEDESKTOP | ACCESSGLOBALATOMS | + EXITWINDOWS`, desktop `READ_CONTROL | READOBJECTS | WRITEOBJECTS | CREATEWINDOW`. Not done + here: the broad set is the one actually verified to work, and `READ_CONTROL`'s hang-vs-fail + behavior already shows this surface punishes guessing. +- **Cost while the child runs:** concurrent runs share the station too — the restore puts back + the DACL the FIRST of them saw, so a second run's ace can be removed while its child is still + alive, and that run's own restore then re-writes the first's ace permanently. Same shape as + the shared-account bound below. + +### Whole-tree kill is best-effort + +`AssignProcessToJobObject` on a `CreateProcessWithLogonW` child commonly returns +`ERROR_NOT_SUPPORTED`: the Secondary Logon service already placed it in its own job and +current Windows refuses that nesting cross-session. The assignment is attempted and the +failure logged; a descendant that outlives the target may survive. + +- **Where fixed:** the two-hop broker→runner design SRT and Codex use, where the runner owns a + job it can assign into. Deferred. + +### Spike bounds carried deliberately + +- **Single-hop launch.** The child is started directly, not through a runner holding a + restricted token. Confinement comes from the account's ACL reach plus SID-keyed WFP, neither + of which needs that token — but the token would additionally strip privileges and groups. +- **`lpDesktop` is NULL**, so the child shares the caller's desktop instead of getting a + private one. A private desktop is the hardening follow-up and would additionally need a + session-`BaseNamedObjects` ace. The station and desktop aces themselves are NOT part of this + bound — the launch writes them on every run (see the window-station item above). +- **Concurrent runs share one account.** Two simultaneous sandboxed runs grant and strip aces + for the SAME SID, so one run's teardown can revoke a grant the other still needs. +- **Child-created files are owned by the sandbox account.** Access is preserved (the grant is + written UNPROTECTED so the user's inherited aces survive on new children), but the OWNER + field changes, which git reports as "dubious ownership" and which leaves an orphaned owner + SID if the account is later deleted. Neither reference implementation solves this. + +### A path with a NULL DACL cannot carry a deny + +Windows reads a NULL DACL as UNRESTRICTED access, not as an empty allow-set. Merging a deny +into it would yield a DACL containing only that ace, replacing the object's permissive state +with one that locks out its own owner — so the engine refuses rather than writing it. A grant +on such a path is skipped instead (the account already has access). + +- **Why bounded:** fail-closed and loud. The launch aborts with a message naming the path. + Ordinary project and profile directories carry a real DACL and are unaffected; this was + observed only under `C:\Windows\Temp`. +- **Where fixed:** synthesize the equivalent explicit DACL (a deny for the sandbox account plus + an `Everyone` full-access allow preserving the NULL-DACL semantics) rather than refusing. + +### Residue after a crash + +A run killed between granting an ace and stripping it leaves the ace behind. The ledger at +`%PROGRAMDATA%\nub\sandbox\acl-ledger.txt` records every path so +`nub run --sandbox-admin clean` (unelevated) collects them. A leaked grant is over-permission +for a confined account, not a host compromise — hygiene, not a correctness boundary. + +- **`clean` is a machine-wide sweep, so it can revoke a LIVE run's aces.** The ledger is + machine-wide and `clean` is unelevated, so a sweep strips every path it lists — including + ones a concurrent sandboxed run still needs. Same root as the shared-account bound above: + every run's aces key on the one SID, and nothing distinguishes a live grant from residue. + ## Launcher-handoff items (engine correct; launcher must complete the guarantee) ### macOS ascendant-env via `KERN_PROCARGS2` — CLOSED in-engine diff --git a/crates/nub-sandbox/src/backend/mod.rs b/crates/nub-sandbox/src/backend/mod.rs index 0f5831d1b..69c2e52e1 100644 --- a/crates/nub-sandbox/src/backend/mod.rs +++ b/crates/nub-sandbox/src/backend/mod.rs @@ -16,13 +16,13 @@ //! //! LAUNCH SEAM: a backend either configures [`Prepared::command`] for the caller to //! spawn (macOS wraps `sandbox-exec`; Linux installs a `pre_exec` hook; the skeleton -//! just scrubs env) OR — Windows only — OWNS the whole spawn lifecycle, because an -//! AppContainer launch cannot be a pre-built `std::process::Command`: it needs a -//! custom `CreateProcessW` with `STARTUPINFOEX`/`SECURITY_CAPABILITIES`, a Job Object -//! assigned at creation, and per-run ACL grants that must be TORN DOWN after the -//! child exits. [`Prepared::status`] is the uniform verb: mac/linux/skeleton delegate -//! to `command.status()`; Windows runs its launcher (setup → spawn → wait → RAII -//! teardown) when a launch plan is attached. +//! just scrubs env) OR — Windows only — OWNS the whole spawn lifecycle, because +//! neither Windows launch can be a pre-built `std::process::Command`: the AppContainer +//! one needs a custom `CreateProcessW` with `STARTUPINFOEX`/`SECURITY_CAPABILITIES`, +//! the dedicated-account one needs `CreateProcessWithLogonW`, and BOTH need per-run +//! ACL grants TORN DOWN after the child exits. [`Prepared::status`] is the uniform +//! verb: mac/linux/skeleton delegate to `command.status()`; Windows runs whichever +//! launcher its plan names (setup → spawn → wait → RAII teardown). use crate::policy::{Effect, Inspection, ProxyMode, SandboxPolicy}; use crate::proxy::mitm::MitmEngine; @@ -48,6 +48,11 @@ mod linux_connect_notify; #[cfg(any(target_os = "windows", test))] mod windows; +// The Windows dedicated-account + WFP backend (agent-sandbox). Same cfg as `windows` so its +// OS-agnostic plan derivation and mode-selection are unit-tested on the macOS dev host. +#[cfg(any(target_os = "windows", test))] +pub(crate) mod windows_account; + // The OS-agnostic Landlock grant derivation. Compiled on Linux (its real consumer) // and under `test` on any host — so its security-critical carve logic is unit-tested // on the macOS dev host over tempfile trees, without a kernel. @@ -127,9 +132,9 @@ impl CommandSpec { /// rides the `status` seam, not the `command` field). pub struct Prepared { /// The configured child for the mac/linux/skeleton path. On Windows this is the - /// env-scrubbed plain child used ONLY when nothing needs AppContainer confinement - /// (`launch` is `None`); when confinement applies, `launch` owns the spawn and - /// this field is unused. + /// env-scrubbed plain child used ONLY when no confinement mechanism applies + /// (`launch` is `None`); when one does — either Windows variant — `launch` owns the + /// spawn and this field is unused. pub command: Command, pub degradation: Degradation, /// The running egress proxy (design.md §2.5), when the policy enforces per-host @@ -145,9 +150,10 @@ pub struct Prepared { /// where the supervisor is viable); `None` otherwise. #[cfg(target_os = "linux")] pub(crate) connect_notify: Option, - /// Windows AppContainer launch plan — the backend owns spawn+wait+teardown when - /// this is `Some`. Absent (or on other OSes) → [`Prepared::status`] spawns - /// `command`. + /// Windows launch plan — the backend owns spawn+wait+teardown when this is `Some`. + /// A two-variant enum, one per Windows mechanism: the per-run AppContainer (LowBox + /// token) and the dedicated local account (`CreateProcessWithLogonW`, no LowBox + /// token). Absent (or on other OSes) → [`Prepared::status`] spawns `command`. #[cfg(target_os = "windows")] pub(crate) launch: Option, } @@ -155,8 +161,10 @@ pub struct Prepared { impl Prepared { /// Launch the prepared child and wait for it, returning its exit status. The /// UNIFORM launch verb across backends: mac/linux/skeleton spawn `command`; - /// Windows runs its AppContainer launcher (ACL setup → `CreateProcessW` under a - /// LowBox token → wait → RAII teardown) when a launch plan is attached. + /// Windows runs whichever launcher its plan names when one is attached — both + /// follow the same shape (ACL setup → a custom spawn → wait → RAII teardown), and + /// differ in the spawn: `CreateProcessW` under a LowBox token for the AppContainer, + /// `CreateProcessWithLogonW` as the dedicated account for the other. /// /// The egress proxy (`self.proxy`) is held for the child's whole run and dropped /// (listener shut down) only after the child exits — `self` owns it until this @@ -199,7 +207,30 @@ fn start_proxy_if_needed(policy: &SandboxPolicy) -> Option { } Inspection::Connection => None, }; - EgressProxy::start(decider, mitm).ok() + EgressProxy::start_in_range(decider, mitm, proxy_port_range(policy)).ok() +} + +/// The loopback window the proxy must bind inside, when one applies. +/// +/// Only Windows' dedicated-account backend has one: its WFP permit is installed ONCE, by the +/// elevated setup, over a fixed port window — so the proxy binds into that window rather than +/// a filter chasing an ephemeral port, which would cost a UAC prompt every run. Everywhere +/// else the deny-layer carves the exact port at launch and no range is needed. +#[cfg(target_os = "windows")] +fn proxy_port_range(policy: &SandboxPolicy) -> Option<(u16, u16)> { + if !windows_account::needs_account_backend(policy) { + return None; + } + // An absent or unreadable marker is NOT decided here: `windows_account::apply` fails + // closed with the actionable "run the elevated setup" message, and an ephemeral bind in + // the meantime is harmless because that launch never happens. + let m = windows_account::state::read_marker().ok().flatten()?; + Some((m.port_low, m.port_high)) +} + +#[cfg(not(target_os = "windows"))] +fn proxy_port_range(_policy: &SandboxPolicy) -> Option<(u16, u16)> { + None } /// The CA-trust env keys pointed at the child CA bundle (ephemeral CA + real roots). diff --git a/crates/nub-sandbox/src/backend/windows.rs b/crates/nub-sandbox/src/backend/windows.rs index 3235e4b34..b3e56f38b 100644 --- a/crates/nub-sandbox/src/backend/windows.rs +++ b/crates/nub-sandbox/src/backend/windows.rs @@ -58,7 +58,7 @@ use std::path::{Path, PathBuf}; /// IR→plan derivation is unit-tested on the dev host; [`WindowsLaunch::run`] (the FFI) /// is `#[cfg(windows)]`. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] -pub(crate) struct WindowsLaunch { +pub(crate) struct AppContainerLaunch { program: OsString, args: Vec, cwd: Option, @@ -78,9 +78,30 @@ pub(crate) struct WindowsLaunch { register_loopback_exemption: bool, } +/// Which Windows mechanism owns this launch. Exactly one applies, which is why this is an +/// enum rather than two `Option` fields: build-jail's admin-free AppContainer and +/// agent-sandbox's dedicated account are alternatives, never a combination. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) enum WindowsLaunch { + /// Per-run AppContainer (LowBox) — the pure-allowlist path. No elevation, ever. + AppContainer(AppContainerLaunch), + /// Dedicated local account + WFP — the full-grammar path. Needs a one-time elevated setup. + Account(super::windows_account::AccountLaunch), +} + +#[cfg(target_os = "windows")] +impl WindowsLaunch { + pub(crate) fn run(self) -> std::io::Result { + match self { + WindowsLaunch::AppContainer(l) => l.run(), + WindowsLaunch::Account(l) => l.run(), + } + } +} + /// What the allowlist model could NOT express for a policy, so the caller can be told. #[derive(Debug, Default, PartialEq)] -struct FsDegrade { +pub(super) struct FsDegrade { /// A generous-read base (`default_effect == Allow`, OR a whole-fs `**` Allow entry /// — the shape the compiler actually emits for `"..."`/`sandbox: true`). The /// allowlist can't express read-all-minus-secrets; reads are confined to the @@ -98,7 +119,7 @@ struct FsDegrade { /// and is reported via [`FsDegrade`] (fail-safe: over-confine + name it, never widen). /// (The deny-shadowing check is done by [`deny_shadows_grant`] in `apply`, AFTER the /// program-dir grant is folded into the read set.) -fn derive_grants(fs: &FsPolicy) -> (Vec, Vec, FsDegrade) { +pub(super) fn derive_grants(fs: &FsPolicy) -> (Vec, Vec, FsDegrade) { let mut read = Vec::new(); let mut write = Vec::new(); let mut degrade = FsDegrade { @@ -144,7 +165,7 @@ fn derive_grants(fs: &FsPolicy) -> (Vec, Vec, FsDegrade) { /// shadows it. Matching is case-insensitive (Windows paths are). Run against the /// policy-derived SUBTREE grants only — the caller excludes the program-file grant (a /// single leaf with no subtree, an exec necessity), which cannot host a deny "inside" it. -fn deny_shadows_grant(entries: &[FsRule], read_grants: &[PathBuf]) -> bool { +pub(super) fn deny_shadows_grant(entries: &[FsRule], read_grants: &[PathBuf]) -> bool { if read_grants.is_empty() { return false; } @@ -203,12 +224,12 @@ fn path_prefixes(a: &Path, b: &Path) -> bool { } /// Whether a canonical IR glob contains glob metacharacters. -fn has_glob_meta(glob: &str) -> bool { +pub(super) fn has_glob_meta(glob: &str) -> bool { glob.contains(['*', '?', '[', ']', '{', '}']) } /// Whether a glob addresses the whole filesystem (the generous-read base spellings). -fn is_whole_fs(glob: &str) -> bool { +pub(super) fn is_whole_fs(glob: &str) -> bool { matches!(glob, "**" | "/**" | "/") } @@ -216,7 +237,7 @@ fn is_whole_fs(glob: &str) -> bool { /// as one inheritable ACE. A plain absolute literal, or a literal + trailing `/**` /// subtree twin, yields that directory; anything with embedded globs (or the whole-fs /// spellings) yields `None`. Mirrors the macOS backend's `to_match_term` subpath case. -fn literal_subtree(glob: &str) -> Option { +pub(super) fn literal_subtree(glob: &str) -> Option { if is_whole_fs(glob) { return None; } @@ -238,7 +259,7 @@ fn literal_subtree(glob: &str) -> Option { /// filesystem-wide write hole. The Windows twin of the macOS `is_dangerous_write_root` /// (reads are exempt; a generous read is a legitimate posture, and read is separately /// allowlist-confined here anyway). Matches on the forward-slashed canonical form. -fn is_dangerous_write_root(dir: &Path) -> bool { +pub(super) fn is_dangerous_write_root(dir: &Path) -> bool { let Some(s) = dir.to_str() else { return false }; let s = s.trim_end_matches('/'); // Drive root (`C:`), the Windows dir, and Program Files are the roots a stray `..` @@ -342,6 +363,27 @@ pub(crate) fn apply( let confine_fs = fs_confines(&policy.fs); let sandboxing = confine_fs || policy.net.enforce; + // ── agent-sandbox route (dedicated account + WFP) ──────────────────────────── + // A policy the ALLOWLIST cannot carry — a generous-read base, a deny that must be carved + // inside a grant, or per-host egress — goes to the dedicated-account backend, which + // expresses all three but costs a one-time elevated setup. build-jail's shape (pure + // default-deny allowlist, coarse or absent net) never matches, so `nub install` stays + // admin-free. See `windows_account`'s module doc for why the split falls exactly here. + // PROVISIONING IS PART OF THE PREDICATE, not just a precondition checked later. Without + // it this branch subsumes the whole strict-Windows tier below — `Tier1`/`FailUnelevated` + // require exactly `net.enforce && any(Allow)`, which is byte-identical to + // `needs_account_backend`'s per-host arm and also forces `sandboxing` — so an + // unprovisioned machine would take the account route, fail closed, and leave that tier + // unreachable. Falling through instead keeps the AppContainer tier live and degrading + // honestly (over-confined reads, a reported deny it cannot carve), which is strictly + // better than refusing to run on a machine that never opted into the elevated setup. + if sandboxing + && super::windows_account::needs_account_backend(policy) + && super::windows_account::is_provisioned() + { + return super::windows_account::apply(policy, spec, proxy_port, proxy_token, ca_bundle); + } + // ── net posture (strict-Windows tier decision) ────────────────────────────── // Per-host + MITM ride nub's loopback proxy, which an AppContainer child can reach // ONLY through an admin-registered loopback exemption. `is_elevated` is queried lazily @@ -446,9 +488,7 @@ pub(crate) fn apply( // proxy's minted leaves. Grant it as nub infra (not user config), mirroring the // mac/linux ca-bundle read grant. Only under a real per-host tier (`tier1`); the plain // path handles CA-trust via `set_ca_env` on an unconfined fs. - if tier1 - && let Some(bundle) = ca_bundle - { + if tier1 && let Some(bundle) = ca_bundle { let b = bundle.to_path_buf(); if !read_grants.contains(&b) { read_grants.push(b); @@ -495,7 +535,7 @@ pub(crate) fn apply( // degraded when it isn't. See the module doc.) deg.reason = reason; - let launch = WindowsLaunch { + let launch = AppContainerLaunch { program: spec.program, args: spec.args, cwd: spec.cwd, @@ -516,7 +556,7 @@ pub(crate) fn apply( command: std::process::Command::new(&launch.program), degradation: deg, proxy: None, - launch: Some(launch), + launch: Some(WindowsLaunch::AppContainer(launch)), }) } @@ -598,7 +638,10 @@ fn build_child_env( /// name → PATH search trying the name and common executable extensions. Windows-only /// (its PATHEXT search is Windows semantics; the host build never calls it). #[cfg(target_os = "windows")] -fn resolve_program(program: &std::ffi::OsStr, child_cwd: Option<&Path>) -> Option { +pub(super) fn resolve_program( + program: &std::ffi::OsStr, + child_cwd: Option<&Path>, +) -> Option { let p = Path::new(program); if p.is_absolute() { return Some(p.to_path_buf()); @@ -634,8 +677,8 @@ fn resolve_program(program: &std::ffi::OsStr, child_cwd: Option<&Path>) -> Optio // ── the FFI launcher ──────────────────────────────────────────────────────────── #[cfg(target_os = "windows")] -mod launch { - use super::WindowsLaunch; +pub(super) mod launch { + use super::AppContainerLaunch; use std::io; use std::os::windows::ffi::OsStrExt; use std::os::windows::io::AsRawHandle; @@ -648,15 +691,15 @@ mod launch { CloseHandle, HANDLE, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, LocalFree, SetHandleInformation, WAIT_OBJECT_0, }; + use windows_sys::Win32::NetworkManagement::WindowsFirewall::{ + NetworkIsolationFreeAppContainers, NetworkIsolationGetAppContainerConfig, + NetworkIsolationSetAppContainerConfig, + }; use windows_sys::Win32::Security::Authorization::{ ConvertStringSidToSidW, EXPLICIT_ACCESS_W, GRANT_ACCESS, GetNamedSecurityInfoW, NO_MULTIPLE_TRUSTEE, REVOKE_ACCESS, SE_FILE_OBJECT, SetEntriesInAclW, SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, }; - use windows_sys::Win32::NetworkManagement::WindowsFirewall::{ - NetworkIsolationFreeAppContainers, NetworkIsolationGetAppContainerConfig, - NetworkIsolationSetAppContainerConfig, - }; use windows_sys::Win32::Security::Isolation::{ CreateAppContainerProfile, DeleteAppContainerProfile, }; @@ -720,9 +763,7 @@ mod launch { if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { return false; } - let mut elevation = TOKEN_ELEVATION { - TokenIsElevated: 0, - }; + let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 }; let mut ret_len: u32 = 0; // SAFETY: `elevation` is a correctly-sized TOKEN_ELEVATION out-buffer. let ok = unsafe { @@ -841,7 +882,7 @@ mod launch { } } - impl WindowsLaunch { + impl AppContainerLaunch { /// Own the full spawn lifecycle: create a per-run AppContainer profile, grant /// the inheritable allow-ACEs, launch the child under the LowBox token inside a /// kill-on-close Job, wait, then tear everything down (RAII). @@ -1359,7 +1400,7 @@ mod launch { } /// UTF-16, NUL-terminated. - fn to_wide(s: &str) -> Vec { + pub(in crate::backend) fn to_wide(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() } @@ -1373,7 +1414,10 @@ mod launch { /// Build a mutable UTF-16 command line from program + args, quoting each token per /// the CommandLineToArgvW rules std uses. lpApplicationName is NULL, so the child /// gets a conventional argv. - fn build_command_line(program: &std::ffi::OsStr, args: &[std::ffi::OsString]) -> Vec { + pub(in crate::backend) fn build_command_line( + program: &std::ffi::OsStr, + args: &[std::ffi::OsString], + ) -> Vec { let mut line: Vec = Vec::new(); append_quoted(&mut line, program); for a in args { @@ -1424,7 +1468,9 @@ mod launch { /// expects (the source `BTreeMap` is case-sensitive, so a lowercase key like /// `windir` would otherwise sort after all-uppercase keys and violate the /// convention). - fn build_env_block(env: &std::collections::BTreeMap) -> Vec { + pub(in crate::backend) fn build_env_block( + env: &std::collections::BTreeMap, + ) -> Vec { let mut pairs: Vec<(&String, &String)> = env.iter().collect(); pairs.sort_by_key(|a| a.0.to_ascii_uppercase()); let mut block: Vec = Vec::new(); @@ -1676,9 +1722,15 @@ mod tests { default_effect: Effect::Deny, ..Default::default() }); - let deg = apply(&deny_all, crate::CommandSpec::new("cmd.exe"), None, None, None) - .expect("apply deny-all") - .degradation; + let deg = apply( + &deny_all, + crate::CommandSpec::new("cmd.exe"), + None, + None, + None, + ) + .expect("apply deny-all") + .degradation; assert!( !deg.lost.iter().any(|s| s == "net-per-host"), "deny-all is coarse-enforced, not degraded (got {:?})", @@ -1711,7 +1763,11 @@ mod tests { deg.lost ); } else { - let err = res.expect_err("unelevated per-host must fail-closed, not degrade"); + // `expect_err` would need `Prepared: Debug`, which it is not (it owns a live + // proxy and a launch plan) — so destructure instead. + let Err(err) = res else { + panic!("unelevated per-host must fail-closed, not degrade"); + }; assert!( err.lost.iter().any(|s| s == "net-per-host"), "the fail-closed Degradation must name net-per-host (got {:?})", diff --git a/crates/nub-sandbox/src/backend/windows_account/account.rs b/crates/nub-sandbox/src/backend/windows_account/account.rs new file mode 100644 index 000000000..8c728644d --- /dev/null +++ b/crates/nub-sandbox/src/backend/windows_account/account.rs @@ -0,0 +1,932 @@ +//! The sandbox account's lifecycle and its credential store. +//! +//! THE PRIVILEGE SPLIT LIVES HERE. [`provision`] and [`deprovision`] are the ELEVATED +//! one-time halves — SAM writes (`NetUserAdd`, `NetLocalGroupAddMembers`) and an `HKLM` value +//! all demand administrator. [`lookup_sid`] and [`load_credential`] are the UNELEVATED +//! per-run halves the broker calls on every launch. Keeping that split visible in this file's +//! signatures is what stops a per-run path from quietly acquiring an elevation requirement +//! (see [`super`]'s module doc: every run after setup is unelevated). +//! +//! WHY THE CREDENTIAL IS DPAPI **MACHINE** SCOPE. The elevated setup writes the blob and the +//! unelevated broker reads it. Those are the same *user* but DIFFERENT LOGON SESSIONS, and a +//! self-elevated child may not have the user's master key loaded at all — user-scope DPAPI +//! does not round-trip across that split, machine scope does. The honest consequence is that +//! machine scope is **not a security boundary**: any local principal that can READ the +//! ciphertext can decrypt it, the sandbox account included. The directory's DACL is the only +//! gate, and this module never writes a DACL — [`credential_dir`] exists so the setup path can +//! hand the directory to the acl module (which owns every DACL write) and have it locked down +//! BEFORE [`provision`] writes a credential into it. +//! +//! Mirrors SRT's `vendor/srt-win-src/src/{user,sam,dpapi}.rs` (read 2026-07-24); Codex's +//! `windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs` is the second reference. + +#![cfg(target_os = "windows")] + +use super::{SANDBOX_ACCOUNT, SANDBOX_GROUP}; +use std::io; +use std::path::PathBuf; +use std::ptr::{from_mut, from_ref, null, null_mut}; +use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_INSUFFICIENT_BUFFER, + ERROR_MEMBER_IN_ALIAS, ERROR_NONE_MAPPED, ERROR_SUCCESS, GetLastError, HANDLE, LocalFree, +}; +use windows_sys::Win32::NetworkManagement::NetManagement::{ + LOCALGROUP_INFO_1, LOCALGROUP_MEMBERS_INFO_0, NERR_GroupExists, NERR_GroupNotFound, + NERR_PasswordTooShort, NERR_UserExists, NERR_UserNotFound, NetApiBufferFree, NetLocalGroupAdd, + NetLocalGroupAddMembers, NetLocalGroupDel, NetUserAdd, NetUserDel, NetUserGetInfo, + NetUserModalsGet, NetUserSetInfo, UF_DONT_EXPIRE_PASSWD, UF_SCRIPT, USER_INFO_1, + USER_INFO_1003, USER_INFO_1008, USER_MODALS_INFO_0, USER_PRIV_USER, +}; +use windows_sys::Win32::Security::Authorization::{ConvertSidToStringSidW, ConvertStringSidToSidW}; +use windows_sys::Win32::Security::Cryptography::{ + BCRYPT_USE_SYSTEM_PREFERRED_RNG, BCryptGenRandom, CRYPT_INTEGER_BLOB, + CRYPTPROTECT_LOCAL_MACHINE, CRYPTPROTECT_UI_FORBIDDEN, CryptProtectData, CryptUnprotectData, +}; +use windows_sys::Win32::Security::{ + GetTokenInformation, LookupAccountNameW, LookupAccountSidW, PSID, SID_NAME_USE, + TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, +}; +use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_LOCAL_MACHINE, KEY_SET_VALUE, REG_DWORD, REG_OPTION_NON_VOLATILE, RegCloseKey, + RegCreateKeyExW, RegDeleteValueW, RegOpenKeyExW, RegSetValueExW, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; +use windows_sys::Win32::UI::Shell::DeleteProfileW; + +/// `BUILTIN\Users`. The well-known SID is stable across locales; the *name* is not +/// ("Benutzer" on de-DE, "Utilisateurs" on fr-FR), which is why membership goes through a +/// reverse lookup rather than a literal. +const SID_BUILTIN_USERS: &str = "S-1-5-32-545"; + +const WINLOGON_USERLIST: &str = + r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList"; + +/// Benign-idempotency codes `windows-sys` has no constant for. +const ERROR_ALIAS_EXISTS: u32 = 1379; +const ERROR_NO_SUCH_ALIAS: u32 = 1376; + +const MAX_PW_ATTEMPTS: usize = 5; +const PW_LEN: usize = 32; + +// ───────────────────────────── public surface ───────────────────────────── + +/// ELEVATED. Create-or-repair the sandbox account and its group, rotate the credential, and +/// store it. Idempotent — re-running repairs a half-provisioned machine (account present but +/// out of `BUILTIN\Users`, flags cleared by a GPO, credential file deleted). Returns the +/// account's SID, the identity every downstream object keys on: WFP's `ALE_USER_ID` +/// descriptor and every granted or denied ACE. +pub(crate) fn provision() -> io::Result { + ensure_group()?; + let password = ensure_user()?; + + let sid = lookup_sid()?.ok_or_else(|| { + io::Error::other(format!( + "the {SANDBOX_ACCOUNT} account was created but does not resolve to a SID" + )) + })?; + let psid = LocalSid::parse(&sid)?; + + // `BUILTIN\Users` is REQUIRED, not cosmetic: it carries the interactive-logon right that + // `CreateProcessWithLogonW` depends on, and without it every launch fails at logon. + let builtin_users = lookup_account_name(SID_BUILTIN_USERS)?; + add_member(&builtin_users, &psid)?; + add_member(SANDBOX_GROUP, &psid)?; + + // Deliberately NOT granting `SeDenyInteractiveLogonRight`: `CreateProcessWithLogonW` goes + // through an INTERACTIVE-type logon, so a deny-interactive right plausibly breaks the + // launch outright. Neither SRT nor Codex sets one. Revisit only against a real Windows box + // that can prove the launch survives it. + set_hidden(true)?; + + store_credential(password.as_str()) + .map(|()| sid) + .map_err(|e| io::Error::new(e.kind(), format!("storing the sandbox credential: {e}"))) +} + +/// ELEVATED. Remove the account, its profile, the group, the Winlogon hide entry, and the +/// credential file. Every step tolerates already-absent state and runs even after an earlier +/// step failed, so a partially-provisioned or crash-interrupted machine still gets cleaned as +/// far as it can; the FIRST failure is what surfaces. +pub(crate) fn deprovision() -> io::Result<()> { + // Resolve BEFORE `NetUserDel` destroys the SAM mapping — `DeleteProfileW` takes the SID + // *string* and there is no route back to it once the account is gone. + let sid = lookup_sid().ok().flatten(); + + let mut failure: Option = None; + let mut record = |r: io::Result<()>| { + if let Err(e) = r { + let _ = failure.get_or_insert(e); + } + }; + + if let Some(sid) = &sid { + let sid_w = to_wide(sid); + // Best-effort by design: Windows only materializes the profile on first logon, and a + // stuck child can hold it open. Neither may block the account delete below. + // SAFETY: NUL-terminated SID string; NULL profile path and NULL computer name select + // the local machine's default profile, which is the documented form. + unsafe { DeleteProfileW(sid_w.as_ptr(), null(), null()) }; + } + + let name_w = to_wide(SANDBOX_ACCOUNT); + // SAFETY: NUL-terminated account name; NULL server means the local SAM. + let rc = unsafe { NetUserDel(null(), name_w.as_ptr()) }; + if rc != 0 && rc != NERR_UserNotFound { + record(Err(net_err("NetUserDel", rc))); + } + + let group_w = to_wide(SANDBOX_GROUP); + // SAFETY: as above. + let rc = unsafe { NetLocalGroupDel(null(), group_w.as_ptr()) }; + if rc != 0 && rc != NERR_GroupNotFound && rc != ERROR_NO_SUCH_ALIAS { + record(Err(net_err("NetLocalGroupDel", rc))); + } + + record(set_hidden(false)); + record( + credential_path().and_then(|p| match std::fs::remove_file(&p) { + Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e), + _ => Ok(()), + }), + ); + + match failure { + Some(e) => Err(e), + None => Ok(()), + } +} + +/// UNELEVATED. The sandbox account's SID, or `None` when it does not exist. An access-denied +/// or transient LSA failure PROPAGATES — reporting "absent" for those would make an +/// already-provisioned machine look unprovisioned and trigger a spurious elevation prompt. +pub(crate) fn lookup_sid() -> io::Result> { + lookup_account_sid(SANDBOX_ACCOUNT) +} + +/// UNELEVATED. Decrypt the stored credential into a [`Secret`], which zeroes itself on drop. +/// +/// Every buffer the plaintext passes through is zeroed: DPAPI's own output block (in +/// [`take_blob`], before it is freed), the decoded copy here, the `Secret` on drop, and the +/// UTF-16 relay the launch builds for `CreateProcessWithLogonW`. That bounds how long the +/// password sits readable in nub's heap; it is not a defence against a process-memory attacker. +pub(crate) fn load_credential() -> io::Result { + let path = credential_path()?; + let ciphertext = std::fs::read(&path).map_err(|e| { + io::Error::new( + e.kind(), + format!( + "cannot read the sandbox credential at {} ({e}) — the one-time elevated sandbox \ + setup has not run on this machine", + path.display() + ), + ) + })?; + let mut plaintext = dpapi_unprotect(&ciphertext)?; + let out = std::str::from_utf8(&plaintext) + .map(|s| Secret(s.to_owned())) + .map_err(|_| io::Error::other("the stored sandbox credential is corrupt (not UTF-8)")); + scrub_u8(&mut plaintext); + out +} + +/// The credential store's directory — the SAME directory as the marker and the ledger, which is +/// why it delegates rather than recomputing the path: setup locks whatever THIS returns, so a +/// second definition that drifted would protect a directory the credential is not in. +/// +/// Machine-scope DPAPI protects nothing on its own, so that directory's owner + DACL are the +/// whole boundary: the setup path hands it to [`super::acl::lock_to_admins`] BEFORE any +/// credential is written into it. This module never writes a DACL. +pub(crate) fn credential_dir() -> io::Result { + super::state::state_dir() +} + +pub(crate) fn credential_path() -> io::Result { + Ok(credential_dir()?.join("credential.bin")) +} + +/// Whether this process holds an ELEVATED (full-admin) token — the exact condition under +/// which the SAM and `HKLM` writes in [`provision`] succeed. A standard user and an admin's +/// filtered Medium-IL token both report `false`, and both would get `ERROR_ACCESS_DENIED`. +pub(crate) fn is_elevated() -> bool { + let mut token: HANDLE = null_mut(); + // SAFETY: query-only handle into our own process token. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return false; + } + let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 }; + let mut ret_len: u32 = 0; + // SAFETY: `elevation` is a correctly-sized TOKEN_ELEVATION out-buffer. + let ok = unsafe { + GetTokenInformation( + token, + TokenElevation, + from_mut(&mut elevation).cast(), + size_of::() as u32, + &mut ret_len, + ) + }; + // SAFETY: `token` came from a successful OpenProcessToken and is closed once. + unsafe { CloseHandle(token) }; + ok != 0 && elevation.TokenIsElevated != 0 +} + +// ───────────────────────────── errors, scratch, RAII ───────────────────────────── + +/// Access-denied on any of these operations means "you are not elevated", not a fault — the +/// caller turns `PermissionDenied` into an actionable re-run-elevated message rather than +/// surfacing a bare numeric code. +fn net_err(op: &str, rc: u32) -> io::Error { + if rc == ERROR_ACCESS_DENIED { + return io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "{op} failed: administrator rights are required to manage the \ + {SANDBOX_ACCOUNT} account" + ), + ); + } + io::Error::other(format!("{op} failed (status {rc})")) +} + +fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +fn scrub_u8(buf: &mut [u8]) { + for b in buf { + // SAFETY: `b` is a live unique reference. Volatile so the store survives a compiler + // that would otherwise treat writing to a dying buffer as dead code. + unsafe { std::ptr::write_volatile(b, 0) }; + } +} + +/// Also the launch's own UTF-16 relay into `CreateProcessWithLogonW`, which is why this is +/// `pub(crate)`: a plain `fill(0)` on a buffer about to be dropped is a dead store the +/// optimizer may delete, and the password would survive in the heap. +pub(crate) fn scrub_u16(buf: &mut [u16]) { + for b in buf { + // SAFETY: as above. + unsafe { std::ptr::write_volatile(b, 0) }; + } +} + +/// A plaintext credential zeroed on drop. NOT a defence against a process-memory attacker +/// (the value was copied at least once on the way in) — it bounds how long the password sits +/// readable in nub's heap, which is the part this module controls. +pub(crate) struct Secret(String); + +impl Secret { + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl Drop for Secret { + fn drop(&mut self) { + // SAFETY: zeroing every byte keeps the buffer valid UTF-8 (NUL is ASCII), so the + // `String` invariant holds. + scrub_u8(unsafe { self.0.as_bytes_mut() }); + } +} + +/// The UTF-16 form the `USER_INFO_*` structs point at, zeroed on drop for the same reason. +struct WideSecret(Vec); + +impl Drop for WideSecret { + fn drop(&mut self) { + scrub_u16(&mut self.0); + } +} + +/// A `PSID` minted by `ConvertStringSidToSidW`, released with `LocalFree` — never `FreeSid`, +/// which is only valid for SIDs built by `AllocateAndInitializeSid`. +struct LocalSid(PSID); + +impl LocalSid { + fn parse(sid: &str) -> io::Result { + let w = to_wide(sid); + let mut psid: PSID = null_mut(); + // SAFETY: `w` is NUL-terminated UTF-16; `psid` is a valid out-slot. + let ok = unsafe { ConvertStringSidToSidW(w.as_ptr(), &mut psid) }; + if ok == 0 { + // SAFETY: read immediately after the failed call on this thread. + let rc = unsafe { GetLastError() }; + return Err(net_err(&format!("ConvertStringSidToSidW({sid})"), rc)); + } + Ok(LocalSid(psid)) + } +} + +impl Drop for LocalSid { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: `ConvertStringSidToSidW` documents `LocalFree` as the release. + unsafe { LocalFree(self.0) }; + } + } +} + +// ───────────────────────────── SID lookups ───────────────────────────── + +/// Two-call `LookupAccountNameW`. `ERROR_NONE_MAPPED` is the "no such account" answer and +/// becomes `Ok(None)`; everything else is a real failure. +fn lookup_account_sid(name: &str) -> io::Result> { + let name_w = to_wide(name); + let mut cb_sid: u32 = 0; + let mut cch_dom: u32 = 0; + let mut kind: SID_NAME_USE = 0; + + // SAFETY: the documented sizing form — NULL buffers with zeroed lengths. + let ok = unsafe { + LookupAccountNameW( + null(), + name_w.as_ptr(), + null_mut(), + &mut cb_sid, + null_mut(), + &mut cch_dom, + &mut kind, + ) + }; + if ok == 0 { + // SAFETY: read immediately after the failed call on this thread. + let rc = unsafe { GetLastError() }; + if rc == ERROR_NONE_MAPPED { + return Ok(None); + } + if rc != ERROR_INSUFFICIENT_BUFFER { + return Err(net_err(&format!("LookupAccountNameW({name})"), rc)); + } + } + if cb_sid == 0 { + return Ok(None); + } + + // `u32` backing, not `u8`: a SID's `SubAuthority` array is `DWORD`-typed, so the buffer + // must be DWORD-aligned — which `Vec` does not promise. + let mut sid = vec![0u32; cb_sid.div_ceil(4) as usize]; + let mut dom = vec![0u16; cch_dom.max(1) as usize]; + // SAFETY: both buffers are sized by the call above and outlive this one. + let ok = unsafe { + LookupAccountNameW( + null(), + name_w.as_ptr(), + sid.as_mut_ptr().cast(), + &mut cb_sid, + dom.as_mut_ptr(), + &mut cch_dom, + &mut kind, + ) + }; + if ok == 0 { + // SAFETY: as above. + let rc = unsafe { GetLastError() }; + if rc == ERROR_NONE_MAPPED { + return Ok(None); + } + return Err(net_err(&format!("LookupAccountNameW({name})"), rc)); + } + // SAFETY: the buffer now holds a valid self-relative SID. + Ok(Some(unsafe { sid_to_string(sid.as_mut_ptr().cast()) }?)) +} + +/// Reverse lookup, used to resolve a well-known SID to its LOCALIZED account name so the +/// name-taking SAM calls work on non-English Windows. +fn lookup_account_name(sid_str: &str) -> io::Result { + let sid = LocalSid::parse(sid_str)?; + let mut cch_name: u32 = 0; + let mut cch_dom: u32 = 0; + let mut kind: SID_NAME_USE = 0; + + // SAFETY: sizing call against a live PSID; both out-lengths are valid slots. + unsafe { + LookupAccountSidW( + null(), + sid.0, + null_mut(), + &mut cch_name, + null_mut(), + &mut cch_dom, + &mut kind, + ) + }; + if cch_name == 0 { + return Err(io::Error::other(format!( + "LookupAccountSidW({sid_str}) sizing returned 0" + ))); + } + let mut name = vec![0u16; cch_name as usize]; + let mut dom = vec![0u16; cch_dom.max(1) as usize]; + // SAFETY: both buffers are sized by the call above and outlive this one. + let ok = unsafe { + LookupAccountSidW( + null(), + sid.0, + name.as_mut_ptr(), + &mut cch_name, + dom.as_mut_ptr(), + &mut cch_dom, + &mut kind, + ) + }; + if ok == 0 { + // SAFETY: read immediately after the failed call on this thread. + let rc = unsafe { GetLastError() }; + return Err(net_err(&format!("LookupAccountSidW({sid_str})"), rc)); + } + Ok(String::from_utf16_lossy(&name[..cch_name as usize])) +} + +/// # Safety +/// `sid` must point at a valid self-relative SID for the duration of the call. +unsafe fn sid_to_string(sid: PSID) -> io::Result { + let mut out: *mut u16 = null_mut(); + // SAFETY: caller guarantees `sid`; `out` is a valid slot. + let ok = unsafe { ConvertSidToStringSidW(sid, &mut out) }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + let mut len = 0usize; + // SAFETY: on success the buffer is NUL-terminated UTF-16 allocated by `LocalAlloc`. + while unsafe { *out.add(len) } != 0 { + len += 1; + } + // SAFETY: `len` units precede the terminator. + let s = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(out, len) }); + // SAFETY: `ConvertSidToStringSidW` documents `LocalFree` as the release. + unsafe { LocalFree(out.cast()) }; + Ok(s) +} + +// ───────────────────────────── password ───────────────────────────── + +/// The 85-symbol alphabet. It EXCLUDES `"`, `\`, backtick, whitespace and the shell-special +/// `& | < > ^` set, so the credential survives any cmd / PowerShell / argv relay between here +/// and `CreateProcessWithLogonW`. 32 chars ≈ 205 bits; Windows caps a local account at 127. +const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ + abcdefghijklmnopqrstuvwxyz\ + 0123456789!#$%()*+,-./:;=?@[]_{}~"; + +const CLASSES: [&[u8]; 4] = [ + b"ABCDEFGHIJKLMNOPQRSTUVWXYZ", + b"abcdefghijklmnopqrstuvwxyz", + b"0123456789", + b"!#$%()*+,-./:;=?@[]_{}~", +]; + +fn fill_random(buf: &mut [u8]) -> io::Result<()> { + // SAFETY: `buf` is a live writable slice of exactly `len()` bytes; a NULL algorithm handle + // is what BCRYPT_USE_SYSTEM_PREFERRED_RNG requires. + let st = unsafe { + BCryptGenRandom( + null_mut(), + buf.as_mut_ptr(), + buf.len() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + }; + if st != 0 { + return Err(io::Error::other(format!( + "BCryptGenRandom failed (NTSTATUS {st:#010x})" + ))); + } + Ok(()) +} + +/// 32 chars rejection-sampled from [`ALPHA`] via the system CSPRNG (never a userspace PRNG). +/// Rejection sampling is what makes each pick UNIFORM — 85 does not divide 256, so a bare +/// `% 85` would bias the first 86 symbols. +fn gen_password() -> io::Result { + let mut raw = [0u8; PW_LEN]; + fill_random(&mut raw)?; + let bound = (u8::MAX - (u8::MAX % ALPHA.len() as u8)) as usize; + let mut out = Vec::with_capacity(PW_LEN); + let mut i = 0usize; + while out.len() < PW_LEN { + if i == raw.len() { + fill_random(&mut raw)?; + i = 0; + } + let b = raw[i] as usize; + i += 1; + if b < bound { + out.push(ALPHA[b % ALPHA.len()]); + } + } + scrub_u8(&mut raw); + + let mut extra = [0u8; 5]; + fill_random(&mut extra)?; + apply_class_floor(&mut out, &extra); + scrub_u8(&mut extra); + Ok(Secret(String::from_utf8(out).expect("ALPHA is ASCII"))) +} + +/// Force one character from each complexity class when the uniform draw missed one. The 2245 +/// retry loop in [`ensure_user`] is the primary defence against a tightened local policy; +/// this makes the FIRST attempt pass almost always. +fn apply_class_floor(out: &mut [u8], extra: &[u8; 5]) { + if CLASSES.iter().all(|c| out.iter().any(|b| c.contains(b))) { + return; + } + let base = extra[0] as usize; + for (k, class) in CLASSES.iter().enumerate() { + let slot = (base + k) % out.len(); + out[slot] = class[extra[1 + k] as usize % class.len()]; + } +} + +/// The rejected draw's CLASS COMPOSITION — never the password itself. This is what makes a +/// 2245 on a user's machine diagnosable from logs alone. +fn class_summary(password: &str) -> String { + let (mut u, mut l, mut d, mut s) = (0u32, 0u32, 0u32, 0u32); + for b in password.bytes() { + match b { + b'A'..=b'Z' => u += 1, + b'a'..=b'z' => l += 1, + b'0'..=b'9' => d += 1, + _ => s += 1, + } + } + format!("len={} U={u} L={l} D={d} S={s}", password.len()) +} + +/// `(min_password_len, password_history_len)` from the local SAM, `(-1, -1)` when unreadable. +fn local_password_policy() -> (i64, i64) { + let mut buf: *mut u8 = null_mut(); + // SAFETY: NULL server = local SAM; `buf` is a valid out-slot. + if unsafe { NetUserModalsGet(null(), 0, &mut buf) } != 0 || buf.is_null() { + return (-1, -1); + } + // SAFETY: on success netapi returns one USER_MODALS_INFO_0 in its own buffer. + let m = unsafe { *buf.cast::() }; + // SAFETY: the buffer came from NetUserModalsGet and is freed exactly once. + unsafe { NetApiBufferFree(buf.cast()) }; + ( + i64::from(m.usrmod0_min_passwd_len), + i64::from(m.usrmod0_password_hist_len), + ) +} + +fn warn_password_rejected(op: &str, attempt: usize, password: &str) { + let (min_len, hist) = local_password_policy(); + tracing::warn!( + "nub sandbox: {op} rejected a generated password (NERR_PasswordTooShort, attempt {}/{MAX_PW_ATTEMPTS}); \ + local policy min_len={min_len} history={hist}; rejected draw {}; retrying", + attempt + 1, + class_summary(password) + ); +} + +// ───────────────────────────── account + group ───────────────────────────── + +/// Create the account, or rotate the credential of the one already present. +/// +/// Retries on `NERR_PasswordTooShort` (2245) because SAM returns that code for ANY local +/// password-policy rejection — length, history, or a third-party password-filter DLL — not +/// literally "too short". A policy tightened since the last provision will bounce an +/// otherwise-valid 32-char draw, and a fresh draw usually clears it. +fn ensure_user() -> io::Result { + let mut name_w = to_wide(SANDBOX_ACCOUNT); + let mut comment_w = to_wide("nub: dedicated account for OS-enforced sandboxing"); + + for attempt in 0..MAX_PW_ATTEMPTS { + let password = gen_password()?; + let mut pw = WideSecret(to_wide(password.as_str())); + + let info = USER_INFO_1 { + usri1_name: name_w.as_mut_ptr(), + usri1_password: pw.0.as_mut_ptr(), + usri1_password_age: 0, + usri1_priv: USER_PRIV_USER, + usri1_home_dir: null_mut(), + usri1_comment: comment_w.as_mut_ptr(), + // UF_SCRIPT is MANDATORY on workstation SKUs — a vestigial LAN-Manager flag SAM + // still insists on. Omit it and NetUserAdd fails with NERR_BadUsername / + // ERROR_INVALID_PARAMETER, neither of which hints at the real cause. + usri1_flags: UF_SCRIPT | UF_DONT_EXPIRE_PASSWD, + usri1_script_path: null_mut(), + }; + // SAFETY: every PWSTR field points into a buffer that outlives this call. + let rc = unsafe { NetUserAdd(null(), 1, from_ref(&info).cast(), null_mut()) }; + if rc == 0 { + return Ok(password); + } + if rc == NERR_PasswordTooShort && attempt + 1 < MAX_PW_ATTEMPTS { + warn_password_rejected("NetUserAdd", attempt, password.as_str()); + continue; + } + if rc != NERR_UserExists { + return Err(net_err("NetUserAdd", rc)); + } + + // Already present — rotate, so the credential file about to be rewritten matches the + // live account. Levels 1003 (password) and 1008 (flags) rather than another level-1 + // SetInfo, which would clobber priv / home_dir / comment. + let info = USER_INFO_1003 { + usri1003_password: pw.0.as_mut_ptr(), + }; + // SAFETY: `name_w` and the password buffer outlive this call. + let rc = unsafe { + NetUserSetInfo( + null(), + name_w.as_ptr(), + 1003, + from_ref(&info).cast(), + null_mut(), + ) + }; + if rc == NERR_PasswordTooShort && attempt + 1 < MAX_PW_ATTEMPTS { + warn_password_rejected("NetUserSetInfo(1003)", attempt, password.as_str()); + continue; + } + if rc != 0 { + return Err(net_err("NetUserSetInfo(1003, password rotate)", rc)); + } + reassert_flags(&name_w)?; + return Ok(password); + } + Err(io::Error::other(format!( + "the local password policy rejected {MAX_PW_ATTEMPTS} generated passwords \ + (NERR_PasswordTooShort)" + ))) +} + +/// Re-OR `UF_DONT_EXPIRE_PASSWD` into an existing account's flags. An older nub or a GPO may +/// have cleared it since the last provision, and an expiring password silently breaks every +/// future launch with an opaque logon failure. +fn reassert_flags(name_w: &[u16]) -> io::Result<()> { + let mut buf: *mut u8 = null_mut(); + // SAFETY: NUL-terminated name; `buf` is a valid out-slot. + let rc = unsafe { NetUserGetInfo(null(), name_w.as_ptr(), 1, &mut buf) }; + if rc != 0 || buf.is_null() { + return Err(net_err("NetUserGetInfo(1)", rc)); + } + // SAFETY: on success netapi returns one USER_INFO_1 in its own buffer. + let flags = unsafe { (*buf.cast::()).usri1_flags }; + // SAFETY: the buffer came from NetUserGetInfo and is freed exactly once. + unsafe { NetApiBufferFree(buf.cast()) }; + + let info = USER_INFO_1008 { + usri1008_flags: flags | UF_DONT_EXPIRE_PASSWD, + }; + // SAFETY: `info` and `name_w` both outlive the call. + let rc = unsafe { + NetUserSetInfo( + null(), + name_w.as_ptr(), + 1008, + from_ref(&info).cast(), + null_mut(), + ) + }; + if rc != 0 { + return Err(net_err("NetUserSetInfo(1008, flags)", rc)); + } + Ok(()) +} + +fn ensure_group() -> io::Result<()> { + let mut name_w = to_wide(SANDBOX_GROUP); + let mut comment_w = to_wide("nub: holds the dedicated account used for OS-enforced sandboxing"); + let info = LOCALGROUP_INFO_1 { + lgrpi1_name: name_w.as_mut_ptr(), + lgrpi1_comment: comment_w.as_mut_ptr(), + }; + // SAFETY: both PWSTR fields point into buffers that outlive this call. + let rc = unsafe { NetLocalGroupAdd(null(), 1, from_ref(&info).cast(), null_mut()) }; + if rc != 0 && rc != NERR_GroupExists && rc != ERROR_ALIAS_EXISTS { + return Err(net_err("NetLocalGroupAdd", rc)); + } + Ok(()) +} + +/// Add `member` to the local group NAMED `group`, by PSID at level 0. Level 3 takes a name +/// and wants the literal `\` form — it rejects `.\name` with +/// `ERROR_NO_SUCH_MEMBER`, so the SID form is the only portable one. +fn add_member(group: &str, member: &LocalSid) -> io::Result<()> { + let group_w = to_wide(group); + let info = LOCALGROUP_MEMBERS_INFO_0 { + lgrmi0_sid: member.0, + }; + // SAFETY: `group_w` and the member's PSID both outlive this call. + let rc = + unsafe { NetLocalGroupAddMembers(null(), group_w.as_ptr(), 0, from_ref(&info).cast(), 1) }; + if rc != 0 && rc != ERROR_MEMBER_IN_ALIAS { + return Err(net_err(&format!("NetLocalGroupAddMembers({group})"), rc)); + } + Ok(()) +} + +/// Add or remove the Winlogon `SpecialAccounts\UserList` entry. COSMETIC ONLY — it keeps the +/// account off the sign-in picker and changes nothing about its rights; the account stays +/// fully usable through `CreateProcessWithLogonW`. +fn set_hidden(hide: bool) -> io::Result<()> { + let sub_w = to_wide(WINLOGON_USERLIST); + let val_w = to_wide(SANDBOX_ACCOUNT); + let mut key: HKEY = null_mut(); + + if hide { + // Create, not open: the SpecialAccounts subtree does not exist on a stock install. + // SAFETY: NUL-terminated subkey; `key` is a valid out-slot. + let rc = unsafe { + RegCreateKeyExW( + HKEY_LOCAL_MACHINE, + sub_w.as_ptr(), + 0, + null(), + REG_OPTION_NON_VOLATILE, + KEY_SET_VALUE, + null(), + &mut key, + null_mut(), + ) + }; + if rc != ERROR_SUCCESS { + return Err(net_err("RegCreateKeyExW(Winlogon SpecialAccounts)", rc)); + } + let data = 0u32.to_ne_bytes(); + // SAFETY: `key` is open for KEY_SET_VALUE; `data` is the 4 bytes REG_DWORD requires. + let rc = unsafe { RegSetValueExW(key, val_w.as_ptr(), 0, REG_DWORD, data.as_ptr(), 4) }; + // SAFETY: `key` came from a successful create and is closed once. + unsafe { RegCloseKey(key) }; + if rc != ERROR_SUCCESS { + return Err(net_err("RegSetValueExW(SpecialAccounts UserList)", rc)); + } + } else { + // Open, not create: if the key was never written there is nothing to remove. + // SAFETY: NUL-terminated subkey; `key` is a valid out-slot. + let rc = unsafe { + RegOpenKeyExW( + HKEY_LOCAL_MACHINE, + sub_w.as_ptr(), + 0, + KEY_SET_VALUE, + &mut key, + ) + }; + if rc != ERROR_SUCCESS { + return Ok(()); + } + // SAFETY: `key` is open for KEY_SET_VALUE. + let rc = unsafe { RegDeleteValueW(key, val_w.as_ptr()) }; + // SAFETY: `key` came from a successful open and is closed once. + unsafe { RegCloseKey(key) }; + if rc != ERROR_SUCCESS && rc != ERROR_FILE_NOT_FOUND { + return Err(net_err("RegDeleteValueW(SpecialAccounts UserList)", rc)); + } + } + Ok(()) +} + +// ───────────────────────────── credential store ───────────────────────────── + +fn store_credential(password: &str) -> io::Result<()> { + let dir = credential_dir()?; + std::fs::create_dir_all(&dir)?; + let ciphertext = dpapi_protect(password.as_bytes())?; + std::fs::write(credential_path()?, &ciphertext) +} + +fn dpapi_protect(plaintext: &[u8]) -> io::Result> { + let input = CRYPT_INTEGER_BLOB { + cbData: plaintext.len() as u32, + pbData: plaintext.as_ptr().cast_mut(), + }; + let mut out = CRYPT_INTEGER_BLOB::default(); + // SAFETY: `input` borrows a live slice for the call and `out` is a valid out-slot; every + // optional parameter is NULL, which the API documents as "unused". + let ok = unsafe { + CryptProtectData( + &input, + null(), + null(), + null(), + null(), + CRYPTPROTECT_LOCAL_MACHINE | CRYPTPROTECT_UI_FORBIDDEN, + &mut out, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: on success `out` describes a LocalAlloc'd buffer this call now owns. + Ok(unsafe { take_blob(out) }) +} + +fn dpapi_unprotect(ciphertext: &[u8]) -> io::Result> { + let input = CRYPT_INTEGER_BLOB { + cbData: ciphertext.len() as u32, + pbData: ciphertext.as_ptr().cast_mut(), + }; + let mut out = CRYPT_INTEGER_BLOB::default(); + // SAFETY: as above. No scope flag: DPAPI reads it back out of the blob header. + let ok = unsafe { + CryptUnprotectData( + &input, + null_mut(), + null(), + null(), + null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut out, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: as above. + Ok(unsafe { take_blob(out) }) +} + +/// # Safety +/// `out` must be a blob DPAPI filled in on a successful call; ownership transfers here. +/// +/// The block is SCRUBBED before it is freed: on the unprotect path it holds the decrypted +/// password, and `LocalFree` only returns the pages to the heap. +unsafe fn take_blob(out: CRYPT_INTEGER_BLOB) -> Vec { + if out.pbData.is_null() { + return Vec::new(); + } + // SAFETY: DPAPI guarantees `cbData` readable+writable bytes at `pbData`, which this call + // now owns exclusively. + let block = unsafe { std::slice::from_raw_parts_mut(out.pbData, out.cbData as usize) }; + let v = block.to_vec(); + scrub_u8(block); + // SAFETY: freed exactly once, and freed even when `cbData` is 0 — which the API can + // return for an empty plaintext. + unsafe { LocalFree(out.pbData.cast()) }; + v +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The alphabet is the whole reason the credential survives a cmd / PowerShell / argv + /// relay into `CreateProcessWithLogonW`. One stray metacharacter here becomes an + /// intermittent, machine-specific logon failure. + #[test] + fn alphabet_excludes_every_shell_hostile_character() { + assert_eq!(ALPHA.len(), 85, "the rejection bound assumes this size"); + for c in [b'"', b'\\', b'`', b'\'', b' ', b'&', b'|', b'<', b'>', b'^'] { + assert!(!ALPHA.contains(&c), "ALPHA contains {}", c as char); + } + assert!(ALPHA.iter().all(u8::is_ascii_graphic)); + // Every class must be drawable from ALPHA, or the floor below writes a character the + // uniform draw could never produce. + for class in CLASSES { + assert!(class.iter().all(|b| ALPHA.contains(b))); + } + } + + /// A tightened local complexity policy rejects a draw that happens to miss a class, and + /// the retry loop then burns attempts on identically-shaped draws. The floor is what + /// makes the first attempt pass. + #[test] + fn class_floor_repairs_a_draw_missing_every_class() { + let mut out = [b'a'; PW_LEN]; + apply_class_floor(&mut out, &[7, 0, 0, 0, 0]); + assert!(out.iter().any(u8::is_ascii_uppercase)); + assert!(out.iter().any(u8::is_ascii_lowercase)); + assert!(out.iter().any(u8::is_ascii_digit)); + assert!(out.iter().any(|b| !b.is_ascii_alphanumeric())); + } + + /// A complete draw must be left ALONE — rewriting four slots with a pick derived from + /// five bytes would shed entropy on every generated password. + #[test] + fn class_floor_leaves_a_complete_draw_untouched() { + let mut out = *b"Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!"; + let before = out; + apply_class_floor(&mut out, &[3, 9, 9, 9, 9]); + assert_eq!(out, before); + } + + /// The 2245 diagnostic is logged on a user's machine; leaking the rejected password into + /// it would be a credential disclosure in plain logs. + #[test] + fn class_summary_reports_composition_without_the_password() { + let pw = "Aa1!Aa1!"; + let s = class_summary(pw); + assert_eq!(s, "len=8 U=2 L=2 D=2 S=2"); + assert!(!s.contains(pw) && !s.contains("Aa1")); + } + + /// End-to-end shape of the generator against the real system CSPRNG. Needs Windows but no + /// elevation, so it runs on the ordinary CI leg. + #[test] + fn generated_password_is_ascii_complex_and_unique() { + let p = gen_password().expect("gen_password"); + assert_eq!(p.as_str().len(), PW_LEN); + assert!(p.as_str().bytes().all(|b| ALPHA.contains(&b))); + for class in CLASSES { + assert!( + p.as_str().bytes().any(|b| class.contains(&b)), + "missing a complexity class: {}", + class_summary(p.as_str()) + ); + } + assert_ne!(p.as_str(), gen_password().unwrap().as_str()); + } +} diff --git a/crates/nub-sandbox/src/backend/windows_account/acl.rs b/crates/nub-sandbox/src/backend/windows_account/acl.rs new file mode 100644 index 000000000..47adaa205 --- /dev/null +++ b/crates/nub-sandbox/src/backend/windows_account/acl.rs @@ -0,0 +1,1019 @@ +//! Every DACL write the account backend makes: explicit ACEs keyed on the sandbox SID for the +//! policy's paths, plus the two non-filesystem objects a launch touches (the caller's window +//! station and desktop) and the lock on nub's own credential store. +//! +//! ON USER PATHS THE MODEL IS PURELY ADDITIVE. The sandbox account is a DIFFERENT local +//! principal, so every path it was never granted is already unreachable — the invoking user's +//! profile needs no ACE authored at all. [`grant`]/[`deny`]/[`strip`] therefore only ever ADD +//! ACEs for the sandbox SID and later remove exactly those. They never rewrite, protect, or +//! snapshot a user path's descriptor, which is why there is no crash journal here and nothing +//! to restore after a hard kill beyond [`strip`]. (The abandoned deny-strip design — +//! `SE_DACL_PROTECTED` plus a DACL restore journal — is why that distinction is worth stating; +//! see [`super`].) [`lock_to_admins`] is the ONE protected write, and it lands on nub's own +//! state directory, never a user path. +//! +//! CANONICAL DACL ORDER IS THE WHOLE MECHANISM. Windows resolves an access check first-match +//! over the DACL and orders ACEs explicit-DENY → explicit-ALLOW → explicit-other → inherited +//! (any type). Because *explicit* always precedes *inherited*, a DENY written directly onto +//! `/.env` outranks the ALLOW that ``'s `(OI)(CI)` grant PROPAGATED onto it +//! as an inherited ACE. That is exactly the deny-inside-allow the AppContainer backend cannot +//! express. The hazard: a non-canonical order is ACCEPTED by Windows and still resolves +//! first-match, so a misplaced DENY silently resolves as ALLOW — a security failure with no +//! error anywhere. `SetEntriesInAclW` canonicalizes on insert and [`strip`] canonicalizes by +//! hand, and `deny_inside_a_grant_lands_before_the_inherited_allow` pins the result rather +//! than trusting either. +//! +//! ON A USER PATH, INHERITANCE IS DELIBERATELY LEFT UNPROTECTED. Every write there passes +//! `DACL_SECURITY_INFORMATION` alone, never `PROTECTED_DACL_SECURITY_INFORMATION`: the +//! invoking user's own inherited access must survive so they can still read what the sandbox +//! child creates inside a granted tree. + +#![cfg(target_os = "windows")] + +use std::io; +use std::path::{Component, Path, PathBuf}; +use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, HANDLE, LocalFree, +}; +use windows_sys::Win32::Security::Authorization::{ + ACCESS_MODE, ConvertStringSidToSidW, DENY_ACCESS, EXPLICIT_ACCESS_W, GRANT_ACCESS, + GetNamedSecurityInfoW, GetSecurityInfo, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SE_WINDOW_OBJECT, + SetEntriesInAclW, SetNamedSecurityInfoW, SetSecurityInfo, TRUSTEE_IS_SID, TRUSTEE_IS_UNKNOWN, + TRUSTEE_W, +}; +use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_SIZE_INFORMATION, AclSizeInformation, AddAce, + CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetAclInformation, + GetTokenInformation, InitializeAcl, OBJECT_INHERIT_ACE, OWNER_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, TOKEN_QUERY, TOKEN_USER, + TokenUser, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; +// `FILE_TRAVERSE` is the SAME bit as `FILE_EXECUTE` — the kernel reads it as traverse on a +// directory and as execute on a file, and no primitive separates them. It is imported under +// the traverse spelling because that is the property the mask assertions below are about. +use windows_sys::Win32::Storage::FileSystem::{ + DELETE, FILE_ALL_ACCESS, FILE_DELETE_CHILD, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, + FILE_GENERIC_WRITE, FILE_TRAVERSE, WRITE_DAC, WRITE_OWNER, +}; + +/// Read + write + execute + delete. What a policy's write grant stamps. +const RW: u32 = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE; + +/// Read + execute. What a policy's read grant stamps. +const RO: u32 = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE; + +// What these masks LEAVE OUT is the security property, and an omission is invisible on +// inspection — so each exclusion is asserted at compile time, against windows-sys' own values. +const _: () = { + // `FILE_DELETE_CHILD` on a granted PARENT is checked INSTEAD OF `DELETE` on the child, so + // including it would let the account delete a file carrying a full deny ACE. That single + // bit voids every deny-inside-allow rule the policy can express. + assert!(RW & FILE_DELETE_CHILD == 0); + // Either bit lets the confined account rewrite its own confinement. + assert!(RW & (WRITE_DAC | WRITE_OWNER) == 0); + assert!(RO & (WRITE_DAC | WRITE_OWNER) == 0); + // The ladder must nest, or a read grant could reach something a write grant cannot. + assert!(RW & RO == RO); + // Without traverse, `SetCurrentDirectoryW` into a granted directory fails + // ERROR_ACCESS_DENIED — which is why `FILE_GENERIC_EXECUTE` is in BOTH masks. + assert!(RO & FILE_TRAVERSE != 0); +}; + +const ACCESS_ALLOWED_ACE_TYPE: u8 = 0x00; +const ACCESS_DENIED_ACE_TYPE: u8 = 0x01; +const INHERITED_ACE_FLAG: u8 = 0x10; + +/// `NT AUTHORITY\SYSTEM` and `BUILTIN\Administrators`, by well-known SID because the NAMES are +/// localized ("Administratoren", "Administrateurs") and the SIDs are not. +const SID_LOCAL_SYSTEM: &str = "S-1-5-18"; +const SID_BUILTIN_ADMINISTRATORS: &str = "S-1-5-32-544"; + +/// What a grant hands the sandbox account on a subtree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Access { + Read, + ReadWrite, +} + +impl Access { + fn mask(self) -> u32 { + match self { + Access::Read => RO, + Access::ReadWrite => RW, + } + } +} + +/// Add an inheritable ALLOW ace for `sid` on `path`. +/// +/// Inheritance is applied only on a directory: `(OI)(CI)` on a leaf file is meaningless and +/// Windows would reject or silently strip it. +pub(crate) fn grant(path: &Path, sid: &str, access: Access) -> io::Result<()> { + let (target, is_dir) = resolve(path)?; + let sid = OwnedSid::parse(sid)?; + add_ace(&target, &sid, access.mask(), GRANT_ACCESS, is_dir) +} + +/// Add an inheritable DENY ace for `sid` on `path`, plus the parent-side carve that stops the +/// account deleting or renaming `path` through its parent directory. +/// +/// Stamps exactly the paths [`deny_targets`] reports, which the caller must have recorded in +/// the ledger first — the parent carve is a SECOND ace on a SECOND object, and one that is +/// never stripped outlives every run as a permanent explicit DENY for the sandbox SID. +pub(crate) fn deny(path: &Path, sid: &str) -> io::Result<()> { + let (target, is_dir) = resolve(path)?; + let sid = OwnedSid::parse(sid)?; + add_ace(&target, &sid, FILE_ALL_ACCESS, DENY_ACCESS, is_dir)?; + + // The counterpart to excluding `FILE_DELETE_CHILD` from the grant masks: that exclusion + // only covers rights WE stamp, while the account may hold the bit on the parent through + // an inherited `BUILTIN\Users` ACE it picks up as a local user. Denying it explicitly on + // the parent closes `del`/`ren` of the denied target. NOT inheritable — the check that + // matters is against the parent directory object itself, so propagating it down would + // confine sibling subtrees for no gain. (SRT applies the same carve with `(OI)(CI)`.) + let Some(parent) = carve_parent(&target) else { + return Ok(()); + }; + add_ace(&parent, &sid, FILE_DELETE_CHILD, DENY_ACCESS, false) +} + +/// Every path a [`deny`] on `path` will stamp: the resolved target, and the parent carrying +/// its `FILE_DELETE_CHILD` carve when there is one. +/// +/// Exposed rather than merely returned by [`deny`] so the caller can honor the ledger's +/// record-BEFORE-apply rule for both aces — recording after the fact would leave the parent +/// carve unrecorded across the window a crash can land in. +pub(crate) fn deny_targets(path: &Path) -> io::Result> { + let (target, _) = resolve(path)?; + let mut out = Vec::with_capacity(2); + out.extend(carve_parent(&target)); + out.push(target); + Ok(out) +} + +/// The directory `target`'s delete-through carve belongs on, or `None` when there is none. +/// +/// `Path::parent` on the extended-length spellings [`resolve`] produces returns the VOLUME +/// ROOT for a top-level target (`\\?\C:\secrets.env` → `\\?\C:\`), which must not be ACE'd: +/// unelevated that fails the whole run with a misleading error, and where the caller does hold +/// `WRITE_DAC` it writes a permanent explicit DENY onto the drive itself. The accepted cost is +/// that a deny on a volume-root child gets no delete-through carve — `BUILTIN\Users` holds no +/// `FILE_DELETE_CHILD` on a default `C:\`, so the account has nothing to carve away. +fn carve_parent(target: &Path) -> Option { + target + .parent() + .filter(|p| !is_volume_root(p)) + .map(Path::to_path_buf) +} + +/// Whether `p` names a volume root — a prefix and its root separator with no named component +/// under it. Covers both forms [`resolve`] emits: `\\?\C:\` (`Prefix` + `RootDir`) and +/// `\\?\UNC\server\share\`, where the share itself is part of the prefix. +fn is_volume_root(p: &Path) -> bool { + p.components() + .all(|c| matches!(c, Component::Prefix(_) | Component::RootDir)) +} + +/// Take ownership of `dir` and replace its DACL with a PROTECTED one naming ONLY SYSTEM, +/// `BUILTIN\Administrators` and the calling user — the lock on nub's own sandbox state +/// directory. +/// +/// PROTECTED IS CORRECT HERE AND NOWHERE ELSE IN THIS MODULE. `%PROGRAMDATA%\nub\sandbox` +/// inherits ProgramData's `BUILTIN\Users:(RX)`, and it holds the DPAPI credential — whose +/// machine scope is explicitly NOT a boundary, so any local user who can READ the ciphertext +/// can decrypt it and then hold the sandbox account's password. Blocking that inheritance is +/// the entire boundary; an additive deny cannot express it. This is nub's own state directory, +/// so the module doc's unprotected rule (which exists to preserve a USER's inherited access on +/// THEIR files) does not apply. +/// +/// THE OWNER IS RESET TOO, AND THAT HALF IS NOT OPTIONAL. An object's owner holds implicit +/// `READ_CONTROL | WRITE_DAC` whatever the DACL says, and `%PROGRAMDATA%` grants +/// `BUILTIN\Users:(CI)(AD)` — so a standard user can pre-create this directory before setup +/// has ever run, stay its owner through the lock, then rewrite the DACL back and read the +/// credential. Handing ownership to `BUILTIN\Administrators` is what closes that; a protected +/// DACL on its own does not. (Same pre-create-and-own primitive the marker's identity check +/// defends against — see [`super::state::Marker`].) +/// +/// The calling user is named so the provisioning administrator's later UNELEVATED runs can +/// still read the marker and append the ledger. Consequence, deliberate: when setup is +/// elevated with a DIFFERENT admin account (over-the-shoulder UAC), the ordinary user is not +/// named and their runs fail closed as "not provisioned". +pub(crate) fn lock_to_admins(dir: &Path) -> io::Result<()> { + let (target, _) = resolve(dir)?; + let system = OwnedSid::parse(SID_LOCAL_SYSTEM)?; + let admins = OwnedSid::parse(SID_BUILTIN_ADMINISTRATORS)?; + let token_user = current_token_user()?; + // SAFETY: the block holds one `TOKEN_USER` whose `User.Sid` points inside it, and it + // outlives every use of the pointer below. + let me = unsafe { (*token_user.as_ptr().cast::()).User.Sid }; + + let entries: Vec = [system.0, admins.0, me] + .into_iter() + .map(|sid| explicit_access(sid, FILE_ALL_ACCESS, GRANT_ACCESS, true)) + .collect(); + + let mut new_dacl: *mut ACL = std::ptr::null_mut(); + // A NULL "old ACL" is the point: the result is built from these three entries ALONE, so + // nothing pre-existing and nothing inherited survives into it. + // SAFETY: every entry and the SIDs they point at outlive the call; `new_dacl` is a valid + // out-slot. + let rc = unsafe { + SetEntriesInAclW( + entries.len() as u32, + entries.as_ptr(), + std::ptr::null_mut(), + &mut new_dacl, + ) + }; + if rc != 0 { + return Err(win32_err("SetEntriesInAclW", &target, rc)); + } + let _guard = LocalFreeGuard(new_dacl.cast()); + + let wpath = wide(&target); + // SAFETY: `new_dacl` is a live ACL from SetEntriesInAclW, `admins` outlives the call, and + // `wpath` is NUL-terminated UTF-16. An elevated token carries `BUILTIN\Administrators` as + // a valid owner, so the OWNER write needs no extra privilege. + let rc = unsafe { + SetNamedSecurityInfoW( + wpath.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION, + admins.0, + std::ptr::null_mut(), + new_dacl, + std::ptr::null(), + ) + }; + if rc != 0 { + return Err(win32_err("SetNamedSecurityInfoW", &target, rc)); + } + Ok(()) +} + +/// The calling process's `TOKEN_USER` block. Returned as its raw backing buffer because the +/// SID inside it is only valid while that buffer lives. `u64`-backed because `TOKEN_USER`'s +/// first field is a `PSID` POINTER — 8-aligned on x64, which a `Vec` does not promise. +fn current_token_user() -> io::Result> { + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: query-only handle onto this process's own token. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let out = read_token_user(token); + // SAFETY: `token` came from a successful OpenProcessToken and is closed exactly once. + unsafe { CloseHandle(token) }; + out +} + +fn read_token_user(token: HANDLE) -> io::Result> { + let mut len: u32 = 0; + // SAFETY: the documented sizing form — NULL buffer, zero length; it fails and sets `len`. + unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut len) }; + if len == 0 { + return Err(io::Error::last_os_error()); + } + let mut buf: Vec = vec![0; (len as usize).div_ceil(8)]; + // SAFETY: `buf` holds at least `len` bytes and outlives the call. + let ok = + unsafe { GetTokenInformation(token, TokenUser, buf.as_mut_ptr().cast(), len, &mut len) }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(buf) +} + +// ── window station + desktop: the launch's other securable objects ────────────── + +/// Add an ALLOW ace for `sid` on a window-station or desktop HANDLE, returning the DACL that +/// was there first so [`restore_window_object`] can put it back byte-exactly. +/// +/// These are `SE_WINDOW_OBJECT`s — handle-addressed, with no named-path form — so this is the +/// `Get`/`SetSecurityInfo` twin of [`add_ace`] rather than a variant of it. The ace is NOT +/// inheritable: the caller aces the desktop directly rather than relying on propagation from +/// the station. +pub(crate) fn grant_window_object( + handle: HANDLE, + sid: &str, + mask: u32, +) -> io::Result>> { + let sid = OwnedSid::parse(sid)?; + let existing = ReadWindowDacl::open(handle)?; + let saved = copy_acl(existing.acl); + + let ea = explicit_access(sid.0, mask, GRANT_ACCESS, false); + let mut new_dacl: *mut ACL = std::ptr::null_mut(); + // SAFETY: `ea` and the SID it points at outlive the call; `existing.acl` came from + // GetSecurityInfo and may legitimately be NULL. + let rc = unsafe { SetEntriesInAclW(1, &ea, existing.acl, &mut new_dacl) }; + if rc != 0 { + return Err(win32_obj_err("SetEntriesInAclW", rc)); + } + let _guard = LocalFreeGuard(new_dacl.cast()); + set_window_dacl(handle, new_dacl)?; + Ok(saved) +} + +/// Put back what [`grant_window_object`] returned; `None` restores the NULL DACL the object +/// had. A byte-exact restore rather than a trustee-keyed strip because this module already +/// documents that `SetEntriesInAclW(REVOKE_ACCESS)` cannot be trusted to remove an ace (see +/// [`strip`]). It is NOT better under concurrency — restoring the DACL the FIRST run saw +/// discards a second run's ace while its child is still alive, and a SID-keyed strip would +/// too, since every run shares the account (recorded in LIMITATIONS.md). +pub(crate) fn restore_window_object(handle: HANDLE, dacl: Option<&[u32]>) -> io::Result<()> { + let ptr = dacl.map_or(std::ptr::null(), |d| d.as_ptr().cast::()); + set_window_dacl(handle, ptr) +} + +fn set_window_dacl(handle: HANDLE, dacl: *const ACL) -> io::Result<()> { + // SAFETY: `handle` is a live window-station/desktop handle owned by this process; `dacl` + // is NULL or a live ACL outliving the call. DACL only — never PROTECTED, so whatever the + // object inherited stays. + let rc = unsafe { + SetSecurityInfo( + handle, + SE_WINDOW_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + dacl, + std::ptr::null(), + ) + }; + if rc != 0 { + return Err(win32_obj_err("SetSecurityInfo", rc)); + } + Ok(()) +} + +/// A DWORD-aligned byte copy of `acl`, or `None` for a NULL (no-DACL) descriptor. The source +/// lives inside a descriptor freed the moment the read guard drops, so a later restore has to +/// own its bytes. +fn copy_acl(acl: *mut ACL) -> Option> { + if acl.is_null() { + return None; + } + // SAFETY: `acl` is a live ACL, whose `AclSize` is its own total length in bytes. + let size = usize::from(unsafe { (*acl).AclSize }); + let mut buf: Vec = vec![0; size.div_ceil(4)]; + // SAFETY: source and destination are both at least `size` bytes and cannot overlap. + unsafe { + std::ptr::copy_nonoverlapping(acl.cast::(), buf.as_mut_ptr().cast::(), size); + } + Some(buf) +} + +/// A window object's DACL plus the descriptor owning its storage — the `SE_WINDOW_OBJECT` twin +/// of [`ReadDacl`]. The descriptor MUST outlive every read of `acl`, which points INTO it. +struct ReadWindowDacl { + acl: *mut ACL, + _sd: LocalFreeGuard, +} + +impl ReadWindowDacl { + fn open(handle: HANDLE) -> io::Result { + let mut acl: *mut ACL = std::ptr::null_mut(); + let mut sd: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: `handle` is a live window-station/desktop handle; every out-param is a valid + // slot and the unwanted ones are NULL. + let rc = unsafe { + GetSecurityInfo( + handle, + SE_WINDOW_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut acl, + std::ptr::null_mut(), + &mut sd, + ) + }; + if rc != 0 { + return Err(win32_obj_err("GetSecurityInfo", rc)); + } + Ok(ReadWindowDacl { + acl, + _sd: LocalFreeGuard(sd), + }) + } +} + +/// The handle-addressed counterpart to [`win32_err`]: a window object has no path to name, and +/// access-denied here means the caller cannot re-ACL its own station rather than anything +/// about a file. +fn win32_obj_err(op: &str, rc: u32) -> io::Error { + if rc == ERROR_ACCESS_DENIED { + return io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{op} on this session's window object: access denied"), + ); + } + io::Error::other(format!( + "{op} on this session's window object failed (Win32 error {rc})" + )) +} + +/// Remove EVERY explicit ace whose trustee is `sid`, preserving all other explicit aces +/// verbatim and leaving inherited aces alone. Idempotent; safe on a path with no such ace. +/// +/// This deliberately does NOT use `SetEntriesInAclW(REVOKE_ACCESS)`: on Windows 11 25H2 that +/// fails to remove explicit `ACCESS_DENIED` aces (field-observed, and regression-tested as +/// MXC's `deny_round_trip_leaves_no_residue`). The documented behavior and the observed +/// behavior disagree, so the DACL is rebuilt by hand instead. +pub(crate) fn strip(path: &Path, sid: &str) -> io::Result<()> { + let (target, _) = resolve(path)?; + let sid = OwnedSid::parse(sid)?; + + let dacl = ReadDacl::open(&target)?; + let Some(rebuilt) = rebuild_without_sid(&target, dacl.acl, &sid)? else { + // Nothing matched. Returning early is not just an optimization: writing a rebuilt + // DACL onto a path whose DACL is NULL would replace "no DACL, everyone allowed" with + // an EMPTY DACL, which denies everyone — a catastrophic silent lockout on a path we + // were only asked to clean up. + return Ok(()); + }; + + let wpath = wide(&target); + // SAFETY: `rebuilt` is a live, DWORD-aligned, InitializeAcl'd buffer that outlives the + // call; `wpath` is NUL-terminated UTF-16 and likewise outlives it. DACL only — + // inheritance stays unprotected so the invoking user keeps their inherited access. + let rc = unsafe { + SetNamedSecurityInfoW( + wpath.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + rebuilt.as_ptr().cast::(), + std::ptr::null(), + ) + }; + if rc != 0 { + return Err(win32_err("SetNamedSecurityInfoW", &target, rc)); + } + Ok(()) +} + +/// One `SetEntriesInAclW` read-modify-write. `SetEntriesInAclW` merges the new entry into the +/// path's existing DACL and canonicalizes on insert, so this is additive: no pre-existing ace, +/// explicit or inherited, is disturbed. +fn add_ace( + path: &Path, + sid: &OwnedSid, + mask: u32, + mode: ACCESS_MODE, + inherit: bool, +) -> io::Result<()> { + let wpath = wide(path); + let dacl = ReadDacl::open(path)?; + + // A NULL DACL means "no DACL at all", which Windows reads as UNRESTRICTED access — not as + // an empty allow-set. `SetEntriesInAclW` merging into NULL yields a DACL containing ONLY + // our ace, and writing that converts unrestricted into "the sandbox account and nobody + // else", permanently locking out the object's own owner. That is destructive and violates + // this module's additive-on-user-paths contract, so neither polarity may write here. + // (VM-observed on a `C:\Windows\Temp` child, 2026-07-25: a grant reduced a 7-ace DACL to + // one `nub-sandbox` ace and the owner lost traverse. `strip` already guards the mirror + // case; `add_ace` did not.) + if dacl.acl.is_null() { + return match mode { + // Nothing to grant: a NULL DACL already admits the sandbox account. + GRANT_ACCESS => { + tracing::debug!( + path = %path.display(), + "sandbox: grant skipped — path has a NULL DACL, so access is already unrestricted" + ); + Ok(()) + } + // FAIL CLOSED. The deny cannot be expressed without replacing the object's + // permissive state wholesale, and silently skipping it would leave a hole while + // reporting full enforcement. + _ => Err(io::Error::other(format!( + "sandbox: cannot deny {} to the sandbox account — the path has a NULL DACL \ + (unrestricted access), and adding a deny there would replace that with a DACL \ + that locks out its own owner. Give the path an explicit DACL, or drop it from \ + the policy's deny list.", + path.display() + ))), + }; + } + + let ea = explicit_access(sid.0, mask, mode, inherit); + + let mut new_dacl: *mut ACL = std::ptr::null_mut(); + // SAFETY: `ea` and the SID it points at outlive the call; `dacl.acl` came from + // GetNamedSecurityInfoW and may legitimately be NULL. + let rc = unsafe { SetEntriesInAclW(1, &ea, dacl.acl, &mut new_dacl) }; + if rc != 0 { + return Err(win32_err("SetEntriesInAclW", path, rc)); + } + let _new_guard = LocalFreeGuard(new_dacl.cast()); + + // SAFETY: `new_dacl` is a live ACL from SetEntriesInAclW; the path buffer is + // NUL-terminated. `DACL_SECURITY_INFORMATION` only — never PROTECTED (see module doc). + let rc = unsafe { + SetNamedSecurityInfoW( + wpath.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + new_dacl, + std::ptr::null(), + ) + }; + if rc != 0 { + return Err(win32_err("SetNamedSecurityInfoW", path, rc)); + } + Ok(()) +} + +/// One ace description for `SetEntriesInAclW`. The returned struct BORROWS `sid`, which must +/// outlive the call it is passed to. +fn explicit_access(sid: PSID, mask: u32, mode: ACCESS_MODE, inherit: bool) -> EXPLICIT_ACCESS_W { + EXPLICIT_ACCESS_W { + grfAccessPermissions: mask, + grfAccessMode: mode, + grfInheritance: if inherit { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + 0 + }, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + // UNKNOWN, not `TRUSTEE_IS_USER`: this helper also stamps the well-known + // `BUILTIN\Administrators` and SYSTEM SIDs, and the field is advisory anyway. + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: sid.cast(), + }, + } +} + +/// Rebuild `existing`'s ace list without any explicit ace for `sid`, in canonical order. +/// `Ok(None)` means nothing matched and the caller must not write anything back. +/// +/// The rebuilt buffer is a `Vec` because `InitializeAcl` requires DWORD alignment. +fn rebuild_without_sid( + path: &Path, + existing: *mut ACL, + sid: &OwnedSid, +) -> io::Result>> { + // (bucket, original index, ace pointer, ace size). The stable sort on (bucket, index) + // preserves each bucket's original ordering so unrelated aces never shuffle. + let mut kept: Vec<(u8, u32, *const std::ffi::c_void, u32)> = Vec::new(); + let mut kept_bytes: u32 = 0; + let mut dropped = 0usize; + + walk_aces(existing, path, |i, header, ace| { + let inherited = header.AceFlags & INHERITED_ACE_FLAG != 0; + // Only the allow/deny types share the `{ header, mask, SidStart }` layout the SID is + // read out of, and they are the only types this module ever writes. An ace of any + // other type — object, callback, audit — is copied through untouched rather than + // guessed at: keeping a foreign ace is safe, dropping one corrupts someone else's ACL. + let is_ours = !inherited + && matches!( + header.AceType, + ACCESS_ALLOWED_ACE_TYPE | ACCESS_DENIED_ACE_TYPE + ) + // SAFETY: layout checked above; `SidStart` is the first DWORD of the inline SID. + && unsafe { EqualSid(sid_of(ace), sid.0) } != 0; + + if is_ours { + dropped += 1; + return; + } + kept.push(( + canonical_bucket(header.AceType, inherited), + i, + ace.cast_const(), + u32::from(header.AceSize), + )); + kept_bytes += u32::from(header.AceSize); + })?; + + if dropped == 0 { + return Ok(None); + } + // Leaving our own ace in place is the deliberate lesser evil: a residual ace for a confined + // account is over-permission, while an empty DACL locks the OWNER out of their own file + // and is not recoverable without taking ownership. + if kept.is_empty() { + tracing::debug!( + path = %path.display(), + "sandbox: ace strip skipped — our aces are the only ones on this path, and an \ + empty DACL would deny everyone" + ); + return Ok(None); + } + kept.sort_by_key(|&(bucket, index, _, _)| (bucket, index)); + + let acl_bytes = (std::mem::size_of::() as u32 + kept_bytes).next_multiple_of(4); + let mut buf: Vec = vec![0; (acl_bytes as usize).div_ceil(4)]; + let acl = buf.as_mut_ptr().cast::(); + + // Preserve the source revision rather than assuming ACL_REVISION: an ACL carrying object + // aces is revision 4, and AddAce rejects a revision-4 ace into a revision-2 ACL. + // SAFETY: `existing` is live; `acl` points at `acl_bytes` of zeroed, DWORD-aligned space. + let revision = u32::from(unsafe { (*existing).AclRevision }); + // SAFETY: as above. + if unsafe { InitializeAcl(acl, acl_bytes, revision) } == 0 { + return Err(win32_last_err("InitializeAcl", path)); + } + + // `AddAce` and the `AddAccess{Allowed,Denied}AceEx` family both APPEND at the tail and do + // NOT canonicalize despite the latter's name, so the bucket order above is what actually + // produces a canonical DACL. Emitting out of order would still be accepted by Windows and + // would silently resolve a deny as an allow. + for &(_, _, ace, size) in &kept { + // SAFETY: `acl` was sized to hold exactly these aces; each `ace` is a live + // `size`-byte ace inside the descriptor `ReadDacl` keeps alive across this call. + if unsafe { AddAce(acl, revision, u32::MAX, ace, size) } == 0 { + return Err(win32_last_err("AddAce", path)); + } + } + Ok(Some(buf)) +} + +/// Walk every ace in `acl` in DACL order. A NULL `acl` means "no DACL, everything allowed" +/// and yields nothing. +fn walk_aces( + acl: *mut ACL, + path: &Path, + mut f: impl FnMut(u32, ACE_HEADER, *mut std::ffi::c_void), +) -> io::Result<()> { + if acl.is_null() { + return Ok(()); + } + let mut info = ACL_SIZE_INFORMATION { + AceCount: 0, + AclBytesInUse: 0, + AclBytesFree: 0, + }; + // SAFETY: `acl` is a live ACL from GetNamedSecurityInfoW; `info` is a correctly sized + // out-slot for the AclSizeInformation class. + let ok = unsafe { + GetAclInformation( + acl, + std::ptr::from_mut(&mut info).cast(), + std::mem::size_of::() as u32, + AclSizeInformation, + ) + }; + if ok == 0 { + return Err(win32_last_err("GetAclInformation", path)); + } + for i in 0..info.AceCount { + let mut ace: *mut std::ffi::c_void = std::ptr::null_mut(); + // SAFETY: `i` is below the reported AceCount of a live ACL. + if unsafe { GetAce(acl, i, &mut ace) } == 0 { + return Err(win32_last_err("GetAce", path)); + } + // SAFETY: every ace GetAce yields begins with an ACE_HEADER. + f(i, unsafe { *ace.cast::() }, ace); + } + Ok(()) +} + +/// The trustee SID of an allow/deny ace. Caller must have checked the type — the inline +/// `SidStart` field is only at this offset for the types sharing `ACCESS_ALLOWED_ACE`'s +/// layout. +fn sid_of(ace: *mut std::ffi::c_void) -> PSID { + // SAFETY: `addr_of!` takes the field address without forming a reference to the + // variable-length SID that follows it. + unsafe { std::ptr::addr_of!((*ace.cast::()).SidStart) } + .cast_mut() + .cast() +} + +/// Canonical-order bucket: explicit DENY, explicit ALLOW, explicit other, then inherited. +/// Smaller sorts earlier. +fn canonical_bucket(ace_type: u8, inherited: bool) -> u8 { + if inherited { + return 3; + } + match ace_type { + ACCESS_DENIED_ACE_TYPE => 0, + ACCESS_ALLOWED_ACE_TYPE => 1, + _ => 2, + } +} + +/// A path's DACL plus the security descriptor that owns its storage. The descriptor MUST +/// outlive every read of `acl` — the ACL points INTO it. +struct ReadDacl { + acl: *mut ACL, + _sd: LocalFreeGuard, +} + +impl ReadDacl { + fn open(path: &Path) -> io::Result { + let wpath = wide(path); + let mut acl: *mut ACL = std::ptr::null_mut(); + let mut sd: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: `wpath` is NUL-terminated UTF-16 and outlives the call; every out-param is + // a valid slot and the unwanted ones are NULL. + let rc = unsafe { + GetNamedSecurityInfoW( + wpath.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut acl, + std::ptr::null_mut(), + &mut sd, + ) + }; + if rc != 0 { + return Err(win32_err("GetNamedSecurityInfoW", path, rc)); + } + Ok(ReadDacl { + acl, + _sd: LocalFreeGuard(sd), + }) + } +} + +/// A SID parsed from its string form, `LocalFree`d on drop. +struct OwnedSid(PSID); + +impl OwnedSid { + fn parse(s: &str) -> io::Result { + let wide: Vec = s.encode_utf16().chain(std::iter::once(0)).collect(); + let mut sid: PSID = std::ptr::null_mut(); + // SAFETY: `wide` is NUL-terminated and outlives the call; `sid` is a valid out-slot. + if unsafe { ConvertStringSidToSidW(wide.as_ptr(), &mut sid) } == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("not a valid SID string: {s}"), + )); + } + Ok(OwnedSid(sid)) + } +} + +impl Drop for OwnedSid { + fn drop(&mut self) { + // SAFETY: `self.0` came from ConvertStringSidToSidW, which documents LocalFree. + unsafe { LocalFree(self.0) }; + } +} + +struct LocalFreeGuard(*mut std::ffi::c_void); + +impl Drop for LocalFreeGuard { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: every pointer wrapped here came from an API documenting LocalFree. + unsafe { LocalFree(self.0) }; + } + } +} + +/// Canonicalize for the Win32 security APIs, returning the path and whether it is a directory. +/// +/// `fs::canonicalize` emits `\\?\C:\…` and `\\?\UNC\server\share\…` — the extended-length +/// spellings both `Get`/`SetNamedSecurityInfoW` accept, and the only forms that are correct +/// for BOTH drive and UNC inputs (a naive `\\?\` prefix produces a malformed UNC path). It +/// also supplies the not-found check for free. Deliberate consequence: a symlink or junction +/// resolves to its TARGET, so the ACE lands on the object an open actually reaches rather than +/// on a reparse point, which does not gate content access. +fn resolve(path: &Path) -> io::Result<(PathBuf, bool)> { + let canonical = std::fs::canonicalize(path).map_err(|e| { + if e.kind() == io::ErrorKind::NotFound { + io::Error::new( + io::ErrorKind::NotFound, + format!("sandbox ACL target does not exist: {}", path.display()), + ) + } else { + io::Error::other(format!( + "resolving sandbox ACL target {}: {e}", + path.display() + )) + } + })?; + let is_dir = std::fs::metadata(&canonical)?.is_dir(); + Ok((canonical, is_dir)) +} + +fn wide(p: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + p.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +fn win32_err(op: &str, path: &Path, rc: u32) -> io::Error { + match rc { + ERROR_ACCESS_DENIED => io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "{op} on {}: access denied — nub holds no WRITE_DAC on this path, so the \ + sandbox account's access cannot be set", + path.display() + ), + ), + ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND => io::Error::new( + io::ErrorKind::NotFound, + format!("{op} on {}: path no longer exists", path.display()), + ), + _ => io::Error::other(format!( + "{op} on {} failed (Win32 error {rc})", + path.display() + )), + } +} + +/// For the ACL-surgery calls, which report failure through `GetLastError` rather than a +/// returned status. +fn win32_last_err(op: &str, path: &Path) -> io::Error { + let rc = io::Error::last_os_error().raw_os_error().unwrap_or(0) as u32; + win32_err(op, path, rc) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One ace, flattened enough to assert ordering and byte-level preservation without + /// holding a pointer into a freed descriptor. + struct Ace { + ace_type: u8, + inherited: bool, + bytes: Vec, + } + + /// Every ace on `path`'s DACL, in DACL order. Shares [`walk_aces`] with the production + /// rebuild so the assertion can never drift from what `strip` actually sees. + fn read_aces(path: &Path) -> Vec { + let (canonical, _) = resolve(path).expect("resolve"); + let dacl = ReadDacl::open(&canonical).expect("GetNamedSecurityInfoW"); + let mut out = Vec::new(); + walk_aces(dacl.acl, &canonical, |_, header, ace| { + // SAFETY: `AceSize` is the ace's own length, inside the descriptor `dacl` holds. + let bytes = + unsafe { std::slice::from_raw_parts(ace.cast::(), header.AceSize as usize) }; + out.push(Ace { + ace_type: header.AceType, + inherited: header.AceFlags & INHERITED_ACE_FLAG != 0, + bytes: bytes.to_vec(), + }); + }) + .expect("walk DACL"); + out + } + + /// The raw SID bytes a `S-1-…` string parses to, for matching aces by trustee. + fn sid_bytes(sid: &str) -> Vec { + let parsed = OwnedSid::parse(sid).expect("parse SID"); + // SubAuthorityCount is byte 1; a SID is 8 + 4 * count bytes. + let head = unsafe { std::slice::from_raw_parts(parsed.0.cast::(), 2) }; + let len = 8 + 4 * head[1] as usize; + unsafe { std::slice::from_raw_parts(parsed.0.cast::(), len) }.to_vec() + } + + /// Explicit allow/deny aces on `aces` whose trustee is `sid` — the SID sits at offset 8, + /// immediately after the 4-byte header and the 4-byte mask. + fn explicit_for(aces: &[Ace], sid: &[u8]) -> Vec> { + aces.iter() + .filter(|a| { + !a.inherited + && matches!(a.ace_type, ACCESS_ALLOWED_ACE_TYPE | ACCESS_DENIED_ACE_TYPE) + && a.bytes.len() >= 8 + sid.len() + && &a.bytes[8..8 + sid.len()] == sid + }) + .map(|a| a.bytes.clone()) + .collect() + } + + /// No explicit ace after any inherited one; no explicit DENY after any explicit ALLOW. + fn assert_canonical(aces: &[Ace], label: &str) { + let order: Vec<(u8, bool)> = aces.iter().map(|a| (a.ace_type, a.inherited)).collect(); + let mut saw_inherited = false; + let mut saw_allow = false; + for (i, a) in aces.iter().enumerate() { + if a.inherited { + saw_inherited = true; + continue; + } + assert!( + !saw_inherited, + "{label}: explicit ace at {i} follows an inherited ace; order={order:?}" + ); + match a.ace_type { + ACCESS_DENIED_ACE_TYPE => assert!( + !saw_allow, + "{label}: explicit DENY at {i} follows an explicit ALLOW — Windows accepts \ + this and resolves it first-match, so the deny would silently read as \ + allow; order={order:?}" + ), + ACCESS_ALLOWED_ACE_TYPE => saw_allow = true, + _ => {} + } + } + } + + /// BUILTIN\Users. Always resolves, harmless to ace on a temp dir the test owns, and does + /// not appear as an *explicit* ace under a user-profile `%TEMP%`. + const SID: &str = "S-1-5-32-545"; + /// Everyone — a second, distinct trustee for the preservation test. + const OTHER_SID: &str = "S-1-1-0"; + + /// A top-level deny target's `Path::parent` IS the volume root, so without this check + /// `deny: ["C:/creds.json"]` writes a permanent explicit DENY ace onto the whole drive + /// (or, unelevated, aborts the run with an error naming a path the policy never mentioned). + #[test] + fn a_volume_root_is_recognized_and_carries_no_carve() { + for root in [r"\\?\C:\", r"\\?\UNC\server\share\"] { + assert!(is_volume_root(Path::new(root)), "{root}"); + } + for under in [r"\\?\C:\creds.json", r"\\?\UNC\server\share\creds.json"] { + assert!(!is_volume_root(Path::new(under)), "{under}"); + } + assert_eq!(carve_parent(Path::new(r"\\?\C:\creds.json")), None); + assert_eq!( + carve_parent(Path::new(r"\\?\C:\proj\.env")), + Some(PathBuf::from(r"\\?\C:\proj")) + ); + } + + /// The property the whole agent-sandbox fs axis rests on: a deny written onto a file + /// inside a granted tree must land AHEAD of the allow the tree's `(OI)(CI)` grant + /// propagated onto that file. Windows accepts the wrong order silently, so nothing but an + /// assertion catches a regression here. + #[test] + fn deny_inside_a_grant_lands_before_the_inherited_allow() { + let td = tempfile::tempdir().unwrap(); + grant(td.path(), SID, Access::ReadWrite).expect("grant"); + + // Created AFTER the grant, so the inheritable allow propagates onto it as an + // INHERITED ace — the exact shape the deny has to outrank. + let secret = td.path().join(".env"); + std::fs::write(&secret, b"TOKEN=x").unwrap(); + deny(&secret, SID).expect("deny"); + + let aces = read_aces(&secret); + assert_canonical(&aces, "deny inside grant"); + assert!( + aces.iter().any(|a| a.inherited), + "the grant should have propagated an inherited ace onto the file" + ); + assert!( + aces.iter() + .any(|a| !a.inherited && a.ace_type == ACCESS_DENIED_ACE_TYPE), + "the deny should be present as an EXPLICIT ace" + ); + } + + /// The regression `SetEntriesInAclW(REVOKE_ACCESS)` fails: on Windows 11 25H2 an explicit + /// ACCESS_DENIED ace survives a REVOKE. Baseline-relative so a pre-existing ace for the + /// same SID cannot make it pass or fail spuriously. + #[test] + fn strip_removes_a_deny_ace() { + let td = tempfile::tempdir().unwrap(); + let sid = sid_bytes(SID); + let baseline = explicit_for(&read_aces(td.path()), &sid).len(); + + grant(td.path(), SID, Access::ReadWrite).expect("grant"); + deny(td.path(), SID).expect("deny"); + assert!( + explicit_for(&read_aces(td.path()), &sid).len() > baseline, + "setup did not add any explicit ace to strip" + ); + + strip(td.path(), SID).expect("strip"); + assert_eq!( + explicit_for(&read_aces(td.path()), &sid).len(), + baseline, + "strip left residue — the REVOKE_ACCESS deny-survival defect is back" + ); + assert_canonical(&read_aces(td.path()), "after strip"); + } + + /// Teardown must not disturb aces nub did not author — including ones written by other + /// tools. Byte-identical, not merely present: a rebuild that re-encoded a kept ace would + /// silently rewrite a third party's rights. + #[test] + fn strip_preserves_foreign_aces() { + let td = tempfile::tempdir().unwrap(); + grant(td.path(), OTHER_SID, Access::Read).expect("seed foreign ace"); + grant(td.path(), SID, Access::ReadWrite).expect("grant"); + + let foreign = sid_bytes(OTHER_SID); + let before = explicit_for(&read_aces(td.path()), &foreign); + assert!(!before.is_empty(), "foreign ace was not seeded"); + + strip(td.path(), SID).expect("strip"); + assert_eq!( + explicit_for(&read_aces(td.path()), &foreign), + before, + "stripping our SID altered another trustee's aces" + ); + } +} diff --git a/crates/nub-sandbox/src/backend/windows_account/launch.rs b/crates/nub-sandbox/src/backend/windows_account/launch.rs new file mode 100644 index 000000000..db99d0980 --- /dev/null +++ b/crates/nub-sandbox/src/backend/windows_account/launch.rs @@ -0,0 +1,483 @@ +//! The unelevated per-run launch: ACL the policy's paths, then start the child AS the +//! sandbox account through the Secondary Logon service. +//! +//! WHY `CreateProcessWithLogonW` AND NOT `LogonUser` + `CreateProcessAsUserW`: the latter +//! makes the caller hold a FOREIGN primary token, which requires +//! `SE_ASSIGNPRIMARYTOKEN_NAME` + `SE_INCREASE_QUOTA_NAME` — i.e. administrator, on every +//! run. `CreateProcessWithLogonW` hands the credential to the seclogon service, which does +//! the token work out of process, so an ordinary unelevated token suffices. That single API +//! choice is what makes the "one elevated setup, then never again" promise hold. (SRT and +//! Codex independently converged on it.) +//! +//! CONSEQUENCES OF THAT API, all load-bearing: +//! - It has NO `bInheritHandles` parameter and no `STARTUPINFOEX` overload. seclogon +//! duplicates exactly the `STARTF_USESTDHANDLES` handles into the new logon and nothing +//! else — so stdio must ride those three fields, and there is no handle-list attribute to +//! scope inheritance with (nor any need for one: nothing else crosses). +//! - `lpDesktop` is left NULL deliberately — but NULL does NOT mean there is no +//! window-station work to do. seclogon's auto-grant covers `WinSta0` only, so a launch +//! from a NON-INTERACTIVE caller (an SSH session, a service, a CI agent), which runs on a +//! per-logon `Service-0x0-…$` station, needs an explicit station + desktop ace or the +//! child dies in loader init with `0xC0000142` (VM-diagnosed; see +//! [`WindowAceGuard`]). Setting `lpDesktop` was tried and does not substitute for it. The +//! cost of NULL is no desktop isolation, which is a hardening follow-up, not a +//! confinement hole. +//! - `AssignProcessToJobObject` on the resulting child commonly fails `ERROR_NOT_SUPPORTED`: +//! seclogon already placed it in its own job, and current Windows refuses that nesting +//! cross-session. The assignment is attempted and its failure reported, never silently +//! swallowed — whole-tree reap is genuinely weaker here than on the AppContainer path. + +use super::{AccountLaunch, SANDBOX_ACCOUNT, account, acl, state}; +use crate::backend::windows::launch::{build_command_line, build_env_block, to_wide}; +use std::io; +use std::os::windows::io::AsRawHandle; +use std::os::windows::process::ExitStatusExt; +use std::path::Path; +use std::process::ExitStatus; +use windows_sys::Win32::Foundation::{ + CloseHandle, HANDLE, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, SetHandleInformation, + WAIT_OBJECT_0, +}; +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, +}; +use windows_sys::Win32::System::StationsAndDesktops::{ + DESKTOP_CREATEMENU, DESKTOP_CREATEWINDOW, DESKTOP_ENUMERATE, DESKTOP_HOOKCONTROL, + DESKTOP_JOURNALPLAYBACK, DESKTOP_JOURNALRECORD, DESKTOP_READ_CONTROL, DESKTOP_READOBJECTS, + DESKTOP_SWITCHDESKTOP, DESKTOP_WRITEOBJECTS, GetProcessWindowStation, GetThreadDesktop, +}; +use windows_sys::Win32::System::Threading::{ + CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, CreateProcessWithLogonW, GetCurrentThreadId, + GetExitCodeProcess, INFINITE, LOGON_WITH_PROFILE, PROCESS_INFORMATION, ResumeThread, + STARTF_USESTDHANDLES, STARTUPINFOW, TerminateProcess, WaitForSingleObject, +}; + +const ERROR_NOT_SUPPORTED: i32 = 50; +const ERROR_LOGON_FAILURE: i32 = 1326; +const ERROR_SERVICE_DISABLED: i32 = 1058; + +/// `STATUS_DLL_INIT_FAILED`, which arrives as the CHILD'S EXIT CODE rather than a +/// `CreateProcessWithLogonW` failure — so nothing in [`map_spawn_error`] ever sees it and an +/// unmapped run surfaces the bare `-1073741502`. +const STATUS_DLL_INIT_FAILED: u32 = 0xC000_0142; + +/// `WINSTA_ALL_ACCESS` (0x37F) — the union of the nine `WINSTA_*` rights. Spelled here because +/// `windows-sys` exports it only from `Win32_UI_WindowsAndMessaging`, a feature this crate does +/// not otherwise need. `DESKTOP_READ_CONTROL` is the same `READ_CONTROL` bit (0x0002_0000), +/// which is LOAD-BEARING on the station: without it the child HANGS in loader init rather than +/// failing, so it is folded in here and never optional. +const WINSTA_GRANT: u32 = 0x0000_037F | DESKTOP_READ_CONTROL; + +/// The documented `DESKTOP_*` rights union (0x1FF), plus `READ_CONTROL` for the same reason. +const DESKTOP_GRANT: u32 = DESKTOP_READOBJECTS + | DESKTOP_CREATEWINDOW + | DESKTOP_CREATEMENU + | DESKTOP_HOOKCONTROL + | DESKTOP_JOURNALRECORD + | DESKTOP_JOURNALPLAYBACK + | DESKTOP_ENUMERATE + | DESKTOP_WRITEOBJECTS + | DESKTOP_SWITCHDESKTOP + | DESKTOP_READ_CONTROL; + +/// Strips every ace this run applied, on drop. Ordering matters: declared before the child is +/// spawned but dropped after the wait returns, so a granted path is never revoked out from +/// under a live child. Best-effort — a failed strip leaves an over-permissive ace for a +/// confined account, which the ledger sweep (`nub run --sandbox-admin clean`) collects later. +struct AceGuard { + paths: Vec, + sid: String, +} + +impl Drop for AceGuard { + fn drop(&mut self) { + for p in &self.paths { + match acl::strip(p, &self.sid) { + Ok(()) => {} + // A recorded path that no longer exists carries no ace to strip: it was either + // skipped as absent at apply time, or deleted by the child inside a grant. + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => { + tracing::debug!(path = %p.display(), error = %e, "sandbox: ace strip failed — left for the ledger sweep"); + } + } + } + } +} + +/// An ace target that does not exist is SKIPPED, not fatal: the flagship agent policy denies +/// `~/.ssh`, `~/.aws`, `/.env` and most are absent on any real machine, so failing would +/// kill the backend on its own headline shape. Denying an absent path denies nothing — the +/// created-later-inside-a-grant residual is in LIMITATIONS.md. Every other error stays fatal. +fn tolerate_absent(r: io::Result<()>, path: &Path, what: &str) -> io::Result<()> { + match r { + Err(e) if e.kind() == io::ErrorKind::NotFound => { + tracing::debug!(path = %path.display(), "sandbox: {what} target does not exist — skipped"); + Ok(()) + } + other => other, + } +} + +impl AccountLaunch { + pub(crate) fn run(self) -> io::Result { + let marker = state::read_marker()?.ok_or_else(not_provisioned)?; + + // The account can be deleted out from under a stale marker; catching that here turns + // an inscrutable `ERROR_LOGON_FAILURE` into an actionable message. + match account::lookup_sid()? { + Some(live) if live == marker.sid => {} + Some(_) => { + return Err(io::Error::other( + "the nub sandbox account exists but its SID no longer matches the recorded \ + setup — re-run `nub run --sandbox-admin setup` from an elevated prompt", + )); + } + None => return Err(not_provisioned()), + } + + // Ledger BEFORE apply: a crash between the two leaves a recorded path whose ace was + // never written, and stripping an absent ace is a no-op. The reverse order would + // leave an ace nothing knows about. + let mut guard = AceGuard { + paths: Vec::new(), + sid: marker.sid.clone(), + }; + for (path, access) in self + .read_grants + .iter() + .map(|p| (p, acl::Access::Read)) + .chain( + self.write_grants + .iter() + .map(|p| (p, acl::Access::ReadWrite)), + ) + { + state::record_acl_path(path)?; + guard.paths.push(path.clone()); + tolerate_absent(acl::grant(path, &marker.sid, access), path, "grant")?; + } + // Denies go on AFTER the grants so the deny ace is inserted into a DACL that already + // carries the grant it must outrank — the canonical-order insert has to see both. + for path in &self.denies { + let targets = match acl::deny_targets(path) { + Ok(t) => t, + // Same skip-if-absent rule as the grants above. + Err(e) if e.kind() == io::ErrorKind::NotFound => { + tracing::debug!(path = %path.display(), "sandbox: deny target does not exist — skipped"); + continue; + } + Err(e) => return Err(e), + }; + for t in targets { + state::record_acl_path(&t)?; + guard.paths.push(t); + } + acl::deny(path, &marker.sid)?; + } + + let password = account::load_credential()?; + let status = self.spawn_and_wait(&marker.sid, password.as_str()); + drop(guard); + status + } + + fn spawn_and_wait(&self, sid: &str, password: &str) -> io::Result { + // Declared before the spawn and dropped after the wait, like `AceGuard`: revoking the + // station access out from under a live child is the same hazard as revoking a path. + let _window = WindowAceGuard::grant(sid); + + // The COMPILE-TIME const, never a name read from the marker file: the SID checked in + // `run` resolves THIS name, so a name field on disk would have been an attacker-chosen + // logon target that still passed that check — the marker lives in a directory a + // standard user can pre-create and then own. + let user_w = to_wide(SANDBOX_ACCOUNT); + // "." targets the LOCAL SAM regardless of whether the machine is domain-joined. + let domain_w = to_wide("."); + let mut password_w: Vec = password.encode_utf16().chain(std::iter::once(0)).collect(); + let mut cmdline = build_command_line(&self.program, &self.args); + let app_w = to_wide(&self.program.to_string_lossy()); + let cwd_w = self.cwd.as_ref().map(|c| to_wide(&c.to_string_lossy())); + let env_block = self.env.as_ref().map(build_env_block); + + let mut si: STARTUPINFOW = unsafe { std::mem::zeroed() }; + si.cb = std::mem::size_of::() as u32; + // seclogon duplicates ONLY these three handles, and only when the flag is set. + let std_handles = inheritable_std_handles(); + if let Some((i, o, e)) = std_handles { + si.dwFlags |= STARTF_USESTDHANDLES; + si.hStdInput = i; + si.hStdOutput = o; + si.hStdError = e; + } + + let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + let flags = CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED; + let env_ptr: *const std::ffi::c_void = match &env_block { + Some(b) => b.as_ptr().cast(), + // NULL + LOGON_WITH_PROFILE makes seclogon build the SANDBOX ACCOUNT's own + // profile environment — isolated USERPROFILE/TEMP/LOCALAPPDATA, machine PATH. + None => std::ptr::null(), + }; + + // SAFETY: every buffer referenced (user/domain/password/app/cmdline/cwd/env/si) + // outlives this call; `lpCommandLine` is a writable UTF-16 buffer as required. + let ok = unsafe { + CreateProcessWithLogonW( + user_w.as_ptr(), + domain_w.as_ptr(), + password_w.as_ptr(), + LOGON_WITH_PROFILE, + app_w.as_ptr(), + cmdline.as_mut_ptr(), + flags, + env_ptr, + cwd_w.as_ref().map_or(std::ptr::null(), |w| w.as_ptr()), + &si, + &mut pi, + ) + }; + account::scrub_u16(&mut password_w); + if ok == 0 { + return Err(map_spawn_error(io::Error::last_os_error())); + } + + // Best-effort containment. seclogon has already placed the child in its own job and + // current Windows refuses cross-session nesting, so this commonly returns + // ERROR_NOT_SUPPORTED — reported, never presented as success. + let job = match create_kill_on_close_job() { + Ok(j) => Some(j), + Err(e) => { + tracing::debug!( + error = %e, + "sandbox: could not create the reaping Job Object — whole-tree reap is \ + best-effort" + ); + None + } + }; + if let Some(j) = job { + // SAFETY: both handles are live; the child is still suspended. + if unsafe { AssignProcessToJobObject(j, pi.hProcess) } == 0 { + let e = io::Error::last_os_error(); + if e.raw_os_error() == Some(ERROR_NOT_SUPPORTED) { + tracing::debug!( + "sandbox: the sandbox child could not be placed in a nub Job Object \ + (seclogon owns it) — whole-tree reap is best-effort" + ); + } else { + // A non-nesting failure is a real fault: terminate the still-suspended + // child rather than run it uncontained. + unsafe { + TerminateProcess(pi.hProcess, 1); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + CloseHandle(j); + } + return Err(io::Error::other(format!( + "sandbox: could not contain the child in a Job Object: {e}" + ))); + } + } + } + + // A failed resume must NOT fall through to the wait below: the child would stay + // suspended forever and `WaitForSingleObject(…, INFINITE)` would never return. + // SAFETY: `pi.hThread` is the suspended primary thread of the process just created. + if unsafe { ResumeThread(pi.hThread) } == u32::MAX { + let e = io::Error::last_os_error(); + // SAFETY: both handles are live and each is closed exactly once. + unsafe { + TerminateProcess(pi.hProcess, 1); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } + close_job(job); + return Err(io::Error::other(format!( + "sandbox: the sandboxed child could not be resumed: {e}" + ))); + } + + // SAFETY: `pi.hProcess` is live until closed below. + if unsafe { WaitForSingleObject(pi.hProcess, INFINITE) } != WAIT_OBJECT_0 { + let e = io::Error::last_os_error(); + // SAFETY: as above. + unsafe { + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } + close_job(job); + return Err(e); + } + + let mut code: u32 = u32::MAX; + // SAFETY: `pi.hProcess` is a live, signalled process handle. + let queried = unsafe { GetExitCodeProcess(pi.hProcess, &mut code) }; + let query_err = (queried == 0).then(io::Error::last_os_error); + // SAFETY: both handles came from CreateProcessWithLogonW and are closed exactly once. + unsafe { + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } + // Closed LAST so KILL_ON_JOB_CLOSE reaps anything still in the tree. + close_job(job); + + if let Some(e) = query_err { + return Err(io::Error::other(format!( + "sandbox: the sandboxed child's exit code could not be read: {e}" + ))); + } + if code == STATUS_DLL_INIT_FAILED { + return Err(io::Error::other( + "sandbox: the sandboxed child died in loader init with STATUS_DLL_INIT_FAILED \ + (0xC0000142) — it could not attach to this session's window station. nub grants \ + the sandbox account access to the caller's station and desktop before \ + launching; a station nub cannot re-ACL (an unusual service or remoting host) \ + can still refuse it. Running from an ordinary interactive session avoids it.", + )); + } + Ok(ExitStatus::from_raw(code)) + } +} + +fn close_job(job: Option) { + if let Some(j) = job { + // SAFETY: the handle came from CreateJobObjectW and is closed exactly once. + unsafe { CloseHandle(j) }; + } +} + +/// The parent's three std handles, marked inheritable so seclogon can duplicate them. `None` +/// when any is absent (a detached parent) — the child then gets no stdio rather than a +/// half-wired set that would make it block on a dead handle. +fn inheritable_std_handles() -> Option<(HANDLE, HANDLE, HANDLE)> { + let i = std::io::stdin().as_raw_handle() as HANDLE; + let o = std::io::stdout().as_raw_handle() as HANDLE; + let e = std::io::stderr().as_raw_handle() as HANDLE; + for h in [i, o, e] { + if h.is_null() || h == INVALID_HANDLE_VALUE { + return None; + } + // SAFETY: each handle is owned by this process for its whole lifetime. + unsafe { SetHandleInformation(h, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) }; + } + Some((i, o, e)) +} + +/// The window-station and desktop aces the child needs, restored on drop. +/// +/// WHY THIS EXISTS (VM-diagnosed 2026-07-24, reproduced with a bare P/Invoke on a throwaway +/// account and no nub code): a NON-INTERACTIVE caller — SSH, a service, a CI agent — runs on a +/// per-logon `Service-0x0-…$` window station, NOT `WinSta0`, and the Secondary Logon service's +/// station auto-grant covers only `WinSta0`. Without these two aces the child dies in loader +/// init with `0xC0000142 STATUS_DLL_INIT_FAILED`. Setting `lpDesktop` explicitly was tried and +/// does NOT substitute, which is why the launch still passes NULL. +/// +/// Neither handle is closed: `GetProcessWindowStation` and `GetThreadDesktop` both return +/// handles the caller does not own. +struct WindowAceGuard { + /// `(handle, the DACL that was there before this run)`. `None` restores a NULL DACL. + restore: Vec<(HANDLE, Option>)>, +} + +impl WindowAceGuard { + fn grant(sid: &str) -> Self { + // SAFETY: neither call takes a parameter that can be invalid, and both return a handle + // owned by the system for this process/thread's lifetime. + let (station, desktop) = unsafe { + ( + GetProcessWindowStation().cast::(), + GetThreadDesktop(GetCurrentThreadId()).cast::(), + ) + }; + let mut guard = WindowAceGuard { + restore: Vec::with_capacity(2), + }; + for (handle, mask) in [(station, WINSTA_GRANT), (desktop, DESKTOP_GRANT)] { + if handle.is_null() { + continue; + } + // FAIL FORWARD, never abort. On an interactive `WinSta0` these aces are redundant + // — seclogon's auto-grant already covers it — so a station whose DACL nub cannot + // rewrite (a locked-down remoting host, some CI agents) must still launch rather + // than lose a run that worked before this ace existed. The one case where the ace + // IS load-bearing surfaces instead as the mapped `STATUS_DLL_INIT_FAILED` exit. + match acl::grant_window_object(handle, sid, mask) { + Ok(saved) => guard.restore.push((handle, saved)), + Err(e) => tracing::debug!( + error = %e, + "sandbox: could not grant the sandbox account window-object access — a \ + child on a non-interactive station may fail loader init" + ), + } + } + guard + } +} + +impl Drop for WindowAceGuard { + fn drop(&mut self) { + for (handle, dacl) in &self.restore { + if let Err(e) = acl::restore_window_object(*handle, dacl.as_deref()) { + tracing::debug!( + error = %e, + "sandbox: could not restore the window-station DACL — the sandbox account \ + keeps station access until this session ends" + ); + } + } + } +} + +fn create_kill_on_close_job() -> io::Result { + // SAFETY: unnamed job with default security. + let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if job.is_null() { + return Err(io::Error::last_os_error()); + } + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: `info` is a correctly-sized JOBOBJECT_EXTENDED_LIMIT_INFORMATION. + let ok = unsafe { + SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + std::ptr::from_mut(&mut info).cast(), + std::mem::size_of::() as u32, + ) + }; + if ok == 0 { + let e = io::Error::last_os_error(); + unsafe { CloseHandle(job) }; + return Err(e); + } + Ok(job) +} + +fn not_provisioned() -> io::Error { + io::Error::new( + io::ErrorKind::NotFound, + "this policy needs nub's dedicated Windows sandbox account, which has not been set up \ + on this machine. Run `nub run --sandbox-admin setup` once from an elevated (Run as \ + administrator) prompt.", + ) +} + +/// Turn the two spawn failures a user can actually act on into instructions. +fn map_spawn_error(e: io::Error) -> io::Error { + match e.raw_os_error() { + Some(ERROR_LOGON_FAILURE) => io::Error::other(format!( + "sandbox: the stored credential for `{SANDBOX_ACCOUNT}` was rejected — the \ + account's password was changed or the account was disabled. Re-run `nub run \ + --sandbox-admin setup` from an elevated prompt to reprovision it." + )), + Some(ERROR_SERVICE_DISABLED) => io::Error::other( + "sandbox: the Windows Secondary Logon service is disabled, so nub cannot start the \ + sandboxed child under its dedicated account. Enable the `seclogon` service (it \ + may be disabled by group policy).", + ), + _ => e, + } +} diff --git a/crates/nub-sandbox/src/backend/windows_account/mod.rs b/crates/nub-sandbox/src/backend/windows_account/mod.rs new file mode 100644 index 000000000..cd02c85aa --- /dev/null +++ b/crates/nub-sandbox/src/backend/windows_account/mod.rs @@ -0,0 +1,811 @@ +//! Windows **agent-sandbox** backend: a dedicated local account + WFP. +//! +//! WHY A SECOND WINDOWS BACKEND (the 2026-07-24 pivot). The AppContainer backend +//! ([`super::windows`]) is a pure ALLOWLIST: reachable only where an object's ACL names the +//! per-run AppContainer SID. That maps build-jail exactly — and cannot express agent-sandbox +//! at all. Two things defeat it: +//! 1. **Generous-read-minus-secrets is inexpressible.** An allowlist cannot say "read +//! everything except these", so the policy degrades to the explicit allow-set. +//! 2. **Deny-inside-allow does not hold.** A secret under a dir carrying an inherited +//! `ALL APPLICATION PACKAGES` grant is readable regardless of the allow-set — the AAP +//! grant satisfies the LowBox check before default-deny is reached. +//! +//! Both dissolve when the child runs as a **separate local principal**: the invoking user's +//! own profile (`~/.ssh`, `~/.aws`, a home-dir `.env`) is denied by DEFAULT with no ACE +//! authored at all, and no AAP grant ever covers a user SID, so an explicit deny ACE on a +//! secret *inside* a granted tree wins on canonical DACL order. +//! +//! And per-host egress was never admin-free on Windows regardless: there is no unprivileged +//! per-host mechanism (AppContainer's loopback exemption is all-or-nothing), so the full +//! grammar always implied WFP, which always implied elevation. Given elevation is required +//! anyway, the dedicated account buys the *whole* grammar rather than a subset — which is +//! why the deny-strip (`SE_DACL_PROTECTED` + a DACL restore journal) was dropped rather than +//! finished: its only advantage had been avoiding an elevation that turned out to be +//! mandatory. See `.fray/sandbox-decisions-current.md` §2. +//! +//! **THE PRIVILEGE SPLIT IS THE WHOLE PRODUCT DECISION.** One elevated +//! `nub run --sandbox-admin setup` per machine creates the account and installs four persistent +//! WFP filters over a pre-authorized loopback port window. Every run after that is +//! **fully unelevated**: read the credential, ACL the policy's paths, and +//! `CreateProcessWithLogonW` the child through the Secondary Logon service — which needs no +//! privilege because the unelevated broker never holds a foreign primary token. +//! build-jail keeps using [`super::windows`] and stays admin-free end to end. +//! +//! SPIKE BOUNDS (deliberate, not hidden — see LIMITATIONS.md): the child is launched in ONE +//! hop straight to the target. SRT and Codex add a second hop that re-launches through a +//! runner holding a *restricted* token; nub's confinement comes from the account's ACL reach +//! plus SID-keyed WFP, neither of which needs that token, so hop 2 is a hardening follow-up. +//! Consequences: `lpDesktop` stays NULL, at the cost of desktop isolation (and NOT because it +//! avoids window-station work — seclogon's auto-grant covers only `WinSta0`, so the launch +//! aces the caller's station and desktop itself; see [`launch`]), and Job-Object whole-tree +//! kill is best-effort because seclogon's own job refuses cross-session nesting. + +#![cfg(any(target_os = "windows", test))] + +use crate::policy::{Effect, FsAccess, FsPolicy}; +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::PathBuf; + +#[cfg(target_os = "windows")] +pub(crate) mod account; +#[cfg(target_os = "windows")] +pub(crate) mod acl; +#[cfg(target_os = "windows")] +mod launch; +pub(crate) mod state; +#[cfg(target_os = "windows")] +pub(crate) mod wfp; + +/// The local account nub provisions. 20 chars is the SAM limit for a local account; this is +/// 11. Stable — the WFP filters and every granted ACE key on the SID it resolves to. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) const SANDBOX_ACCOUNT: &str = "nub-sandbox"; + +/// A local group holding the sandbox account. NOTHING KEYS ON IT TODAY: the credential store +/// is locked by a PROTECTED DACL naming SYSTEM, `BUILTIN\Administrators` and the provisioning +/// user, which needs no deny trustee at all. It is created and deleted anyway because it is the +/// stable trustee a future per-session-account design would add members to rather than +/// rewriting DACLs (SRT's rationale), and provisioning it is free. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) const SANDBOX_GROUP: &str = "nub-sandbox-users"; + +/// What the account model could not express for a policy, so the caller is told rather than +/// silently over- or under-confined. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct AccountFsDegrade { + /// A read-all base. The account reads what any standard local user reads (system dirs, + /// Program Files) PLUS the explicit grants — but never the invoking user's profile. That + /// is narrower than "read everything", so it is over-confinement and is reported. + pub(crate) generous_read_narrowed: bool, + /// An embedded-glob allow can't be one inheritable ACE; skipped rather than widened to + /// its literal prefix, which could sweep in a sibling secret. + pub(crate) glob_read_unenforced: bool, + /// An embedded-glob DENY can't be one ACE either — and a missed deny is a HOLE, not + /// over-confinement, so it is reported distinctly. + pub(crate) glob_deny_unenforced: bool, +} + +/// The net posture the account backend can achieve. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AccountNet { + /// Policy allows specific hosts: the child reaches nub's loopback proxy (permitted by the + /// installed port window) and nothing else. + ProxyOnly, + /// Pure deny-all: the persistent block filters alone, no proxy. + DenyAll, + /// Policy leaves net unconfined, but the account is PERMANENTLY fenced by the persistent + /// filters, so egress is denied anyway. Over-confinement — reported, never silent. + UnconfinedButFenced, +} + +/// A resolved account-backend launch plan. Plain data so the derivation is unit-tested on the +/// dev host; the FFI in [`launch`] is `#[cfg(windows)]`. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) struct AccountLaunch { + pub(crate) program: OsString, + pub(crate) args: Vec, + pub(crate) cwd: Option, + /// Subtrees granted read+execute to the sandbox SID, inheritable. + pub(crate) read_grants: Vec, + /// Subtrees granted modify (read+write+delete, NEVER `FILE_DELETE_CHILD`), inheritable. + pub(crate) write_grants: Vec, + /// Paths carrying an explicit DENY ACE for the sandbox SID. Only meaningful INSIDE a + /// grant — outside one the account already has no reach — but applying them + /// unconditionally is free and survives a later widening of the grant set. + pub(crate) denies: Vec, + /// `Some` ⇒ the child env IS this map. `None` ⇒ seclogon builds the account's own + /// profile environment. + pub(crate) env: Option>, +} + +// The net posture deliberately does NOT ride the launch plan: it is fully decided in `apply` +// (the degradation it reports and the port-window gate it enforces) and then carried by the +// PERSISTENT WFP filters the elevated setup installed. There is no per-run network work to +// do, which is exactly the property that keeps every run unelevated. + +/// Whether this machine has completed the one-time elevated setup. +/// +/// Cheap and read-only: the marker's mere presence is the signal. [`apply`] re-reads it and +/// re-validates the recorded SID against the live account, so a stale or tampered marker still +/// fails closed there — this is a ROUTING question ("is the account path available at all?"), +/// deliberately not the trust decision. +#[cfg(target_os = "windows")] +pub(crate) fn is_provisioned() -> bool { + matches!(state::read_marker(), Ok(Some(_))) +} + +/// Whether a policy needs the ACCOUNT backend rather than the AppContainer allowlist. +/// +/// The account backend costs a one-time elevated setup, so it is chosen only where the +/// allowlist genuinely cannot carry the policy — which is exactly the agent-sandbox shape: +/// a generous-read base, a deny that has to be carved inside a grant, or per-host egress. +/// A pure default-deny allowlist with coarse/absent net is build-jail and stays on the +/// admin-free AppContainer path. +pub(crate) fn needs_account_backend(policy: &crate::policy::SandboxPolicy) -> bool { + let fs = &policy.fs; + let generous_read = + fs.rules.default_effect == Effect::Allow + || fs.rules.entries.iter().any(|r| { + r.effect == Effect::Allow && super::windows::is_whole_fs(r.matcher.as_str()) + }); + // A deny only needs carving when it can land inside something the policy also grants; + // `deny_shadows_grant` is exactly that test, and it is the AppContainer's known hole. + let (read_grants, _, _) = super::windows::derive_grants(fs); + let deny_needs_carve = super::windows::deny_shadows_grant(&fs.rules.entries, &read_grants); + let per_host_net = + policy.net.enforce && policy.net.rules.iter().any(|r| r.effect == Effect::Allow); + generous_read || deny_needs_carve || per_host_net +} + +/// Derive the account's grant/deny sets from the fs IR. +/// +/// Unlike the AppContainer derivation, DENIES ARE REAL HERE: an explicit deny ACE for the +/// sandbox SID on a path inside a granted subtree outranks the grant's *inherited* allow, +/// because Windows orders every explicit ACE ahead of every inherited one. That ordering is +/// the mechanism the whole agent-sandbox fs axis rests on. +pub(crate) fn derive_plan( + fs: &FsPolicy, +) -> (Vec, Vec, Vec, AccountFsDegrade) { + let mut read = Vec::new(); + let mut write = Vec::new(); + let mut deny = Vec::new(); + let mut degrade = AccountFsDegrade { + generous_read_narrowed: fs.rules.default_effect == Effect::Allow, + ..Default::default() + }; + + for rule in &fs.rules.entries { + let g = rule.matcher.as_str(); + match rule.effect { + Effect::Allow => match super::windows::literal_subtree(g) { + Some(dir) => { + if !read.contains(&dir) { + read.push(dir.clone()); + } + if rule.access == FsAccess::ReadWrite + && !super::windows::is_dangerous_write_root(&dir) + && !write.contains(&dir) + { + write.push(dir); + } + } + None if super::windows::is_whole_fs(g) => degrade.generous_read_narrowed = true, + None if super::windows::has_glob_meta(g) => degrade.glob_read_unenforced = true, + None => {} + }, + Effect::Deny => match super::windows::literal_subtree(g) { + Some(p) => { + if !deny.contains(&p) { + deny.push(p); + } + } + // A whole-fs deny is the default-deny base, carried by the account having no + // reach rather than by an ACE — not a lost deny. + None if super::windows::is_whole_fs(g) => {} + // A glob deny (`**/.env`, `C:/proj/*.pem`) is NOT expressible as one ACE. + // This is a HOLE, not over-confinement, so it is reported separately. + None => degrade.glob_deny_unenforced = true, + }, + } + } + (read, write, deny, degrade) +} + +/// Decide the net posture. Mirrors `backend::start_proxy_if_needed`, which is what actually +/// decides whether a proxy is running. +pub(crate) fn plan_net(net: &crate::policy::NetPolicy) -> AccountNet { + if !net.enforce { + return AccountNet::UnconfinedButFenced; + } + if net.rules.iter().any(|r| r.effect == Effect::Allow) { + AccountNet::ProxyOnly + } else { + AccountNet::DenyAll + } +} + +// ── one-time setup / teardown / status (the elevated half) ────────────────────── + +/// ELEVATED, once per machine. Lock nub's state directory, create the sandbox account, install +/// the WFP egress fence over `port_range`, and record the marker every later unelevated run +/// reads. Idempotent — safe to re-run to repair a partial install. +/// +/// ORDER IS DELIBERATE AT BOTH ENDS. The state directory is locked FIRST, before `provision` +/// writes a credential into it: the DACL is the DPAPI blob's only boundary, so writing the +/// credential first would leave it world-readable for a live account if the lock then failed. +/// The marker is written LAST, so a failure anywhere leaves the machine looking un-provisioned +/// and every run fails closed with "run the setup", rather than half-provisioned and failing in +/// some less legible way later. +#[cfg(target_os = "windows")] +pub fn setup(port_range: Option<(u16, u16)>) -> std::io::Result { + if !account::is_elevated() { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "nub's Windows sandbox setup creates a local account and installs network filters, \ + both of which require administrator. Re-run this from an elevated (Run as \ + administrator) prompt.", + )); + } + let range = port_range.unwrap_or(wfp::DEFAULT_PROXY_PORT_RANGE); + // `store_credential` uses `create_dir_all`, a no-op on the directory created here, and the + // elevated writer keeps access through the Administrators ace this stamps. + let cred_dir = account::credential_dir()?; + std::fs::create_dir_all(&cred_dir)?; + acl::lock_to_admins(&cred_dir)?; + let sid = account::provision()?; + wfp::install(&sid, range)?; + state::write_marker(&state::Marker { + version: state::MARKER_VERSION, + sid: sid.clone(), + port_low: range.0, + port_high: range.1, + })?; + Ok(sid) +} + +/// ELEVATED. Undo [`setup`] completely: sweep every ace the ledger records, remove the WFP +/// objects, delete the account/group/profile, and drop the marker. Every step is idempotent +/// and a later failure never skips the cleanup already done. +#[cfg(target_os = "windows")] +pub fn teardown() -> std::io::Result<()> { + if !account::is_elevated() { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "removing the nub sandbox account and its network filters requires administrator. \ + Re-run this from an elevated (Run as administrator) prompt.", + )); + } + // Sweep BEFORE the account is deleted: once the SID stops resolving, the leftover aces + // render as raw SID strings and are far harder to attribute. + let _ = clean(); + let wfp_result = wfp::uninstall(); + let account_result = account::deprovision(); + let _ = state::remove_marker(); + let _ = state::clear_ledger(); + // The state directory carries the protected DACL and Administrators owner `setup` wrote, + // so leaving it behind is residue an unelevated user cannot clear themselves. + let _ = state::state_dir().map(std::fs::remove_dir_all); + wfp_result.and(account_result) +} + +/// UNELEVATED. Strip every ace the ledger recorded, then clear it. This is the crash-residue +/// path: a run killed between grant and strip leaves aces behind, and this collects them. +/// Returns how many paths were swept. A path that no longer exists is pruned rather than +/// retried forever. +#[cfg(target_os = "windows")] +pub fn clean() -> std::io::Result { + let Some(marker) = state::read_marker()? else { + return Ok(0); + }; + // The SAME live-SID check the launch makes, for the same reason. This sweep strips explicit + // aces for whatever trustee the marker names, from whatever paths the ledger lists — BOTH + // read off disk. Trusting them unverified would make `clean` an ace-removal primitive + // aimed by anyone who can write nub's state directory. + if account::lookup_sid()?.as_deref() != Some(marker.sid.as_str()) { + return Err(std::io::Error::other( + "the recorded sandbox SID does not match the live account, so nub will not sweep \ + aces for it — re-run `nub run --sandbox-admin setup` from an elevated prompt", + )); + } + let paths = state::ledger_paths()?; + let mut swept = 0; + for p in &paths { + match acl::strip(p, &marker.sid) { + Ok(()) => swept += 1, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => swept += 1, + Err(e) => { + tracing::debug!(path = %p.display(), error = %e, "sandbox: ace sweep failed"); + } + } + } + if swept == paths.len() { + state::clear_ledger()?; + } + Ok(swept) +} + +/// A human-readable provisioning report. +/// +/// The filter count is only reachable when elevated: WFP gates even ENUMERATION on +/// administrator, so an unelevated caller genuinely cannot see it and the report says so +/// rather than implying the fence is absent. +#[cfg(target_os = "windows")] +pub fn status() -> std::io::Result { + let marker = state::read_marker()?; + let live_sid = account::lookup_sid()?; + let mut out = String::new(); + match (&marker, &live_sid) { + (None, None) => out.push_str("sandbox account: not set up\n"), + (None, Some(sid)) => out.push_str(&format!( + "sandbox account: `{SANDBOX_ACCOUNT}` exists ({sid}) but nub has no setup record — \ + re-run `nub run --sandbox-admin setup` from an elevated prompt\n" + )), + (Some(m), None) => out.push_str(&format!( + "sandbox account: recorded as {} but no longer exists — re-run `nub run \ + --sandbox-admin setup` from an elevated prompt\n", + m.sid + )), + (Some(m), Some(sid)) if m.sid != *sid => out.push_str(&format!( + "sandbox account: SID changed (recorded {}, live {sid}) — re-run `nub run \ + --sandbox-admin setup` from an elevated prompt\n", + m.sid + )), + (Some(m), Some(sid)) => out.push_str(&format!( + "sandbox account: `{SANDBOX_ACCOUNT}` ({sid})\nproxy port window: {}-{}\n", + m.port_low, m.port_high + )), + } + out.push_str( + &match (account::is_elevated(), wfp::installed_filter_count()) { + (false, _) => { + "wfp filters: cannot read (enumeration requires administrator)\n".to_string() + } + (true, Ok(n)) => format!("wfp filters: {n} installed\n"), + (true, Err(e)) => format!("wfp filters: could not read ({e})\n"), + }, + ); + out.push_str(&format!( + "acl ledger: {} path(s) recorded\n", + state::ledger_paths().map(|p| p.len()).unwrap_or(0) + )); + Ok(out) +} + +// ── the apply() entry (Windows-only: constructs Prepared.launch) ──────────────── + +/// Build the account-backend launch plan, or fail CLOSED with an actionable message. +/// +/// Two fail-closed gates, both deliberate: the machine must be provisioned (the account and +/// the WFP filters exist), and — when the policy allows specific hosts — the egress proxy must +/// have bound INSIDE the pre-authorized loopback window. A proxy outside the window is not a +/// degradation to warn about, it is a child that would reach nothing while the policy claimed +/// per-host access, so it is an error. +#[cfg(target_os = "windows")] +pub(crate) fn apply( + policy: &crate::policy::SandboxPolicy, + spec: crate::backend::CommandSpec, + proxy_port: Option, + proxy_token: Option<&str>, + ca_bundle: Option<&std::path::Path>, +) -> Result { + use crate::backend::{Degradation, Prepared, windows::WindowsLaunch}; + + let marker = match state::read_marker() { + Ok(Some(m)) => m, + Ok(None) => return Err(not_set_up("this machine has no nub sandbox account")), + // A marker that exists but cannot be READ is a different failure from one that was + // never written: re-running setup does not fix a permission problem, so it must not + // borrow that instruction. + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + return Err(crate::backend::Degradation { + lost: vec!["fs".to_string(), "net".to_string()], + reason: Some(format!( + "nub's sandbox setup record exists but is not readable by this user ({e}). \ + The state directory is readable only by administrators and the user who \ + ran the setup — run the sandboxed command as that user, or re-run \ + `nub run --sandbox-admin setup` as the user who will run it." + )), + }); + } + Err(e) => return Err(not_set_up(&e.to_string())), + }; + + let net = plan_net(&policy.net); + if net == AccountNet::ProxyOnly { + let Some(port) = proxy_port else { + return Err(Degradation { + lost: vec!["net-per-host".to_string()], + reason: Some( + "the egress proxy required for per-host / TLS-inspect enforcement could not \ + start" + .to_string(), + ), + }); + }; + if port < marker.port_low || port > marker.port_high { + return Err(Degradation { + lost: vec!["net-per-host".to_string()], + reason: Some(format!( + "nub's egress proxy bound port {port}, outside the {}-{} loopback window the \ + installed WFP filters permit — the sandboxed child could not reach it. Free a \ + port in that range, or re-run `nub run --sandbox-admin setup` from an elevated \ + prompt to authorize a different one.", + marker.port_low, marker.port_high + )), + }); + } + } + + let (mut read_grants, write_grants, denies, fs_degrade) = derive_plan(&policy.fs); + + // The account is a DIFFERENT principal, so nothing under the invoking user's profile is + // reachable by default — including the program image itself when it is a per-user install + // (nvm/fnm Node, Scoop, `%LOCALAPPDATA%\Programs`). Granting the program FILE (not its + // parent dir, which would sweep in a neighbouring secret) is the minimum that lets the + // child exec at all. A program that loads SIBLING DLLs from its own directory still needs + // the front-end to put that toolchain dir in the read allow-set — the same launcher + // contract the macOS backend documents. + if let Some(prog) = crate::backend::windows::resolve_program(&spec.program, spec.cwd.as_deref()) + && !read_grants.contains(&prog) + { + read_grants.push(prog); + } + // CreateProcess resolves the child's working directory under the CHILD's token, so an + // ungranted cwd fails the spawn outright rather than merely hiding files. + if let Some(cwd) = &spec.cwd + && !read_grants.contains(cwd) + { + read_grants.push(cwd.clone()); + } + if let Some(bundle) = ca_bundle { + let b = bundle.to_path_buf(); + if !read_grants.contains(&b) { + read_grants.push(b); + } + } + + let mut deg = Degradation::full(); + let mut reason: Option = None; + if fs_degrade.generous_read_narrowed { + deg.lost.push("fs-read".to_string()); + reason.get_or_insert_with(|| { + "the sandbox runs as a separate local account, so a read-everything policy reaches \ + only what any standard local user reads plus the explicit grants — never the \ + invoking user's profile (over-confined, not widened)" + .to_string() + }); + } + if fs_degrade.glob_read_unenforced { + deg.lost.push("fs-read-glob".to_string()); + reason.get_or_insert_with(|| { + "an embedded-glob read allow can't be a single inheritable ACE — that path is not \ + read-granted (over-confined)" + .to_string() + }); + } + // A missed DENY is a hole, not over-confinement — reported separately so it is never read + // as the benign case above. + if fs_degrade.glob_deny_unenforced { + deg.lost.push("fs-deny-glob".to_string()); + reason.get_or_insert_with(|| { + "an embedded-glob deny can't be a single ACE — that path is NOT denied to the \ + sandbox account" + .to_string() + }); + } + if net == AccountNet::UnconfinedButFenced { + deg.lost.push("net".to_string()); + reason.get_or_insert_with(|| { + "the dedicated sandbox account is permanently egress-fenced by the WFP filters \ + installed at setup, so an unconfined net policy is still denied (over-confined)" + .to_string() + }); + } + deg.reason = reason; + + let launch = AccountLaunch { + program: spec.program, + args: spec.args, + cwd: spec.cwd, + read_grants, + write_grants, + denies, + env: build_child_env(&policy.env, proxy_port, proxy_token, ca_bundle), + }; + + Ok(Prepared { + command: std::process::Command::new(&launch.program), + degradation: deg, + proxy: None, + launch: Some(WindowsLaunch::Account(launch)), + }) +} + +#[cfg(target_os = "windows")] +fn not_set_up(detail: &str) -> crate::backend::Degradation { + crate::backend::Degradation { + lost: vec!["fs".to_string(), "net".to_string()], + reason: Some(format!( + "{detail}. This policy needs nub's dedicated Windows sandbox account (it uses a \ + generous-read base, a deny inside a granted directory, or per-host network rules — \ + none of which Windows can express without one). Run `nub run --sandbox-admin setup` \ + once from an elevated (Run as administrator) prompt." + )), + } +} + +/// The child's environment, or `None` to let seclogon build the sandbox account's own profile +/// environment (isolated `USERPROFILE`/`TEMP`/`LOCALAPPDATA`, machine `PATH`). +/// +/// The Windows launch block is all-or-nothing — unlike a `Command`'s inherit-plus-override — +/// so "inherit and add the proxy vars" has to be MATERIALIZED from the parent's environment +/// here. That is only reached when the policy did NOT ask for env confinement, in which case +/// inheriting is exactly the contract the mac/linux backends give. +#[cfg(target_os = "windows")] +fn build_child_env( + env: &crate::policy::EnvPolicy, + proxy_port: Option, + proxy_token: Option<&str>, + ca_bundle: Option<&std::path::Path>, +) -> Option> { + let injecting = proxy_port.is_some() || ca_bundle.is_some(); + if !env.enforce && !injecting { + return None; + } + let mut m = if env.enforce { + env.constructed.clone() + } else { + std::env::vars_os() + .map(|(k, v)| { + ( + k.to_string_lossy().into_owned(), + v.to_string_lossy().into_owned(), + ) + }) + .collect() + }; + if let Some(port) = proxy_port { + let url = match proxy_token { + Some(t) => format!("http://{t}@127.0.0.1:{port}"), + None => format!("http://127.0.0.1:{port}"), + }; + for k in [ + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", + "ALL_PROXY", + ] { + m.insert(k.to_string(), url.clone()); + } + m.insert("NODE_USE_ENV_PROXY".to_string(), "1".to_string()); + } + if let Some(bundle) = ca_bundle { + let p = bundle.to_string_lossy().into_owned(); + for k in [ + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + "PIP_CERT", + "NPM_CONFIG_CAFILE", + "npm_config_cafile", + "CARGO_HTTP_CAINFO", + "AWS_CA_BUNDLE", + "DENO_CERT", + ] { + m.insert(k.to_string(), p.clone()); + } + } + Some(m) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy::{ + CanonGlob, EnvPolicy, FsRule, FsRuleSet, NetPolicy, NetRule, NetTarget, PidPolicy, + SandboxPolicy, TmpMode, + }; + + fn fs_policy(default_effect: Effect, entries: Vec) -> FsPolicy { + FsPolicy { + rules: FsRuleSet { + entries, + default_effect, + }, + tmp: TmpMode::Shared, + } + } + + fn rule(m: &str, effect: Effect, access: FsAccess) -> FsRule { + FsRule { + matcher: CanonGlob(m.to_string()), + effect, + access, + } + } + + fn policy(fs: FsPolicy, net: NetPolicy) -> SandboxPolicy { + SandboxPolicy { + fs, + net, + env: EnvPolicy::default(), + pid: PidPolicy::default(), + } + } + + fn net_off() -> NetPolicy { + NetPolicy { + enforce: false, + ..Default::default() + } + } + + /// build-jail's exact shape — default-deny allowlist, no net — must stay on the + /// admin-free AppContainer path. A regression here would make `nub install` demand + /// elevation, which is the one thing the pivot promised it would not do. + #[test] + fn build_jail_shape_does_not_need_the_account_backend() { + let p = policy( + fs_policy( + Effect::Deny, + vec![rule("C:/jail", Effect::Allow, FsAccess::ReadWrite)], + ), + net_off(), + ); + assert!(!needs_account_backend(&p)); + } + + /// A coarse net DENY-ALL still needs no account — nothing is reachable and no proxy runs, + /// which the AppContainer expresses by withholding `internetClient`. + #[test] + fn coarse_net_deny_all_does_not_need_the_account_backend() { + let p = policy( + fs_policy( + Effect::Deny, + vec![rule("C:/jail", Effect::Allow, FsAccess::ReadWrite)], + ), + NetPolicy { + enforce: true, + default_effect: Effect::Deny, + ..Default::default() + }, + ); + assert!(!needs_account_backend(&p)); + } + + /// Per-host egress is the axis that forces WFP, which forces the account. + #[test] + fn per_host_net_needs_the_account_backend() { + let p = policy( + fs_policy( + Effect::Deny, + vec![rule("C:/jail", Effect::Allow, FsAccess::ReadWrite)], + ), + NetPolicy { + enforce: true, + default_effect: Effect::Deny, + rules: vec![NetRule { + target: NetTarget::Host("example.com".into()), + effect: Effect::Allow, + }], + ..Default::default() + }, + ); + assert!(needs_account_backend(&p)); + } + + /// The agent-sandbox fs shape — generous read — is inexpressible as an allowlist, so it + /// routes to the account backend even with net untouched. + #[test] + fn generous_read_needs_the_account_backend() { + let p = policy(fs_policy(Effect::Allow, vec![]), net_off()); + assert!(needs_account_backend(&p)); + } + + /// The whole-fs `**` Allow entry is what the compiler actually emits for `"..."` / + /// `sandbox: true` — the primary real-world agent-sandbox policy — so its routing is + /// pinned separately from the `default_effect == Allow` arm above. + #[test] + fn whole_fs_allow_entry_needs_the_account_backend() { + let p = policy( + fs_policy( + Effect::Deny, + vec![ + rule("**", Effect::Allow, FsAccess::Read), + rule("C:/proj", Effect::Allow, FsAccess::ReadWrite), + ], + ), + net_off(), + ); + assert!(needs_account_backend(&p)); + } + + /// A deny that lands inside a granted subtree is the AppContainer's known hole (its + /// inheritable allow defeats the deny), so it must route to the account. + #[test] + fn deny_inside_a_grant_needs_the_account_backend() { + let p = policy( + fs_policy( + Effect::Deny, + vec![ + rule("C:/proj", Effect::Allow, FsAccess::ReadWrite), + rule("C:/proj/.env", Effect::Deny, FsAccess::Read), + ], + ), + net_off(), + ); + assert!(needs_account_backend(&p)); + } + + /// Denies become REAL ACE targets here — the difference from the allowlist backend, which + /// drops them entirely. + #[test] + fn derive_plan_keeps_literal_denies_as_ace_targets() { + let (read, write, deny, degrade) = derive_plan(&fs_policy( + Effect::Deny, + vec![ + rule("C:/proj", Effect::Allow, FsAccess::ReadWrite), + rule("C:/proj/.env", Effect::Deny, FsAccess::Read), + ], + )); + assert_eq!(read, vec![PathBuf::from("C:/proj")]); + assert_eq!(write, vec![PathBuf::from("C:/proj")]); + assert_eq!(deny, vec![PathBuf::from("C:/proj/.env")]); + assert_eq!(degrade, AccountFsDegrade::default()); + } + + /// A glob deny cannot be one ACE. It must be reported as a distinct LOST DENY — reporting + /// it as over-confinement (like a glob allow) would understate a real hole. + #[test] + fn glob_deny_is_reported_as_a_hole_not_as_over_confinement() { + let (_, _, deny, degrade) = derive_plan(&fs_policy( + Effect::Deny, + vec![ + rule("C:/proj", Effect::Allow, FsAccess::ReadWrite), + rule("**/.env", Effect::Deny, FsAccess::Read), + ], + )); + assert!(deny.is_empty(), "a glob deny yields no literal ACE target"); + assert!(degrade.glob_deny_unenforced); + assert!(!degrade.glob_read_unenforced); + } + + /// The whole-fs deny is the default-deny BASE, carried by the account simply having no + /// reach — flagging it as a lost deny would emit a permanent false degradation on every + /// ordinary confining policy. + #[test] + fn whole_fs_deny_base_is_not_a_lost_deny() { + let (_, _, deny, degrade) = derive_plan(&fs_policy( + Effect::Deny, + vec![ + rule("**", Effect::Deny, FsAccess::Read), + rule("C:/proj", Effect::Allow, FsAccess::ReadWrite), + ], + )); + assert!(deny.is_empty()); + assert!(!degrade.glob_deny_unenforced); + } + + /// A `..`-collapsed write grant must never land on a system root, same guard as the + /// AppContainer path — an inheritable modify ACE there would be a filesystem-wide hole. + #[test] + fn dangerous_write_roots_get_no_write_grant() { + let (read, write, _, _) = derive_plan(&fs_policy( + Effect::Deny, + vec![rule("C:/Windows", Effect::Allow, FsAccess::ReadWrite)], + )); + assert_eq!(read, vec![PathBuf::from("C:/Windows")]); + assert!(write.is_empty(), "C:/Windows must never be write-granted"); + } + + /// Net-unconfined under this backend is still fenced by the persistent filters, so it is + /// over-confinement that must be reported rather than a silently honored allow-all. + #[test] + fn unconfined_net_under_the_account_is_reported_as_fenced() { + assert_eq!(plan_net(&net_off()), AccountNet::UnconfinedButFenced); + } +} diff --git a/crates/nub-sandbox/src/backend/windows_account/state.rs b/crates/nub-sandbox/src/backend/windows_account/state.rs new file mode 100644 index 000000000..dea992a3e --- /dev/null +++ b/crates/nub-sandbox/src/backend/windows_account/state.rs @@ -0,0 +1,325 @@ +//! Provisioning marker + the ACL ledger — the only durable state the account backend keeps. +//! +//! TWO FILES, TWO JOBS. The **marker** records what the elevated setup created (the account's +//! SID and the WFP-authorized loopback port window) so an UNELEVATED run can decide whether it +//! may proceed and which port to bind — WFP gates even *enumeration* on administrator, so an +//! unelevated process cannot ask the firewall what is installed and must read it here. +//! The **ledger** records every path ever ACL'd for the sandbox SID so a sweep can undo them +//! after a crash. +//! +//! WHY THE LEDGER IS A FLAT PATH LIST AND NOT A RESTORE JOURNAL. MXC's DACL journal captures +//! each path's PRIOR aces before mutating, because its trustee (`ALL APPLICATION PACKAGES`, +//! `Everyone`) can legitimately already hold aces authored by installers or `icacls` — so +//! "undo" there means *restore what was there*. nub's trustee is an account nub itself +//! created, which by construction has no pre-existing ace anywhere. Undo is therefore an +//! unconditional idempotent strip, and the only thing worth remembering is WHERE. That +//! collapses ~600 lines of prior-state capture, per-run files, PID+creation-time liveness and +//! corrupt-file quarantine into a path list. Ordering is still load-bearing: the path is +//! recorded BEFORE the ace is applied, so a crash in between leaves a ledger entry for an ace +//! that was never written — and stripping a path that has no ace is a no-op. +//! +//! Both live under `%PROGRAMDATA%\nub\sandbox`, which the elevated setup locks to SYSTEM, +//! `BUILTIN\Administrators` and the provisioning user (see [`super::acl::lock_to_admins`] — +//! the same directory holds the DPAPI credential, and its DACL is that blob's ONLY boundary). +//! So the provisioning user reads the marker and appends the ledger unelevated, while another +//! standard user on the box reads neither and fails closed with "not provisioned". + +// Compiled on the dev host too, so the marker parse + ledger de-duplication are unit-tested +// without a Windows box; only the Windows launch/setup paths actually call the rest. +#![cfg_attr(not(target_os = "windows"), allow(dead_code))] + +use std::io; +use std::path::{Path, PathBuf}; + +/// Bumped when the on-disk shape changes. A marker from a newer nub is refused rather than +/// misread — an unelevated run acting on a misparsed SID would ACL the wrong principal. +/// v2 dropped the account NAME field, so a v1 marker is refused rather than read past. +pub(crate) const MARKER_VERSION: u32 = 2; + +/// What the one-time elevated setup recorded. +/// +/// DELIBERATELY CARRIES NO ACCOUNT NAME. The identity is the compile-time +/// [`super::SANDBOX_ACCOUNT`] const, and the launch validates the SID below against a live +/// lookup of THAT const. A name read from disk would have been an attacker-chosen logon target +/// that still passed the SID check — nothing about the identity comes off disk. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Marker { + pub(crate) version: u32, + /// The account's SID string. Every WFP filter and every granted ace keys on this, so a + /// marker whose SID no longer resolves means the account was deleted out from under us. + pub(crate) sid: String, + /// The loopback window the installed WFP permit covers. The egress proxy must bind + /// inside it or the child cannot reach the proxy at all. + pub(crate) port_low: u16, + pub(crate) port_high: u16, +} + +impl Marker { + /// Hand-rolled rather than derived: `serde_json` is a dependency, but a four-field record + /// does not earn a `Serialize` impl and the parse is the only place a malformed marker can + /// hurt us, so it stays explicit and total. + pub(crate) fn to_json(&self) -> String { + format!( + "{{\"version\":{},\"sid\":{},\"port_low\":{},\"port_high\":{}}}\n", + self.version, + json_string(&self.sid), + self.port_low, + self.port_high + ) + } + + pub(crate) fn from_json(s: &str) -> io::Result { + let v: serde_json::Value = serde_json::from_str(s).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("sandbox marker is not valid JSON: {e}"), + ) + })?; + let field = |k: &str| -> io::Result<&serde_json::Value> { + v.get(k).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("sandbox marker is missing `{k}`"), + ) + }) + }; + let version = field("version")?.as_u64().unwrap_or(0) as u32; + if version != MARKER_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "sandbox marker is version {version}, this nub understands {MARKER_VERSION} \ + — re-run `nub run --sandbox-admin setup` from an elevated prompt" + ), + )); + } + let as_str = |v: &serde_json::Value, k: &str| -> io::Result { + v.as_str().map(str::to_owned).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("sandbox marker field `{k}` is not a string"), + ) + }) + }; + let as_port = |v: &serde_json::Value, k: &str| -> io::Result { + v.as_u64() + .filter(|n| *n > 0 && *n <= u16::MAX as u64) + .map(|n| n as u16) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("sandbox marker field `{k}` is not a valid port"), + ) + }) + }; + let m = Marker { + version, + sid: as_str(field("sid")?, "sid")?, + port_low: as_port(field("port_low")?, "port_low")?, + port_high: as_port(field("port_high")?, "port_high")?, + }; + if m.port_high < m.port_low { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sandbox marker port range is inverted", + )); + } + Ok(m) + } +} + +fn json_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// `%PROGRAMDATA%\nub\sandbox`. Machine-scoped on purpose: the account, the WFP filters and +/// the ledger are all machine state, not per-user state. +/// +/// THE ONE DEFINITION OF THIS PATH — [`super::account::credential_dir`] delegates here. The +/// credential, the marker and the ledger all live in this directory, and setup locks it by +/// asking for the CREDENTIAL directory, so a second definition that ever drifted would silently +/// protect a directory the credential is not in. +pub(crate) fn state_dir() -> io::Result { + let base = std::env::var_os("PROGRAMDATA") + .filter(|v| !v.is_empty()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "PROGRAMDATA is not set — cannot locate the nub sandbox state directory", + ) + })?; + Ok(PathBuf::from(base).join("nub").join("sandbox")) +} + +pub(crate) fn marker_path() -> io::Result { + Ok(state_dir()?.join("setup.json")) +} + +pub(crate) fn ledger_path() -> io::Result { + Ok(state_dir()?.join("acl-ledger.txt")) +} + +/// ELEVATED (the directory's DACL permits only administrators to write). +pub(crate) fn write_marker(m: &Marker) -> io::Result<()> { + let dir = state_dir()?; + std::fs::create_dir_all(&dir)?; + // tmp + rename so a torn write can never leave a half-parsed marker that an unelevated + // run would act on. + let path = marker_path()?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, m.to_json())?; + std::fs::rename(&tmp, &path) +} + +/// UNELEVATED. `None` when the machine has never been set up. +pub(crate) fn read_marker() -> io::Result> { + let path = marker_path()?; + match std::fs::read_to_string(&path) { + Ok(s) => Marker::from_json(&s).map(Some), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +pub(crate) fn remove_marker() -> io::Result<()> { + match std::fs::remove_file(marker_path()?) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + +/// Record a path BEFORE its ace is applied. Append-only and best-effort-atomic: a single +/// short append under `O_APPEND` semantics is not interleaved by a concurrent writer in +/// practice, and the failure mode of a torn line is a path that is never swept — which the +/// caller's own teardown still handles on the happy path. +pub(crate) fn record_acl_path(path: &Path) -> io::Result<()> { + use std::io::Write; + let dir = state_dir()?; + std::fs::create_dir_all(&dir)?; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(ledger_path()?)?; + writeln!(f, "{}", path.display()) +} + +/// Every path the ledger has ever recorded, de-duplicated in first-seen order. +pub(crate) fn ledger_paths() -> io::Result> { + let text = match std::fs::read_to_string(ledger_path()?) { + Ok(t) => t, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e), + }; + Ok(dedup_lines(&text)) +} + +/// Split out so the de-duplication is testable without a filesystem. +fn dedup_lines(text: &str) -> Vec { + let mut seen: Vec = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let p = PathBuf::from(line); + if !seen.contains(&p) { + seen.push(p); + } + } + seen +} + +/// Called only after every recorded path has been stripped. +pub(crate) fn clear_ledger() -> io::Result<()> { + match std::fs::remove_file(ledger_path()?) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Marker { + Marker { + version: MARKER_VERSION, + sid: "S-1-5-21-1-2-3-1001".into(), + port_low: 59080, + port_high: 59089, + } + } + + #[test] + fn marker_round_trips() { + let m = sample(); + assert_eq!(Marker::from_json(&m.to_json()).unwrap(), m); + } + + /// A marker written by a NEWER nub must be refused, not partially believed: acting on a + /// misread SID would ACL the wrong principal, and acting on a misread port range would + /// bind the proxy outside the WFP permit so the child silently reaches nothing. + #[test] + fn a_future_marker_version_is_refused() { + let json = format!( + "{{\"version\":{},\"sid\":\"S-1-5-21-1\",\"port_low\":1,\"port_high\":2}}", + MARKER_VERSION + 1 + ); + let err = Marker::from_json(&json).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!(err.to_string().contains("--sandbox-admin setup")); + } + + /// The v1 marker carried the account NAME, and the launch logged on as it. Anyone able to + /// write the marker file could keep the real SID (so the SID check still passed) while + /// pointing the name at an account they controlled — every "sandboxed" run then launched + /// unfenced. v2 removed the field, so a v1 marker must be REFUSED outright rather than + /// parsed with its name ignored. + #[test] + fn a_v1_marker_carrying_an_account_name_is_refused() { + let json = "{\"version\":1,\"account\":\"attacker\",\"sid\":\"S-1-5-21-1\",\ + \"port_low\":1,\"port_high\":2}"; + let err = Marker::from_json(json).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + /// An inverted or zero range would make the proxy's bind-in-range search fail in a way + /// that reads as "no free port" rather than "your setup is corrupt". + #[test] + fn a_malformed_port_range_is_refused() { + for (low, high) in [(900, 800), (0, 800)] { + let json = format!( + "{{\"version\":{MARKER_VERSION},\"sid\":\"S\",\"port_low\":{low},\"port_high\":{high}}}" + ); + assert!(Marker::from_json(&json).is_err(), "{json}"); + } + } + + /// The ledger is append-only, so the same path recurs across runs; the sweep must visit + /// each path once rather than re-stripping it N times. + #[test] + fn ledger_deduplicates_repeated_paths() { + let paths = dedup_lines("C:/a\nC:/b\nC:/a\n\n C:/c \n"); + assert_eq!( + paths, + vec![ + PathBuf::from("C:/a"), + PathBuf::from("C:/b"), + PathBuf::from("C:/c") + ] + ); + } +} diff --git a/crates/nub-sandbox/src/backend/windows_account/wfp.rs b/crates/nub-sandbox/src/backend/windows_account/wfp.rs new file mode 100644 index 000000000..9c1f33ca6 --- /dev/null +++ b/crates/nub-sandbox/src/backend/windows_account/wfp.rs @@ -0,0 +1,613 @@ +//! Windows Filtering Platform egress fence for the dedicated sandbox account. +//! +//! THE MODEL (mirrors SRT's `vendor/srt-win-src/src/wfp.rs`, read 2026-07-24): four +//! PERSISTENT filters in one nub-owned persistent sublayer key the deny on +//! `FWPM_CONDITION_ALE_USER_ID` — the connecting token's user — so **every** process the +//! sandboxed child spawns is fenced by the same rule. That is what defeats surrogate-spawn: +//! a process-keyed filter loses the moment the child launches a helper, a user-keyed one +//! cannot. +//! +//! WHY A PORT RANGE, NOT THE EXACT PROXY PORT (the decision that keeps every run +//! UNELEVATED): every `Fwpm*Add`/`Delete` needs administrator, so a per-run filter carrying +//! the run's ephemeral proxy port would mean a UAC prompt per run. Instead the one-time +//! elevated install pre-authorizes a narrow LOOPBACK PORT WINDOW, and the proxy binds +//! *into* that window at launch (`proxy::EgressProxy::start_in_range`). One elevated +//! install ever; every run after it touches WFP not at all. The honest cost is that the +//! permit is a ~10-port loopback window rather than one exact port, and it is not +//! user-scoped — any local principal may connect to loopback inside it. Codex takes the +//! other branch (exact ports baked into the rule) and pays a fresh UAC prompt whenever the +//! port changes. +//! +//! ARBITRATION: WFP picks the highest-weight matching filter within a sublayer, so the +//! loopback PERMIT must out-weigh the account BLOCK. That ordering is the entire fence and +//! is const-asserted below. Weights stay under 2^60 so they land in WFP's manual-weight +//! class (the top nibble is the auto-classifier's). +//! +//! HONEST GAPS (both references share them; see LIMITATIONS.md): +//! - Inbound (`ALE_AUTH_RECV_ACCEPT_*`) and bind (`ALE_RESOURCE_ASSIGNMENT_*`) are NOT +//! filtered — the account may still listen. Egress is the axis nub's grammar names. +//! - DNS still resolves: `getaddrinfo` is serviced by the `Dnscache` service running as +//! `NETWORK SERVICE`, so the lookup succeeds under a different token even though the +//! subsequent `connect()` is blocked. Inherent to keying on the connecting token; no +//! filter set can close it. Harmless here — the child talks to nub's proxy by IP and +//! the PROXY resolves. + +#![cfg(target_os = "windows")] + +use std::io; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::*; +use windows_sys::core::GUID; + +/// nub's own WFP provider — every object this module installs carries it, so the uninstall +/// sweep can enumerate exactly nub's filters and nothing else. Stable forever: regenerating +/// it would orphan every filter written by an older nub. +const NUB_PROVIDER_KEY: GUID = GUID::from_u128(0x6f1c9e42_5a3d_4b17_9d84_2c7e0f5a8b31); +const NUB_SUBLAYER_KEY: GUID = GUID::from_u128(0x9a4b7d13_0e62_4c58_bf29_71d3a6e4c085); + +/// Sublayer weight. Mirrors SRT/Codex; high enough to sit above the default filtering +/// sublayers, low enough not to fight IPsec. +const SUBLAYER_WEIGHT: u16 = 0x8000; + +/// The loopback PERMIT must out-weigh the account BLOCK — that ordering IS the fence. +const W_LOOPBACK_PERMIT: u64 = 0x0F80_0000_0000_0000; +const W_ACCOUNT_BLOCK: u64 = 0x0F40_0000_0000_0000; +const _: () = assert!(W_LOOPBACK_PERMIT > W_ACCOUNT_BLOCK); + +/// The loopback window the elevated install pre-authorizes, which the egress proxy then +/// binds into. Ten ports leaves headroom for two concurrent listeners plus bind retries. +pub(crate) const DEFAULT_PROXY_PORT_RANGE: (u16, u16) = (59080, 59089); + +/// A wider window would stop meaningfully narrowing loopback, so the install refuses one. +pub(crate) const MAX_PROXY_PORT_RANGE_WIDTH: u16 = 64; + +// The default window must itself satisfy the rule the installer enforces on a user-supplied +// one, or the out-of-the-box setup would be refused by its own validation. +const _: () = { + let (low, high) = DEFAULT_PROXY_PORT_RANGE; + assert!(low > 0 && high >= low); + assert!(high - low < MAX_PROXY_PORT_RANGE_WIDTH); +}; + +/// BFE reports access-denied as EITHER of these — test both or an unelevated caller looks +/// like a hard failure. +const FWP_E_ACCESS_DENIED: u32 = 0x8032_0028; +const ERROR_ACCESS_DENIED: u32 = 5; +const FWP_E_ALREADY_EXISTS: u32 = 0x8032_0009; +const FWP_E_FILTER_NOT_FOUND: u32 = 0x8032_0003; +const FWP_E_SUBLAYER_NOT_FOUND: u32 = 0x8032_0007; +const FWP_E_PROVIDER_NOT_FOUND: u32 = 0x8032_0006; +const FWP_E_IN_USE: u32 = 0x8032_000A; + +/// `RPC_C_AUTHN_DEFAULT`, spelled locally so this module needs no `Win32_System_Rpc` import +/// beyond the one the binding's own signature forces. +const RPC_C_AUTHN_DEFAULT: u32 = 0xFFFF_FFFF; + +/// Whether a raw WFP status means "you are not elevated" rather than a real fault. +pub(crate) fn is_access_denied(rc: u32) -> bool { + rc == FWP_E_ACCESS_DENIED || rc == ERROR_ACCESS_DENIED +} + +fn wfp_err(op: &'static str, rc: u32) -> io::Error { + if is_access_denied(rc) { + return io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{op} failed: administrator rights are required to change WFP filters"), + ); + } + io::Error::other(format!("{op} failed (WFP status {rc:#010x})")) +} + +fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// An open BFE engine handle, closed on drop. +struct Engine(HANDLE); + +impl Engine { + fn open() -> io::Result { + let mut h: HANDLE = std::ptr::null_mut(); + // SAFETY: all-null optional params; `h` is a valid out-slot. + let rc = unsafe { + FwpmEngineOpen0( + std::ptr::null(), + RPC_C_AUTHN_DEFAULT, + std::ptr::null(), + std::ptr::null(), + &mut h, + ) + }; + if rc != 0 { + return Err(wfp_err("FwpmEngineOpen0", rc)); + } + Ok(Engine(h)) + } +} + +impl Drop for Engine { + fn drop(&mut self) { + // SAFETY: `self.0` came from a successful FwpmEngineOpen0 and is closed once. + unsafe { FwpmEngineClose0(self.0) }; + } +} + +/// Run `f` inside a WFP transaction, aborting on any error or unwind. The whole install is +/// one transaction so a mid-install failure cannot leave a half-fence — which would be +/// fail-OPEN (block filters missing, permit present). +fn in_transaction(engine: &Engine, f: impl FnOnce(&Engine) -> io::Result) -> io::Result { + // SAFETY: engine handle is live for the whole call. + let rc = unsafe { FwpmTransactionBegin0(engine.0, 0) }; + if rc != 0 { + return Err(wfp_err("FwpmTransactionBegin0", rc)); + } + struct AbortOnDrop(HANDLE); + impl Drop for AbortOnDrop { + fn drop(&mut self) { + unsafe { FwpmTransactionAbort0(self.0) }; + } + } + let abort = AbortOnDrop(engine.0); + let out = f(engine)?; + // SAFETY: still inside the transaction opened above. + let rc = unsafe { FwpmTransactionCommit0(engine.0) }; + if rc != 0 { + return Err(wfp_err("FwpmTransactionCommit0", rc)); + } + std::mem::forget(abort); // disarm ONLY after a successful commit + Ok(out) +} + +/// A self-relative security descriptor built from SDDL, `LocalFree`d on drop. This is the +/// value the `ALE_USER_ID` condition carries: WFP runs `AccessCheck` on the connecting +/// token against it, so the filter matches exactly when the token is the sandbox account. +struct OwnedSd { + ptr: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, + len: u32, +} + +impl OwnedSd { + /// `G:` (primary group) is REQUIRED — not by the kernel, but because user-mode + /// `AccessCheck` rejects a group-less descriptor with `ERROR_INVALID_SECURITY_DESCR`, + /// which is how the host-side unit test validates the shape. `LS` (Local Service) is + /// just a stable always-present principal; its value never enters DACL evaluation. + /// The `CC` right is an arbitrary single bit — WFP only cares whether the check grants. + fn for_sid(sid: &str) -> io::Result { + use windows_sys::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW; + let sddl = to_wide(&format!("O:LSG:LSD:(A;;CC;;;{sid})")); + let mut ptr = std::ptr::null_mut(); + let mut len: u32 = 0; + // SAFETY: `sddl` is NUL-terminated UTF-16; both out-params are valid slots. + let ok = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + 1, // SDDL_REVISION_1 + &mut ptr, + &mut len, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(OwnedSd { ptr, len }) + } + + fn blob(&self) -> FWP_BYTE_BLOB { + FWP_BYTE_BLOB { + size: self.len, + data: self.ptr.cast(), + } + } +} + +impl Drop for OwnedSd { + fn drop(&mut self) { + // SAFETY: `ptr` came from ConvertStringSecurityDescriptorToSecurityDescriptorW, + // which documents LocalFree as the release. + unsafe { windows_sys::Win32::Foundation::LocalFree(self.ptr.cast()) }; + } +} + +/// Backing storage every condition value points INTO. WFP's condition unions hold RAW +/// POINTERS, so these slots must outlive `FwpmFilterAdd0` — the single easiest way to write +/// a filter that silently reads freed memory. +struct ConditionSlots { + v4: FWP_V4_ADDR_AND_MASK, + v6: FWP_BYTE_ARRAY16, + port_range: FWP_RANGE0, + sd_blob: FWP_BYTE_BLOB, + weight: u64, +} + +/// The layer/action/conditions of one installed filter, kept as plain data so the four-way +/// filter table reads as a table. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum FilterSpec { + /// Permit the account to reach nub's proxy: loopback, inside the pre-authorized window. + LoopbackPermitV4, + LoopbackPermitV6, + /// Deny the account everything else. + AccountBlockV4, + AccountBlockV6, +} + +impl FilterSpec { + const ALL: [FilterSpec; 4] = [ + FilterSpec::LoopbackPermitV4, + FilterSpec::LoopbackPermitV6, + FilterSpec::AccountBlockV4, + FilterSpec::AccountBlockV6, + ]; + + fn layer(self) -> GUID { + match self { + FilterSpec::LoopbackPermitV4 | FilterSpec::AccountBlockV4 => { + FWPM_LAYER_ALE_AUTH_CONNECT_V4 + } + FilterSpec::LoopbackPermitV6 | FilterSpec::AccountBlockV6 => { + FWPM_LAYER_ALE_AUTH_CONNECT_V6 + } + } + } + + fn is_permit(self) -> bool { + matches!( + self, + FilterSpec::LoopbackPermitV4 | FilterSpec::LoopbackPermitV6 + ) + } + + fn name(self) -> &'static str { + match self { + FilterSpec::LoopbackPermitV4 => "nub sandbox: permit loopback proxy (IPv4)", + FilterSpec::LoopbackPermitV6 => "nub sandbox: permit loopback proxy (IPv6)", + FilterSpec::AccountBlockV4 => "nub sandbox: block egress for sandbox account (IPv4)", + FilterSpec::AccountBlockV6 => "nub sandbox: block egress for sandbox account (IPv6)", + } + } +} + +/// Install the four-filter egress fence for `sid`, permitting only loopback TCP inside +/// `port_range`. ELEVATED. Idempotent: any previously installed nub filters are swept +/// inside the same transaction first, so re-running never accretes duplicates. +pub(crate) fn install(sid: &str, port_range: (u16, u16)) -> io::Result<()> { + let (low, high) = port_range; + if low == 0 || high < low { + return Err(io::Error::other(format!( + "invalid sandbox proxy port range {low}-{high}" + ))); + } + if high - low >= MAX_PROXY_PORT_RANGE_WIDTH { + return Err(io::Error::other(format!( + "sandbox proxy port range {low}-{high} is wider than the {MAX_PROXY_PORT_RANGE_WIDTH}-port maximum \ + — a wide window stops meaningfully narrowing loopback" + ))); + } + + let sd = OwnedSd::for_sid(sid)?; + let engine = Engine::open()?; + + in_transaction(&engine, |e| { + // Sweeping inside the transaction is what makes re-install idempotent. A swallowed + // enum error here would silently skip the sweep and grow the filter count on every + // install, so it propagates. + delete_nub_filters(e)?; + add_provider(e)?; + add_sublayer(e)?; + + let mut slots = ConditionSlots { + v4: FWP_V4_ADDR_AND_MASK { + addr: 0x7F00_0000, // 127.0.0.0 + mask: 0xFF00_0000, // /8 + }, + v6: { + let mut a = FWP_BYTE_ARRAY16 { + byteArray16: [0; 16], + }; + a.byteArray16[15] = 1; // ::1 + a + }, + port_range: FWP_RANGE0 { + valueLow: uint16_value(low), + valueHigh: uint16_value(high), + }, + sd_blob: sd.blob(), + weight: 0, + }; + + for spec in FilterSpec::ALL { + add_filter(e, spec, &mut slots)?; + } + Ok(()) + }) +} + +/// Remove every WFP object nub installed. ELEVATED. Tolerant of a partially-installed or +/// already-clean state so it can also serve as crash-residue recovery. +pub(crate) fn uninstall() -> io::Result<()> { + let engine = Engine::open()?; + in_transaction(&engine, |e| { + delete_nub_filters(e)?; + // SAFETY: engine handle live; key is a 'static GUID. + let rc = unsafe { FwpmSubLayerDeleteByKey0(e.0, &NUB_SUBLAYER_KEY) }; + // FWP_E_IN_USE means a foreign filter still sits under our sublayer — leave it. + if rc != 0 && rc != FWP_E_SUBLAYER_NOT_FOUND && rc != FWP_E_IN_USE { + return Err(wfp_err("FwpmSubLayerDeleteByKey0", rc)); + } + // SAFETY: as above. + let rc = unsafe { FwpmProviderDeleteByKey0(e.0, &NUB_PROVIDER_KEY) }; + if rc != 0 && rc != FWP_E_PROVIDER_NOT_FOUND && rc != FWP_E_IN_USE { + return Err(wfp_err("FwpmProviderDeleteByKey0", rc)); + } + Ok(()) + }) +} + +/// How many nub filters are currently installed. ELEVATED — WFP gates even ENUMERATION on +/// administrator, which is why an unelevated `status` cannot use this and must fall back to +/// the behavioral probe instead. +pub(crate) fn installed_filter_count() -> io::Result { + let engine = Engine::open()?; + let mut n = 0usize; + for layer in [ + FWPM_LAYER_ALE_AUTH_CONNECT_V4, + FWPM_LAYER_ALE_AUTH_CONNECT_V6, + ] { + n += enum_nub_filter_keys(&engine, layer)?.len(); + } + Ok(n) +} + +fn add_provider(e: &Engine) -> io::Result<()> { + let mut name = to_wide("nub sandbox"); + let mut desc = to_wide("nub OS-enforced sandbox egress fence"); + let provider = FWPM_PROVIDER0 { + providerKey: NUB_PROVIDER_KEY, + displayData: FWPM_DISPLAY_DATA0 { + name: name.as_mut_ptr(), + description: desc.as_mut_ptr(), + }, + flags: FWPM_PROVIDER_FLAG_PERSISTENT, + providerData: FWP_BYTE_BLOB { + size: 0, + data: std::ptr::null_mut(), + }, + serviceName: std::ptr::null_mut(), + }; + // SAFETY: `name`/`desc` outlive the call; provider is fully initialized. + let rc = unsafe { FwpmProviderAdd0(e.0, &provider, std::ptr::null_mut()) }; + if rc != 0 && rc != FWP_E_ALREADY_EXISTS { + return Err(wfp_err("FwpmProviderAdd0", rc)); + } + Ok(()) +} + +fn add_sublayer(e: &Engine) -> io::Result<()> { + let mut name = to_wide("nub sandbox"); + let mut desc = to_wide("nub sandbox account egress fence"); + let mut provider_key = NUB_PROVIDER_KEY; + let sublayer = FWPM_SUBLAYER0 { + subLayerKey: NUB_SUBLAYER_KEY, + displayData: FWPM_DISPLAY_DATA0 { + name: name.as_mut_ptr(), + description: desc.as_mut_ptr(), + }, + flags: FWPM_SUBLAYER_FLAG_PERSISTENT, + providerKey: &mut provider_key, + providerData: FWP_BYTE_BLOB { + size: 0, + data: std::ptr::null_mut(), + }, + weight: SUBLAYER_WEIGHT, + }; + // SAFETY: every referenced buffer outlives the call. + let rc = unsafe { FwpmSubLayerAdd0(e.0, &sublayer, std::ptr::null_mut()) }; + if rc != 0 && rc != FWP_E_ALREADY_EXISTS { + return Err(wfp_err("FwpmSubLayerAdd0", rc)); + } + Ok(()) +} + +fn uint16_value(v: u16) -> FWP_VALUE0 { + FWP_VALUE0 { + r#type: FWP_UINT16, + Anonymous: FWP_VALUE0_0 { uint16: v }, + } +} + +fn add_filter(e: &Engine, spec: FilterSpec, slots: &mut ConditionSlots) -> io::Result<()> { + let mut conditions: Vec = Vec::with_capacity(2); + + if spec.is_permit() { + let addr_value = match spec { + FilterSpec::LoopbackPermitV4 => FWP_CONDITION_VALUE0 { + r#type: FWP_V4_ADDR_MASK, + Anonymous: FWP_CONDITION_VALUE0_0 { + v4AddrMask: &mut slots.v4, + }, + }, + _ => FWP_CONDITION_VALUE0 { + r#type: FWP_BYTE_ARRAY16_TYPE, + Anonymous: FWP_CONDITION_VALUE0_0 { + byteArray16: &mut slots.v6, + }, + }, + }; + conditions.push(FWPM_FILTER_CONDITION0 { + fieldKey: FWPM_CONDITION_IP_REMOTE_ADDRESS, + matchType: FWP_MATCH_EQUAL, + conditionValue: addr_value, + }); + conditions.push(FWPM_FILTER_CONDITION0 { + fieldKey: FWPM_CONDITION_IP_REMOTE_PORT, + matchType: FWP_MATCH_RANGE, + conditionValue: FWP_CONDITION_VALUE0 { + r#type: FWP_RANGE_TYPE, + Anonymous: FWP_CONDITION_VALUE0_0 { + rangeValue: &mut slots.port_range, + }, + }, + }); + } else { + conditions.push(FWPM_FILTER_CONDITION0 { + fieldKey: FWPM_CONDITION_ALE_USER_ID, + matchType: FWP_MATCH_EQUAL, + conditionValue: FWP_CONDITION_VALUE0 { + r#type: FWP_SECURITY_DESCRIPTOR_TYPE, + Anonymous: FWP_CONDITION_VALUE0_0 { + sd: &mut slots.sd_blob, + }, + }, + }); + } + + slots.weight = if spec.is_permit() { + W_LOOPBACK_PERMIT + } else { + W_ACCOUNT_BLOCK + }; + + let mut name = to_wide(spec.name()); + let mut provider_key = NUB_PROVIDER_KEY; + let filter = FWPM_FILTER0 { + // A zero filterKey makes WFP mint one; identity comes from the provider key, which + // is what the uninstall sweep enumerates on. + displayData: FWPM_DISPLAY_DATA0 { + name: name.as_mut_ptr(), + description: std::ptr::null_mut(), + }, + flags: FWPM_FILTER_FLAG_PERSISTENT, + providerKey: &mut provider_key, + layerKey: spec.layer(), + subLayerKey: NUB_SUBLAYER_KEY, + weight: FWP_VALUE0 { + r#type: FWP_UINT64, + Anonymous: FWP_VALUE0_0 { + uint64: &mut slots.weight, + }, + }, + numFilterConditions: conditions.len() as u32, + filterCondition: conditions.as_mut_ptr(), + action: FWPM_ACTION0 { + r#type: if spec.is_permit() { + FWP_ACTION_PERMIT + } else { + FWP_ACTION_BLOCK + }, + Anonymous: FWPM_ACTION0_0 { + filterType: GUID::from_u128(0), + }, + }, + ..Default::default() + }; + + // SAFETY: `conditions`, `slots`, `name` and `provider_key` all outlive this call — WFP + // stores raw pointers into every one of them for the duration of the add. + let rc = unsafe { FwpmFilterAdd0(e.0, &filter, std::ptr::null_mut(), std::ptr::null_mut()) }; + if rc != 0 && rc != FWP_E_ALREADY_EXISTS { + return Err(wfp_err("FwpmFilterAdd0", rc)); + } + Ok(()) +} + +/// Every filter key under nub's provider at `layer`. Paged 256 at a time; each returned +/// batch is freed, and the enum handle is destroyed on both the success and error paths. +fn enum_nub_filter_keys(e: &Engine, layer: GUID) -> io::Result> { + let mut provider_key = NUB_PROVIDER_KEY; + let template = FWPM_FILTER_ENUM_TEMPLATE0 { + providerKey: &mut provider_key, + layerKey: layer, + enumType: FWP_FILTER_ENUM_OVERLAPPING, + actionMask: 0xFFFF_FFFF, + ..Default::default() + }; + + let mut enum_handle: HANDLE = std::ptr::null_mut(); + // SAFETY: `template` outlives the create; `enum_handle` is a valid out-slot. + let rc = unsafe { FwpmFilterCreateEnumHandle0(e.0, &template, &mut enum_handle) }; + if rc != 0 { + return Err(wfp_err("FwpmFilterCreateEnumHandle0", rc)); + } + + const PAGE: u32 = 256; + let mut keys = Vec::new(); + let result = loop { + let mut entries: *mut *mut FWPM_FILTER0 = std::ptr::null_mut(); + let mut returned: u32 = 0; + // SAFETY: valid engine + enum handles; both out-params are live slots. + let rc = unsafe { FwpmFilterEnum0(e.0, enum_handle, PAGE, &mut entries, &mut returned) }; + if rc != 0 { + break Err(wfp_err("FwpmFilterEnum0", rc)); + } + if !entries.is_null() { + // SAFETY: WFP guarantees `returned` valid `*mut FWPM_FILTER0` entries. + let slice = unsafe { std::slice::from_raw_parts(entries, returned as usize) }; + for &f in slice { + if !f.is_null() { + // SAFETY: each entry is a live filter for the life of this batch. + keys.push(unsafe { (*f).filterKey }); + } + } + // Every batch is freed, including an empty one. + unsafe { FwpmFreeMemory0(std::ptr::from_mut(&mut entries).cast()) }; + } + if returned < PAGE { + break Ok(()); + } + }; + + // SAFETY: destroy the enum handle on BOTH paths before returning. + unsafe { FwpmFilterDestroyEnumHandle0(e.0, enum_handle) }; + result?; + Ok(keys) +} + +fn delete_nub_filters(e: &Engine) -> io::Result<()> { + for layer in [ + FWPM_LAYER_ALE_AUTH_CONNECT_V4, + FWPM_LAYER_ALE_AUTH_CONNECT_V6, + ] { + for key in enum_nub_filter_keys(e, layer)? { + // SAFETY: `key` came out of the enumeration above. + let rc = unsafe { FwpmFilterDeleteByKey0(e.0, &key) }; + if rc != 0 && rc != FWP_E_FILTER_NOT_FOUND { + return Err(wfp_err("FwpmFilterDeleteByKey0", rc)); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `windows_sys::core::GUID` derives only `Copy`/`Clone`/`Default`, so equality is + /// field-wise. + fn same_guid(a: &GUID, b: &GUID) -> bool { + a.data1 == b.data1 && a.data2 == b.data2 && a.data3 == b.data3 && a.data4 == b.data4 + } + + /// Both access-denied spellings must read as "not elevated" — BFE returns either, and + /// treating one as a hard fault would turn a missing-elevation into a confusing crash. + #[test] + fn both_access_denied_spellings_are_recognized() { + assert!(is_access_denied(FWP_E_ACCESS_DENIED)); + assert!(is_access_denied(ERROR_ACCESS_DENIED)); + assert!(!is_access_denied(FWP_E_ALREADY_EXISTS)); + } + + /// The fence is exactly four filters — two permits, two blocks, one pair per address + /// family. A missing v6 block is a silent egress hole on a dual-stack host. + #[test] + fn filter_table_covers_both_families_in_both_directions() { + let permits = FilterSpec::ALL.iter().filter(|s| s.is_permit()).count(); + assert_eq!(permits, 2); + assert_eq!(FilterSpec::ALL.len() - permits, 2); + let v4 = FilterSpec::ALL + .iter() + .filter(|s| same_guid(&s.layer(), &FWPM_LAYER_ALE_AUTH_CONNECT_V4)) + .count(); + assert_eq!(v4, 2, "one permit + one block on IPv4"); + } +} diff --git a/crates/nub-sandbox/src/lib.rs b/crates/nub-sandbox/src/lib.rs index 0ab1294b5..6780b8ee9 100644 --- a/crates/nub-sandbox/src/lib.rs +++ b/crates/nub-sandbox/src/lib.rs @@ -97,6 +97,18 @@ pub mod policy; pub mod proxy; pub use backend::{CommandSpec, Degradation, Prepared, apply}; + +/// The Windows dedicated-account backend's machine administration, surfaced for the CLI. +/// +/// These are the ONLY operations that need administrator, and they are the reason the +/// per-run launch does not: one elevated `setup` installs the account and the SID-keyed WFP +/// egress fence, after which every sandboxed run is unelevated. `clean` is unelevated and +/// exists for crash residue. Windows-only by construction — no other OS needs a second +/// principal to express the grammar. +#[cfg(target_os = "windows")] +pub mod windows_admin { + pub use crate::backend::windows_account::{clean, setup, status, teardown}; +} pub use compiler::{ CommandRunner, CompileCtx, CompileError, CompileWarning, compile, compile_with_warnings, }; diff --git a/crates/nub-sandbox/src/proxy/mod.rs b/crates/nub-sandbox/src/proxy/mod.rs index 3087e55e9..3ebcae471 100644 --- a/crates/nub-sandbox/src/proxy/mod.rs +++ b/crates/nub-sandbox/src/proxy/mod.rs @@ -115,6 +115,28 @@ pub struct EgressProxy { mitm: Option>, } +/// Walk `[low, high]` for a free loopback port. An in-use port is skipped rather than fatal +/// (a sibling nub run legitimately holds one); exhausting the window is an error naming it, +/// because silently falling back to an ephemeral port would bind OUTSIDE the range the +/// Windows WFP permit covers and leave the child unable to reach the proxy at all. +fn bind_in_range(low: u16, high: u16) -> io::Result { + let mut last: Option = None; + for port in low..=high { + match TcpListener::bind((IpAddr::from([127, 0, 0, 1]), port)) { + Ok(l) => return Ok(l), + Err(e) => last = Some(e), + } + } + Err(io::Error::new( + io::ErrorKind::AddrInUse, + format!( + "every loopback port in the sandbox proxy window {low}-{high} is in use{}", + last.map(|e| format!(" (last error: {e})")) + .unwrap_or_default() + ), + )) +} + impl EgressProxy { /// Bind a loopback listener and start the accept loop. `decider` gates every tunnel; /// `mitm` (when present) terminates the hosts whose rules demand inspection. Returns @@ -123,10 +145,29 @@ impl EgressProxy { pub fn start( decider: Arc, mitm: Option>, + ) -> io::Result { + Self::start_in_range(decider, mitm, None) + } + + /// As [`EgressProxy::start`], but constrained to bind inside `[low, high]` when a range is + /// given. + /// + /// WHY A RANGE EXISTS AT ALL: Windows' dedicated-account backend fences egress with WFP + /// filters keyed on the account SID, and every WFP write needs administrator. Baking the + /// run's ephemeral port into a filter would therefore mean a UAC prompt per run, so the + /// one-time elevated setup pre-authorizes a narrow loopback WINDOW instead and the proxy + /// binds into it. mac/Linux carve the exact port at launch and pass `None`. + pub fn start_in_range( + decider: Arc, + mitm: Option>, + range: Option<(u16, u16)>, ) -> io::Result { // Loopback only — the sandboxed child reaches us via 127.0.0.1; nothing off-box // should ever see this listener. - let listener = TcpListener::bind((IpAddr::from([127, 0, 0, 1]), 0))?; + let listener = match range { + None => TcpListener::bind((IpAddr::from([127, 0, 0, 1]), 0))?, + Some((low, high)) => bind_in_range(low, high)?, + }; let port = listener.local_addr()?.port(); let token: Arc = Arc::from(mint_token()); let shutdown = Arc::new(AtomicBool::new(false)); diff --git a/crates/nub-sandbox/tests/windows_account_enforcement.rs b/crates/nub-sandbox/tests/windows_account_enforcement.rs new file mode 100644 index 000000000..3db9d8bf5 --- /dev/null +++ b/crates/nub-sandbox/tests/windows_account_enforcement.rs @@ -0,0 +1,533 @@ +//! Windows dedicated-account backend — REAL enforcement probe (a provisioned Windows box only). +//! +//! Drives the REAL public seam (`apply` → `Prepared::status`): each case compiles a policy +//! that routes to the account backend, launches a child AS the `nub-sandbox` local account +//! through seclogon, and asserts the account's ACL reach / SID-keyed WFP fence allowed or +//! denied the action. Every confinement assertion is paired with a NEGATIVE CONTROL — almost +//! always an UNCONFINED run of the identical action — so a pass cannot be hollow: the control +//! proves the file or the TCP endpoint is genuinely reachable and the denial is confinement. +//! +//! THE MARQUEE CASE is the secret DENIED INSIDE a granted tree. That is precisely what the +//! AppContainer backend cannot express (an allowlist has no "deny inside allow", and an +//! inherited `ALL APPLICATION PACKAGES` grant satisfies the LowBox check before default-deny +//! is reached), and it is the reason this second backend exists at all. +//! +//! `harness = false`: this binary is BOTH the runner AND the probe child — an +//! `__acctchild__ ` invocation acts as the child (read/write/connect/spawnconnect/whoami +//! → a numeric exit-code contract), any other invocation runs the cases. The exit code is the +//! ONLY channel out of a seclogon-launched child, so the contract carries every verdict. +//! +//! PRECONDITION, NON-NEGOTIABLE: the account backend needs a one-time ELEVATED setup. This +//! probe never passes silently over an un-provisioned machine — it exits NON-ZERO naming the +//! command a human must run. When it IS elevated it may provision itself and tears that down +//! again at the end. + +#[cfg(not(target_os = "windows"))] +fn main() { + // Non-Windows host: nothing to enforce. (`harness = false` needs a `main`.) +} + +#[cfg(target_os = "windows")] +fn main() { + let args: Vec = std::env::args().collect(); + if args.get(1).map(String::as_str) == Some("__acctchild__") { + std::process::exit(win::child_main(&args[2..])); + } + std::process::exit(win::run()); +} + +#[cfg(target_os = "windows")] +mod win { + use nub_sandbox::policy::{ + CanonGlob, Effect, EnvPolicy, FsAccess, FsPolicy, FsRule, FsRuleSet, NetPolicy, PidPolicy, + SandboxPolicy, TmpMode, + }; + use nub_sandbox::{CommandSpec, apply, windows_admin}; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::path::{Path, PathBuf}; + use std::time::Duration; + + /// The account nub provisions (`windows_account::SANDBOX_ACCOUNT`, not public). + const ACCOUNT: &str = "nub-sandbox"; + + // ── the probe child ───────────────────────────────────────────────────────── + + /// The child's exit-code contract. Distinct codes so a denial is never confused with a + /// crash — and so the WFP fence is never confused with an ordinary access denial: + /// 0 ok, 2 unknown-role, 3 grandchild-spawn-failed, 5 DENIED (fs `ERROR_ACCESS_DENIED` + /// or net `WSAEACCES`), 6 timeout, 8 net refused with a plain `ERROR_ACCESS_DENIED` + /// (NOT the fence — never a pass), 9 other error, 10 wrong identity. + pub fn child_main(a: &[String]) -> i32 { + match a.first().map(String::as_str) { + Some("read") => classify_fs(std::fs::read(&a[1]).map(|_| ())), + Some("write") => classify_fs(std::fs::write(&a[1], b"x")), + Some("connect") => connect(&a[1], a[2].parse().unwrap_or(0)), + Some("spawnconnect") => spawn_grandchild(&a[1], &a[2]), + Some("whoami") => whoami(&a[1]), + _ => 2, + } + } + + fn classify_fs(r: std::io::Result<()>) -> i32 { + match r { + Ok(()) => 0, + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => 5, + Err(e) => { + eprintln!(" child fs: {e} os={:?}", e.raw_os_error()); + 9 + } + } + } + + fn connect(host: &str, port: u16) -> i32 { + let Ok(addr) = format!("{host}:{port}").parse::() else { + return 9; + }; + match TcpStream::connect_timeout(&addr, Duration::from_secs(6)) { + Ok(_) => 0, + // 10013 == WSAEACCES: the WFP `ALE_AUTH_CONNECT` block keyed on the account SID. + // A plain ERROR_ACCESS_DENIED (5) comes from somewhere else entirely and must + // never read as the fence, so it gets its own code and fails the assertion. + Err(e) if e.raw_os_error() == Some(10013) => 5, + Err(e) if e.raw_os_error() == Some(5) => 8, + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => 6, + Err(e) => { + eprintln!(" child connect {addr}: {e} os={:?}", e.raw_os_error()); + 9 + } + } + } + + /// Re-exec THIS binary as a grandchild attempting the same connect, propagating its code + /// verbatim so the surrogate is judged by the identical contract. + fn spawn_grandchild(host: &str, port: &str) -> i32 { + let Ok(exe) = std::env::current_exe() else { + return 3; + }; + match std::process::Command::new(exe) + .args(["__acctchild__", "connect", host, port]) + .status() + { + Ok(s) => s.code().unwrap_or(9), + Err(e) => { + eprintln!(" grandchild spawn: {e}"); + 3 + } + } + } + + /// The anti-vacuity guard: prove the child really runs as the dedicated account. Without + /// it every denial below could be an ordinary spawn failure rather than confinement. + /// Identity comes from the profile environment seclogon builds for the account — the + /// launch passes a NULL env block, so `USERNAME`/`USERPROFILE` are the ACCOUNT's. + fn whoami(expected: &str) -> i32 { + let user = std::env::var("USERNAME").unwrap_or_default(); + let profile = std::env::var("USERPROFILE").unwrap_or_default(); + println!(" CHILD USERNAME={user} USERPROFILE={profile}"); + let low = expected.to_ascii_lowercase(); + if user.eq_ignore_ascii_case(expected) || profile.to_ascii_lowercase().contains(&low) { + 0 + } else { + 10 + } + } + + // ── the fixture ─────────────────────────────────────────────────────────────── + + struct Fixture { + _root: tempfile::TempDir, + child: PathBuf, + work: PathBuf, + ok: PathBuf, + secret: PathBuf, + outside: PathBuf, + } + + impl Fixture { + fn new() -> std::io::Result { + // An ORDINARY %TEMP% tree with NO DACL pre-hardening. The AppContainer probe has + // to strip inherited ACEs because an inherited `ALL APPLICATION PACKAGES` grant + // satisfies a LowBox check; no AAP grant ever covers a USER SID, so here the + // account simply has no reach until this run's ACEs land. Traverse to the leaf + // rides SeChangeNotifyPrivilege, which every local user holds. + let root = tempfile::Builder::new().prefix("nub-acct-").tempdir()?; + let bin = root.path().join("bin"); + let work = root.path().join("work"); + let outside = root.path().join("outside"); + for d in [&bin, &work, &outside] { + std::fs::create_dir_all(d)?; + } + let child = bin.join("child.exe"); + std::fs::copy(std::env::current_exe()?, &child)?; + let ok = work.join("ok.txt"); + std::fs::write(&ok, b"this-is-fine")?; + let secret = work.join(".env"); + std::fs::write(&secret, b"TOPSECRET_TOKEN=do-not-leak")?; + Ok(Fixture { + _root: root, + child, + work, + ok, + secret, + outside, + }) + } + } + + // ── policies (direct IR — full control over what routes where) ──────────────── + + /// One fs rule over a real path, spelled the way the compiler would emit it — forward + /// slashes, which the backend re-nativizes. + fn rule(p: &Path, effect: Effect, access: FsAccess) -> FsRule { + FsRule { + matcher: CanonGlob(p.to_string_lossy().replace('\\', "/")), + effect, + access, + } + } + + /// The agent-sandbox fs shape this backend exists for: one granted tree with a secret + /// DENIED inside it. The deny landing under the grant is exactly what `deny_shadows_grant` + /// detects, and that is what routes the policy off the AppContainer allowlist. + fn secret_in_grant(f: &Fixture) -> SandboxPolicy { + SandboxPolicy { + fs: FsPolicy { + rules: FsRuleSet { + entries: vec![ + rule(&f.work, Effect::Allow, FsAccess::ReadWrite), + rule(&f.secret, Effect::Deny, FsAccess::DENY), + ], + default_effect: Effect::Deny, + }, + tmp: TmpMode::Shared, + }, + net: NetPolicy::default(), + env: EnvPolicy::default(), + pid: PidPolicy::default(), + } + } + + /// The same fs shape plus coarse egress-deny. The account is fenced by the persistent WFP + /// filters either way; enforcing net states the intent the egress cases are testing. + fn net_denied(f: &Fixture) -> SandboxPolicy { + let mut p = secret_in_grant(f); + p.net = NetPolicy { + enforce: true, + default_effect: Effect::Deny, + ..Default::default() + }; + p + } + + /// NOT sandboxed at all: `fs_confines` is false and net is off, so `apply` spawns a plain + /// child as the INVOKING user. Every negative control rides this — it is what proves the + /// target is genuinely reachable, so the confined run's failure can only be confinement. + fn unconfined() -> SandboxPolicy { + SandboxPolicy { + fs: FsPolicy { + rules: FsRuleSet { + entries: Vec::new(), + default_effect: Effect::Allow, + }, + tmp: TmpMode::Shared, + }, + ..Default::default() + } + } + + // ── run helpers ─────────────────────────────────────────────────────────────── + + fn code(f: &Fixture, policy: &SandboxPolicy, args: &[&str]) -> i32 { + // The cwd is PINNED to the granted dir: `CreateProcessWithLogonW` resolves the working + // directory under the CHILD's token, and nub's own cwd is unreachable to the account, + // so an unpinned cwd fails the spawn outright — a failure that is not the case under + // test. `apply` folds the cwd into the read grants for us. + let spec = CommandSpec::new(f.child.as_os_str()) + .args(args.iter().copied()) + .cwd(&f.work); + let prepared = match apply(policy, spec) { + Ok(p) => p, + Err(d) => { + eprintln!(" [apply Err] {d:?}"); + return -100; + } + }; + match prepared.status() { + Ok(s) => s.code().unwrap_or(-1), + Err(e) => { + eprintln!(" [status Err] {e} os={:?}", e.raw_os_error()); + -101 + } + } + } + + fn expect(fails: &mut u32, label: &str, got: i32, want: i32) { + if got == want { + println!("PASS {label} (exit {got})"); + } else { + *fails += 1; + eprintln!("FAIL {label}: exit {got}, expected {want}"); + } + } + + /// A live loopback listener the RUNNER owns, so a connect failure can only be the fence + /// and never a dead endpoint. + fn listen(port: u16) -> Option { + let l = TcpListener::bind(("127.0.0.1", port)).ok()?; + let acceptor = l.try_clone().ok()?; + std::thread::spawn(move || { + for c in acceptor.incoming() { + drop(c); + } + }); + Some(l) + } + + /// A listener on an ephemeral port OUTSIDE the WFP-permitted window. The ephemeral range + /// overlaps the window, so a landing inside it is retried rather than silently testing the + /// permitted case. + fn listen_outside(window: (u16, u16)) -> Option<(TcpListener, u16)> { + for _ in 0..8 { + let l = TcpListener::bind(("127.0.0.1", 0)).ok()?; + let port = l.local_addr().ok()?.port(); + if port < window.0 || port > window.1 { + let acceptor = l.try_clone().ok()?; + std::thread::spawn(move || { + for c in acceptor.incoming() { + drop(c); + } + }); + return Some((l, port)); + } + } + None + } + + // ── preconditions ───────────────────────────────────────────────────────────── + + /// Only the healthy arm of `windows_admin::status()` prints this line, so its presence IS + /// the provisioning check and its value is the window the installed WFP permit covers. + fn parse_window(report: &str) -> Option<(u16, u16)> { + let line = report + .lines() + .find_map(|l| l.trim().strip_prefix("proxy port window: "))?; + let (low, high) = line.trim().split_once('-')?; + Some((low.trim().parse().ok()?, high.trim().parse().ok()?)) + } + + fn is_elevated() -> bool { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::Security::{ + GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + // SAFETY: standard token-query sequence; the buffer is exactly a TOKEN_ELEVATION. + unsafe { + let mut tok = std::ptr::null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut tok) == 0 { + return false; + } + let mut elev = TOKEN_ELEVATION { TokenIsElevated: 0 }; + let mut ret = 0u32; + let ok = GetTokenInformation( + tok, + TokenElevation, + std::ptr::from_mut(&mut elev).cast(), + std::mem::size_of::() as u32, + &mut ret, + ); + CloseHandle(tok); + ok != 0 && elev.TokenIsElevated != 0 + } + } + + // ── the cases ───────────────────────────────────────────────────────────────── + + pub fn run() -> i32 { + let report = windows_admin::status() + .unwrap_or_else(|e| format!("sandbox account: status unavailable ({e})\n")); + print!("{report}"); + + let mut provisioned_here = false; + let mut window = parse_window(&report); + if window.is_none() { + if !is_elevated() { + eprintln!( + "\nABORT: this machine has no provisioned nub sandbox account, so there is \ + nothing to probe.\n Run this ONCE from an elevated (Run as \ + administrator) prompt:\n\n nub run --sandbox-admin setup\n\n then \ + re-run this probe. (Run the probe itself elevated and it will provision + \ + tear down for you.)" + ); + return 2; + } + println!( + "ELEVATED and un-provisioned: running windows_admin::setup(None) for this probe; \ + it is torn down again at the end." + ); + match windows_admin::setup(None) { + Ok(sid) => { + provisioned_here = true; + println!("provisioned the sandbox account ({sid})"); + } + Err(e) => { + eprintln!("ABORT: sandbox setup failed: {e}"); + return 2; + } + } + let after = windows_admin::status().unwrap_or_default(); + print!("{after}"); + window = parse_window(&after); + } + + let mut fails = match window { + Some(w) => run_cases(w).unwrap_or_else(|e| { + eprintln!("ABORT: fixture setup failed: {e}"); + u32::MAX + }), + None => { + eprintln!("ABORT: could not read the WFP-permitted proxy port window from status"); + u32::MAX + } + }; + + if provisioned_here { + match windows_admin::teardown() { + Ok(()) => println!("tore down the sandbox account this probe provisioned"), + Err(e) => { + fails = fails.saturating_add(1); + eprintln!("FAIL teardown: the machine is LEFT PROVISIONED by this probe — {e}"); + } + } + } + + if fails == 0 { + println!("ALL WINDOWS ACCOUNT ENFORCEMENT PROBES PASSED"); + 0 + } else { + eprintln!("{fails} WINDOWS ACCOUNT ENFORCEMENT PROBE(S) FAILED"); + 1 + } + } + + fn run_cases(window: (u16, u16)) -> std::io::Result { + let f = Fixture::new()?; + let mut fails = 0u32; + let confine = secret_in_grant(&f); + let open = unconfined(); + let deny_net = net_denied(&f); + let secret = f.secret.to_string_lossy().into_owned(); + let ok = f.ok.to_string_lossy().into_owned(); + let inside = f.work.join("w.txt").to_string_lossy().into_owned(); + let outside = f.outside.join("w.txt").to_string_lossy().into_owned(); + let go = |p: &SandboxPolicy, args: &[&str]| code(&f, p, args); + + expect( + &mut fails, + "the child runs AS the dedicated sandbox account, not the invoking user", + go(&confine, &["__acctchild__", "whoami", ACCOUNT]), + 0, + ); + + // ── 1. secret denied INSIDE a granted tree (the case AppContainer cannot express) ─ + expect( + &mut fails, + "KEY: the denied secret inside the granted tree is UNREADABLE", + go(&confine, &["__acctchild__", "read", &secret]), + 5, + ); + expect( + &mut fails, + "NC same run, same tree: the granted file still reads (the deny is surgical, not a dead grant)", + go(&confine, &["__acctchild__", "read", &ok]), + 0, + ); + expect( + &mut fails, + "NC unconfined: the secret is readable absent the sandbox (the file is not simply broken)", + go(&open, &["__acctchild__", "read", &secret]), + 0, + ); + + // ── 2. writes jailed to the granted tree ───────────────────────────────── + expect( + &mut fails, + "write inside the granted tree succeeds", + go(&confine, &["__acctchild__", "write", &inside]), + 0, + ); + expect( + &mut fails, + "write to the UNGRANTED sibling dir is denied", + go(&confine, &["__acctchild__", "write", &outside]), + 5, + ); + expect( + &mut fails, + "NC unconfined: the sibling dir is writable absent the sandbox", + go(&open, &["__acctchild__", "write", &outside]), + 0, + ); + + // ── 3 + 4. egress fence, and that a surrogate spawn does not escape it ──── + match listen_outside(window) { + None => { + fails += 1; + eprintln!( + "FAIL egress setup: no ephemeral loopback port outside the {}-{} window", + window.0, window.1 + ); + } + Some((_listener, port)) => { + let p = port.to_string(); + expect( + &mut fails, + "egress to a loopback endpoint OUTSIDE the permitted window is blocked (WSAEACCES)", + go(&deny_net, &["__acctchild__", "connect", "127.0.0.1", &p]), + 5, + ); + expect( + &mut fails, + "NC unconfined: the same endpoint is reachable (the target is live; the block is the fence)", + go(&open, &["__acctchild__", "connect", "127.0.0.1", &p]), + 0, + ); + expect( + &mut fails, + "a GRANDCHILD the sandboxed child spawns is fenced too (the filter keys on the SID, not the process)", + go( + &deny_net, + &["__acctchild__", "spawnconnect", "127.0.0.1", &p], + ), + 5, + ); + expect( + &mut fails, + "NC unconfined: the same grandchild reaches the endpoint", + go(&open, &["__acctchild__", "spawnconnect", "127.0.0.1", &p]), + 0, + ); + } + } + + // ── 5. the proxy window is genuinely open (else per-host net is unreachable) ─ + match (window.0..=window.1).find_map(listen) { + Some(listener) => { + let p = listener.local_addr()?.port().to_string(); + expect( + &mut fails, + "a listener INSIDE the permitted window IS reachable from the sandboxed child", + go(&deny_net, &["__acctchild__", "connect", "127.0.0.1", &p]), + 0, + ); + } + None => eprintln!( + "SKIP proxy-window reachability — every port in the {}-{} window is already bound, \ + so this property was NOT verified on this run", + window.0, window.1 + ), + } + + Ok(fails) + } +} diff --git a/crates/nub-sandbox/tests/windows_enforcement.rs b/crates/nub-sandbox/tests/windows_enforcement.rs index 8339458f4..65f9ce57e 100644 --- a/crates/nub-sandbox/tests/windows_enforcement.rs +++ b/crates/nub-sandbox/tests/windows_enforcement.rs @@ -606,6 +606,7 @@ mod win { enforce: true, rules: Vec::new(), default_effect: Effect::Deny, + ..Default::default() }; expect_in( &mut fails,