diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index abd048c73c..283f36e784 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -40,6 +40,97 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; +mod token_probe { + #![allow(unsafe_code)] + + use std::ffi::c_void; + use std::ptr; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> isize; + fn CloseHandle(handle: isize) -> i32; + fn GetLastError() -> u32; + } + + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken(process: isize, access: u32, token: *mut isize) -> i32; + fn GetTokenInformation( + token: isize, + class: i32, + info: *mut c_void, + len: u32, + return_len: *mut u32, + ) -> i32; + fn OpenSCManagerW(machine: *const u16, database: *const u16, access: u32) -> isize; + fn CloseServiceHandle(handle: isize) -> i32; + } + + fn token_u32(token: isize, class: i32) -> Option { + let mut value = 0u32; + let mut returned = 0u32; + let ok = unsafe { + GetTokenInformation( + token, + class, + (&raw mut value).cast(), + u32::try_from(size_of::()).expect("u32 size fits Win32 length"), + &raw mut returned, + ) + }; + (ok != 0).then_some(value) + } + + fn has_appcontainer_sid(token: isize) -> bool { + let mut buf = [0u8; 256]; + let mut returned = 0u32; + let ok = unsafe { + GetTokenInformation( + token, + 31, // TokenAppContainerSid + buf.as_mut_ptr().cast(), + u32::try_from(buf.len()).expect("token buffer length fits u32"), + &raw mut returned, + ) + }; + ok != 0 && !unsafe { ptr::read_unaligned(buf.as_ptr().cast::<*const c_void>()) }.is_null() + } + + fn service_manager_create_access() -> (bool, u32) { + let manager = unsafe { + OpenSCManagerW( + ptr::null(), + ptr::null(), + 0x0002, // SC_MANAGER_CREATE_SERVICE + ) + }; + if manager == 0 { + return (false, unsafe { GetLastError() }); + } + unsafe { CloseServiceHandle(manager) }; + (true, 0) + } + + pub fn snapshot() -> serde_json::Value { + let mut token = 0isize; + let opened = unsafe { OpenProcessToken(GetCurrentProcess(), 0x0008, &raw mut token) }; + assert_ne!(opened, 0, "OpenProcessToken failed"); + + let (can_create_service, create_service_error) = service_manager_create_access(); + let snapshot = serde_json::json!({ + "is_appcontainer": token_u32(token, 29), // TokenIsAppContainer + "has_appcontainer_sid": has_appcontainer_sid(token), + "can_create_service": can_create_service, + "create_service_error": create_service_error, + }); + unsafe { CloseHandle(token) }; + snapshot + } +} + +const TOKEN_PROBE_MARKER: &str = "OPENSHELL_MXC_TOKEN_PROBE="; + // ── Path resolution ────────────────────────────────────────────────────────── /// Resolve the path to `wxc-exec.exe`. @@ -477,6 +568,17 @@ fn dryrun_accepts_split_policy_output() { // These skip on this box (processcontainer velocity keys not enabled; // isolation_session backend absent). They PASS where backends are live. +/// Entrypoint used by `pc_oneshot_token_is_appcontainer_without_admin_access`. +/// The parent test relaunches this integration-test binary inside MXC so the +/// probe observes the workload token rather than the host test runner's token. +#[test] +fn child_token_probe_entry() { + if std::env::var("OPENSHELL_MXC_CHILD_TOKEN_PROBE").as_deref() != Ok("1") { + return; + } + println!("{TOKEN_PROBE_MARKER}{}", token_probe::snapshot()); +} + /// Probe the processcontainer backend. /// /// Runs a trivial one-shot (`cmd /c exit 0`, user-owned temp grant). Returns @@ -801,6 +903,94 @@ fn pc_oneshot_in_policy_write_succeeds() { ); } +/// Verify the default `ProcessContainer` token and an administrator-gated +/// access attempt. `whoami /all` is not sufficient for this assertion: the +/// package SID is exposed through `TokenAppContainerSid`, and `AppContainer` +/// access is the intersection of the user/group and package/capability grants. +#[test] +#[ignore = "requires real wxc-exec"] +fn pc_oneshot_token_is_appcontainer_without_admin_access() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + + let (tempdir, temp_path) = temp_fixture(); + let test_exe = std::env::current_exe().expect("resolve integration-test executable"); + let test_exe_parent = test_exe + .parent() + .expect("integration-test executable has a parent") + .to_string_lossy() + .into_owned(); + let command_line = format!( + "\"{}\" --exact child_token_probe_entry --nocapture", + test_exe.display() + ); + let mut child_env = vec!["OPENSHELL_MXC_CHILD_TOKEN_PROBE=1".to_string()]; + child_env.extend( + ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"] + .into_iter() + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| format!("{key}={value}")) + }), + ); + let config = serde_json::json!({ + "version": "0.8.0-alpha", + "containerId": "pc-token-identity", + "containment": "processcontainer", + "process": { + "commandLine": command_line, + "cwd": temp_path, + "env": child_env, + "timeout": 30_000, + }, + "filesystem": { + "readwritePaths": [temp_path], + "readonlyPaths": [test_exe_parent], + }, + "processContainer": { + "leastPrivilege": false, + }, + "ui": { + "disable": false, + "clipboard": "none", + "injection": false, + }, + }); + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .expect("wxc-exec token probe spawn"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + assert!( + out.status.success(), + "token probe should exit successfully\nstdout={stdout}\nstderr={stderr}" + ); + let snapshot: serde_json::Value = stdout + .lines() + .find_map(|line| line.trim().strip_prefix(TOKEN_PROBE_MARKER)) + .map_or_else( + || panic!("token probe marker missing\nstdout={stdout}\nstderr={stderr}"), + |value| serde_json::from_str(value).expect("parse token probe JSON"), + ); + + assert_eq!(snapshot["is_appcontainer"], 1); + assert_eq!(snapshot["has_appcontainer_sid"], true); + assert_eq!(snapshot["can_create_service"], false); + assert_eq!(snapshot["create_service_error"], 5); // ERROR_ACCESS_DENIED + drop(tempdir); +} + /// Run an HTTPS request through the real driver and `ProcessContainer`. The /// workload explicitly reads the injected bundle before curl uses it, proving /// that the driver's internal TLS share is reachable from the `AppContainer`. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 44de908678..a292cb030c 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -949,6 +949,17 @@ debug = false etw_audit = false ``` +The default `pc_least_privilege = false` still runs the workload with an +AppContainer token. Set it to `true` to request the stricter Less Privileged +AppContainer (LPAC) variant. Windows AppContainer tokens retain the launching +account's user SID and group SIDs. Seeing the same username or +`BUILTIN\\Administrators` in `whoami /all` therefore does not mean the sandbox +can exercise administrator access: Windows +requires both the user/group and AppContainer package/capability sides of an +access check to grant the requested operation. The MXC real-binary integration +suite verifies `TokenIsAppContainer`, `TokenAppContainerSid`, and denial of a +non-mutating administrator-gated Service Control Manager open. + Set `egress_proxy = true` with `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern C split. The address must be a `127.0.0.1:PORT` socket. The driver allocates a unique ephemeral port per sandbox, injects that listener through proxy environment variables, and stages the public proxy CA beneath the sandbox's configured `/.openshell-proxy//`. A non-empty per-sandbox `cwd` is therefore required when governed egress is enabled; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback. MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not receive the gateway middleware registry.