diff --git a/.github/workflows/windows-platform.yml b/.github/workflows/windows-platform.yml index 20db173cf..d09e288d5 100644 --- a/.github/workflows/windows-platform.yml +++ b/.github/workflows/windows-platform.yml @@ -131,6 +131,8 @@ jobs: run: cargo test --locked -p semwright-driver-host --test windows_secure_host -- secure_windows_driver_workspace --nocapture - name: Secure Windows Driver secret grants run: cargo test --locked -p semwright-driver-host --test windows_secure_host -- secure_windows_driver_secret --nocapture + - name: Secure Windows Driver loopback bridge + run: cargo test --locked -p semwright-driver-host --test windows_secure_host -- secure_windows_driver_loopback --nocapture - name: Secure Windows Plugin Host round-trip run: cargo test --locked -p semwright-plugin-host --features test-tools --test windows_secure_host -- --nocapture - name: Secure Windows external MCP round-trip diff --git a/crates/driver-godot/src/bridge.rs b/crates/driver-godot/src/bridge.rs index 48151dae8..aee7f3067 100644 --- a/crates/driver-godot/src/bridge.rs +++ b/crates/driver-godot/src/bridge.rs @@ -20,6 +20,8 @@ use std::{ }; #[cfg(unix)] use tokio::net::UnixListener; +#[cfg(windows)] +use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient}; use tokio::{ io::{AsyncRead, AsyncWrite}, net::TcpListener, @@ -195,6 +197,42 @@ pub struct Bridge { stop: CancellationToken, } +#[cfg(windows)] +async fn connect_loopback_pipe(path: &str, stop: &CancellationToken) -> Result { + if !path.starts_with(r"\\.\pipe\semwright-loopback-") || path.len() > 256 { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Godot loopback pipe path is not Host-controlled", + )); + } + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + if stop.is_cancelled() { + return Err(Error::new(ErrorCode::Cancelled, "Godot loopback stopped")); + } + match ClientOptions::new().open(path) { + Ok(pipe) => return Ok(pipe), + Err(error) + if (matches!( + error.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::PermissionDenied + | std::io::ErrorKind::WouldBlock + ) || error.raw_os_error() == Some(231)) + && tokio::time::Instant::now() < deadline => + { + tokio::select! { + _ = stop.cancelled() => { + return Err(Error::new(ErrorCode::Cancelled, "Godot loopback stopped")); + } + _ = tokio::time::sleep(Duration::from_millis(20)) => {} + } + } + Err(error) => return Err(error.into()), + } + } +} + impl Bridge { pub async fn start( port: u16, @@ -244,6 +282,44 @@ impl Bridge { return Ok(bridge); } + #[cfg(windows)] + if let Ok(pipe_path) = std::env::var("SEMWRIGHT_DRIVER_LOOPBACK_PIPE") { + if !pipe_path.starts_with(r"\\.\pipe\semwright-loopback-") || pipe_path.len() > 256 { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Godot loopback pipe path is not Host-controlled", + )); + } + let pipe_stop = stop.clone(); + let pipe_sessions = sessions.clone(); + let pipe_projects = projects.clone(); + let pipe_events = events.clone(); + tokio::spawn(async move { + loop { + let pipe = tokio::select! { + _ = pipe_stop.cancelled() => break, + result = connect_loopback_pipe(&pipe_path, &pipe_stop) => { + let Ok(pipe) = result else { + if pipe_stop.is_cancelled() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + continue; + }; + pipe + } + }; + let sessions = pipe_sessions.clone(); + let projects = pipe_projects.clone(); + let events = pipe_events.clone(); + tokio::spawn(async move { + let _ = serve_connection(pipe, sessions, projects, events).await; + }); + } + }); + return Ok(bridge); + } + let listener = TcpListener::bind(("127.0.0.1", port)).await?; tokio::spawn(async move { loop { diff --git a/crates/driver-host/src/bin/fixture.rs b/crates/driver-host/src/bin/fixture.rs index 9331eca61..f84102754 100644 --- a/crates/driver-host/src/bin/fixture.rs +++ b/crates/driver-host/src/bin/fixture.rs @@ -7,6 +7,11 @@ use semwright_types::{ CommandDescriptor, Error, ErrorCode, Idempotency, JobArtifact, JobProgress, Result, Risk, }; use serde_json::{Value, json}; +#[cfg(windows)] +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::windows::named_pipe::ClientOptions, +}; fn capability() -> Capability { Capability { @@ -370,8 +375,56 @@ impl Driver for Fixture { } } +#[cfg(windows)] +async fn loopback_echo_connection( + mut pipe: tokio::net::windows::named_pipe::NamedPipeClient, +) -> std::io::Result<()> { + let mut buffer = [0u8; 4096]; + loop { + let read = pipe.read(&mut buffer).await?; + if read == 0 { + return Ok(()); + } + pipe.write_all(&buffer[..read]).await?; + pipe.flush().await?; + } +} + +#[cfg(windows)] +async fn loopback_echo_task(path: String) { + if !path.starts_with(r"\\.\pipe\semwright-loopback-") || path.len() > 256 { + return; + } + loop { + match ClientOptions::new().open(&path) { + Ok(pipe) => { + tokio::spawn(async move { + let _ = loopback_echo_connection(pipe).await; + }); + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::PermissionDenied + | std::io::ErrorKind::WouldBlock + ) || error.raw_os_error() == Some(231) => + { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + Err(_) => { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } + } +} + #[tokio::main(flavor = "current_thread")] async fn main() { + #[cfg(windows)] + if let Ok(path) = std::env::var("SEMWRIGHT_DRIVER_LOOPBACK_PIPE") { + tokio::spawn(loopback_echo_task(path)); + } if let Err(error) = serve(Fixture).await { eprintln!("{error}"); std::process::exit(error.exit_code()); diff --git a/crates/driver-host/src/lib.rs b/crates/driver-host/src/lib.rs index e3e1c75b2..34274bb22 100644 --- a/crates/driver-host/src/lib.rs +++ b/crates/driver-host/src/lib.rs @@ -1,5 +1,5 @@ //! Sandboxed persistent application-driver host. Drivers are mounted into the broker as Providers. -#[cfg(unix)] +#[cfg(any(unix, windows))] mod loopback; use async_trait::async_trait; @@ -794,6 +794,7 @@ fn sandbox_spec_windows( staged: &Path, helper: &Path, roots: &[FilesystemGrant], + loopback_pipe: Option<&Path>, ) -> Result { use semwright_platform_api::launch::{ Mount, MountClass, ResourceLimits, SandboxKind, SandboxSpec, @@ -805,12 +806,6 @@ fn sandbox_spec_windows( "Windows tool grants remain fail-closed until their immutable-executable contract is proven", )); } - if manifest.loopback_port.is_some() { - return Err(Error::new( - ErrorCode::SandboxDenied, - "Windows driver loopback remains fail-closed until a non-Unix bridge is implemented", - )); - } let lookup = |name: &str| -> Result<&FilesystemGrant> { roots .iter() @@ -876,13 +871,21 @@ fn sandbox_spec_windows( }) .collect::>>()?, ); + let environment = loopback_pipe + .map(|path| { + path.to_str() + .ok_or_else(|| Error::invalid("Windows loopback pipe path must be Unicode")) + .map(|value| vec![(loopback::SANDBOX_PIPE_ENV.into(), value.to_owned())]) + }) + .transpose()? + .unwrap_or_default(); Ok(SandboxSpec { kind: SandboxKind::Driver, staged_executable: staged.into(), helper: helper.into(), mounts, args: vec![], - environment: vec![], + environment, sealed_tools: vec![], network: manifest.network, limits: Some(ResourceLimits { @@ -909,7 +912,7 @@ pub struct DriverProvider { cpu_accounting: Option>, operation_cpu_gate: Mutex<()>, _staged: Arc, - #[cfg(unix)] + #[cfg(any(unix, windows))] _loopback: Option>, #[cfg(unix)] _tools: Vec, @@ -941,11 +944,27 @@ impl DriverProvider { // Bind trust checks to the exact staged file that will execute. let _ = verify_owned_executable(&staged_path, &manifest.sha256)?; - let spec = sandbox_spec_windows(&manifest, &staged_path, helper, roots)?; + let loopback = match manifest.loopback_port { + Some(port) => Some(loopback::start(state, port).await?), + None => None, + }; + let spec = sandbox_spec_windows( + &manifest, + &staged_path, + helper, + roots, + loopback.as_deref().map(loopback::LoopbackProxy::pipe_path), + )?; let mut child = semwright_platform_services::sandbox_spawn(&spec)?; let process_id = child .id() .ok_or_else(|| Error::new(ErrorCode::Internal, "Driver child has no PID"))?; + if let Some(proxy) = &loopback + && let Err(error) = proxy.activate(process_id).await + { + let _ = child.kill().await; + return Err(error); + } let cpu_accounting = child.cpu_accounting(); #[cfg(target_os = "windows")] if manifest.resources.operation_cpu_seconds != 0 && cpu_accounting.is_none() { @@ -1113,6 +1132,7 @@ impl DriverProvider { let monitor_closed = closed.clone(); let monitor_terminate = terminate.clone(); let monitor_staged = staged.clone(); + let monitor_loopback = loopback.clone(); tokio::spawn(async move { tokio::select! { _ = monitor_terminate.cancelled() => { @@ -1120,6 +1140,9 @@ impl DriverProvider { } _ = child.wait() => {} } + if let Some(proxy) = monitor_loopback { + proxy.shutdown(); + } monitor_closed.cancel(); drop(monitor_staged); }); @@ -1138,6 +1161,7 @@ impl DriverProvider { cpu_accounting, operation_cpu_gate: Mutex::new(()), _staged: staged, + _loopback: loopback, })) } #[cfg(unix)] @@ -1352,6 +1376,7 @@ impl DriverProvider { let monitor_closed = closed.clone(); let monitor_terminate = terminate.clone(); let monitor_staged = staged.clone(); + let monitor_loopback = loopback.clone(); tokio::spawn(async move { tokio::select! { _ = monitor_terminate.cancelled() => { @@ -1359,6 +1384,9 @@ impl DriverProvider { } _ = child.wait() => {} } + if let Some(proxy) = monitor_loopback { + proxy.shutdown(); + } monitor_closed.cancel(); drop(monitor_staged); }); diff --git a/crates/driver-host/src/loopback.rs b/crates/driver-host/src/loopback.rs index 8172b0a65..16d811027 100644 --- a/crates/driver-host/src/loopback.rs +++ b/crates/driver-host/src/loopback.rs @@ -1,37 +1,170 @@ use semwright_types::{Error, ErrorCode, Result, unique_id}; +#[cfg(windows)] +use std::sync::Mutex as StdMutex; use std::{ path::{Path, PathBuf}, sync::Arc, time::Duration, }; +#[cfg(unix)] +use tokio::net::UnixStream; +#[cfg(windows)] +use tokio::net::windows::named_pipe::NamedPipeServer; use tokio::{ - net::{TcpListener, TcpStream, UnixStream}, + net::{TcpListener, TcpStream}, sync::Semaphore, }; use tokio_util::sync::CancellationToken; +#[cfg(unix)] pub(crate) const MOUNT_NAME: &str = "semwright-internal-loopback"; +#[cfg(unix)] pub(crate) const SANDBOX_SOCKET: &str = "/workspace/semwright-internal-loopback/bridge.sock"; +#[cfg(windows)] +pub(crate) const SANDBOX_PIPE_ENV: &str = "SEMWRIGHT_DRIVER_LOOPBACK_PIPE"; pub(crate) struct LoopbackProxy { stop: CancellationToken, + #[cfg(unix)] directory: PathBuf, + #[cfg(windows)] + pipe_path: PathBuf, + #[cfg(windows)] + reserved_pipe: StdMutex>, + #[cfg(windows)] + listener: StdMutex>, +} + +#[cfg(windows)] +async fn connect_expected_pipe( + server: &mut NamedPipeServer, + expected_pid: u32, + stop: &CancellationToken, +) -> Result<()> { + let connected = tokio::select! { + _ = stop.cancelled() => { + return Err(Error::new(ErrorCode::Cancelled, "Loopback proxy stopped")); + } + result = tokio::time::timeout(Duration::from_secs(5), server.connect()) => result, + }; + match connected { + Ok(Ok(())) => {} + Ok(Err(_)) => { + return Err(Error::new( + ErrorCode::Unavailable, + "Driver loopback pipe connection failed", + )); + } + Err(_) => { + return Err(Error::new( + ErrorCode::Timeout, + "Driver loopback pipe connection timed out", + )); + } + } + semwright_platform_services::validate_windows_appcontainer_loopback_peer(server, expected_pid) } impl LoopbackProxy { + pub(crate) fn shutdown(&self) { + self.stop.cancel(); + } + + #[cfg(unix)] pub(crate) fn directory(&self) -> &Path { &self.directory } + + #[cfg(windows)] + pub(crate) fn pipe_path(&self) -> &Path { + &self.pipe_path + } + + #[cfg(windows)] + pub(crate) async fn activate(self: &Arc, expected_pid: u32) -> Result<()> { + let listener = self + .listener + .lock() + .map_err(|_| Error::new(ErrorCode::Internal, "Loopback listener lock poisoned"))? + .take() + .ok_or_else(|| Error::new(ErrorCode::Conflict, "Loopback proxy already activated"))?; + let pipe_path = self.pipe_path.clone(); + let mut server = self + .reserved_pipe + .lock() + .map_err(|_| Error::new(ErrorCode::Internal, "Reserved loopback pipe lock poisoned"))? + .take() + .ok_or_else(|| Error::new(ErrorCode::Conflict, "Loopback pipe already activated"))?; + // The first Host-owned pipe instance is created before the LPAC child starts with an + // owner/SYSTEM-only DACL. Once the kernel PID is known, grant only that child package + // SID on the existing instance. The unqualified Win32 pipe namespace is intentional: + // this LPAC is launched manually rather than from an MSIX package namespace. + // Authorization still fails closed before any child traffic can connect. + semwright_platform_services::windows_authorize_appcontainer_loopback_server( + &server, + expected_pid, + )?; + connect_expected_pipe(&mut server, expected_pid, &self.stop).await?; + + let stop = self.stop.clone(); + let semaphore = Arc::new(Semaphore::new(16)); + tokio::spawn(async move { + loop { + let accepted = tokio::select! { + _ = stop.cancelled() => break, + accepted = listener.accept() => accepted, + }; + let Ok((tcp, address)) = accepted else { + continue; + }; + if !address.ip().is_loopback() { + continue; + } + let Ok(permit) = semaphore.clone().try_acquire_owned() else { + continue; + }; + + let connected_pipe = server; + let connection_stop = stop.clone(); + tokio::spawn(async move { + let _permit = permit; + let _ = proxy_pipe_connection(tcp, connected_pipe, connection_stop).await; + }); + + let mut next = + match semwright_platform_services::windows_appcontainer_loopback_server( + &pipe_path, + expected_pid, + false, + ) { + Ok(next) => next, + Err(_) => break, + }; + if connect_expected_pipe(&mut next, expected_pid, &stop) + .await + .is_err() + { + break; + } + server = next; + } + }); + Ok(()) + } } impl Drop for LoopbackProxy { fn drop(&mut self) { self.stop.cancel(); - let _ = std::fs::remove_file(self.directory.join("bridge.sock")); - let _ = std::fs::remove_dir(&self.directory); + #[cfg(unix)] + { + let _ = std::fs::remove_file(self.directory.join("bridge.sock")); + let _ = std::fs::remove_dir(&self.directory); + } } } +#[cfg(unix)] async fn connect_private_socket( socket_path: &Path, stop: &CancellationToken, @@ -66,7 +199,8 @@ async fn connect_private_socket( } } -async fn proxy_connection( +#[cfg(unix)] +async fn proxy_unix_connection( mut tcp: TcpStream, socket_path: PathBuf, stop: CancellationToken, @@ -81,6 +215,22 @@ async fn proxy_connection( Ok(()) } +#[cfg(windows)] +async fn proxy_pipe_connection( + mut tcp: TcpStream, + mut pipe: NamedPipeServer, + stop: CancellationToken, +) -> Result<()> { + tokio::select! { + result = tokio::io::copy_bidirectional(&mut tcp, &mut pipe) => { + result?; + } + _ = stop.cancelled() => {} + } + Ok(()) +} + +#[cfg(unix)] pub(crate) async fn start(state: &Path, port: u16) -> Result> { let listener = TcpListener::bind(("127.0.0.1", port)).await.map_err(|_| { Error::new( @@ -101,7 +251,7 @@ pub(crate) async fn start(state: &Path, port: u16) -> Result> tokio::select! { _ = task_stop.cancelled() => break, accepted = listener.accept() => { - let Ok((tcp, address)) = accepted else { continue }; + let Ok((tcp, address)) = accepted else { continue; }; if !address.ip().is_loopback() { continue; } @@ -112,7 +262,7 @@ pub(crate) async fn start(state: &Path, port: u16) -> Result> let connection_stop = task_stop.clone(); tokio::spawn(async move { let _permit = permit; - let _ = proxy_connection(tcp, socket_path, connection_stop).await; + let _ = proxy_unix_connection(tcp, socket_path, connection_stop).await; }); } } @@ -121,3 +271,25 @@ pub(crate) async fn start(state: &Path, port: u16) -> Result> Ok(Arc::new(LoopbackProxy { stop, directory })) } + +#[cfg(windows)] +pub(crate) async fn start(_state: &Path, port: u16) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", port)).await.map_err(|_| { + Error::new( + ErrorCode::Unavailable, + "Driver loopback port is unavailable", + ) + })?; + let pipe_path = PathBuf::from(format!(r"\\.\pipe\semwright-loopback-{}", unique_id())); + // Reserve the first Host-owned instance before the sandbox process is created. The + // initial DACL intentionally excludes AppContainer identities; activate() authorizes exactly + // the kernel-observed child SID after spawn. + let reserved_pipe = + semwright_platform_services::windows_reserve_appcontainer_loopback_server(&pipe_path)?; + Ok(Arc::new(LoopbackProxy { + stop: CancellationToken::new(), + pipe_path, + reserved_pipe: StdMutex::new(Some(reserved_pipe)), + listener: StdMutex::new(Some(listener)), + })) +} diff --git a/crates/driver-host/tests/windows_secure_host.rs b/crates/driver-host/tests/windows_secure_host.rs index 6ee3c0354..a75fe179b 100644 --- a/crates/driver-host/tests/windows_secure_host.rs +++ b/crates/driver-host/tests/windows_secure_host.rs @@ -9,6 +9,10 @@ use semwright_driver_sdk::{ use semwright_policy::FilesystemGrant; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, +}; use tokio_util::sync::CancellationToken; fn digest(path: &Path) -> String { @@ -124,6 +128,55 @@ async fn secure_windows_driver_host_roundtrips_protocol_v2() { .expect("secure Windows Driver Host shutdown"); } +fn free_loopback_port() -> u16 { + let listener = + std::net::TcpListener::bind(("127.0.0.1", 0)).expect("reserve loopback test port"); + let port = listener.local_addr().expect("loopback test address").port(); + drop(listener); + port +} + +#[tokio::test] +async fn secure_windows_driver_loopback_is_host_mediated_without_network_capability() { + let source = PathBuf::from(env!("CARGO_BIN_EXE_semwright-driver-fixture")); + let binary_dir = tempfile::tempdir().expect("fixture directory"); + let executable = binary_dir.path().join("driver.exe"); + std::fs::copy(&source, &executable).expect("copy driver fixture"); + harden_fixture(&executable); + + let port = free_loopback_port(); + let mut manifest = manifest(executable); + manifest.loopback_port = Some(port); + manifest.network = false; + + let state = tempfile::tempdir().expect("driver state"); + let helper = std::env::current_exe().expect("current test executable"); + let provider = DriverProvider::connect(manifest, state.path(), &helper, &[], false) + .await + .expect("Windows loopback Driver Host connection"); + + let mut client = TcpStream::connect(("127.0.0.1", port)) + .await + .expect("connect to Host-owned loopback proxy"); + client + .write_all(b"semwright-loopback") + .await + .expect("write Host loopback probe"); + let mut reply = [0u8; 18]; + tokio::time::timeout( + std::time::Duration::from_secs(5), + client.read_exact(&mut reply), + ) + .await + .expect("Host loopback round-trip timed out") + .expect("read Host loopback reply"); + assert_eq!(&reply, b"semwright-loopback"); + + Provider::shutdown(provider.as_ref()) + .await + .expect("loopback Driver Host shutdown"); +} + fn grant_all_application_packages_modify(path: &Path) { // S-1-15-2-1 is ALL APPLICATION PACKAGES. Granting Modify here creates the // adversarial broad-group allow that the per-AppContainer deny ACE must override. diff --git a/crates/platform-services/src/lib.rs b/crates/platform-services/src/lib.rs index a96a87919..df465b0e4 100644 --- a/crates/platform-services/src/lib.rs +++ b/crates/platform-services/src/lib.rs @@ -39,6 +39,42 @@ pub fn windows_pipe_client( ) -> Result { semwright_platform_windows_sys::pipe::open_tokio_client(path) } +#[cfg(target_os = "windows")] +pub fn windows_reserve_appcontainer_loopback_server( + path: &Path, +) -> Result { + semwright_platform_windows_sys::pipe::reserve_appcontainer_loopback_server(path) +} + +#[cfg(target_os = "windows")] +pub fn windows_authorize_appcontainer_loopback_server( + pipe: &tokio::net::windows::named_pipe::NamedPipeServer, + expected_pid: u32, +) -> Result<()> { + semwright_platform_windows_sys::pipe::authorize_appcontainer_loopback_server(pipe, expected_pid) +} + +#[cfg(target_os = "windows")] +pub fn windows_appcontainer_loopback_server( + path: &Path, + expected_pid: u32, + first: bool, +) -> Result { + semwright_platform_windows_sys::pipe::create_appcontainer_loopback_server( + path, + expected_pid, + first, + ) +} + +#[cfg(target_os = "windows")] +pub fn validate_windows_appcontainer_loopback_peer( + pipe: &tokio::net::windows::named_pipe::NamedPipeServer, + expected_pid: u32, +) -> Result<()> { + semwright_platform_windows_sys::pipe::validate_appcontainer_loopback_peer(pipe, expected_pid) +} + #[cfg(target_os = "windows")] pub fn validate_windows_server_peer( pipe: &tokio::net::windows::named_pipe::NamedPipeServer, diff --git a/crates/platform-windows-sys/src/identity.rs b/crates/platform-windows-sys/src/identity.rs index 2e0d84d61..a61fd6efe 100644 --- a/crates/platform-windows-sys/src/identity.rs +++ b/crates/platform-windows-sys/src/identity.rs @@ -3,7 +3,10 @@ use windows::{ Win32::{ Foundation::{CloseHandle, HANDLE, HLOCAL, LocalFree}, Security::Authorization::ConvertSidToStringSidW, - Security::{GetLengthSid, GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser}, + Security::{ + GetLengthSid, GetTokenInformation, TOKEN_APPCONTAINER_INFORMATION, TOKEN_QUERY, + TOKEN_USER, TokenAppContainerSid, TokenUser, + }, System::{ RemoteDesktop::ProcessIdToSessionId, Threading::{ @@ -63,14 +66,13 @@ fn token_user_sid(token: HANDLE) -> Result> { Ok(bytes) } -fn sid_string_from_token(token: HANDLE) -> Result { - let bytes = token_user_sid(token)?; - // TOKEN_USER is at the beginning of the returned buffer and points into that same live buffer. - // SAFETY: token/process handles and TOKEN_USER/SID backing storage are live and bounded here; access is query-only and pointer-backed bytes are copied before storage is dropped. - let user = unsafe { &*(bytes.as_ptr().cast::()) }; +fn sid_string(sid: windows::Win32::Security::PSID) -> Result { + if sid.is_invalid() { + return Err(Error::new(ErrorCode::BackendFailed, "SID is invalid")); + } let mut out = PWSTR::null(); - // SAFETY: User.Sid belongs to `bytes`, which remains live through conversion. - unsafe { ConvertSidToStringSidW(user.User.Sid, &mut out) } + // SAFETY: sid points to a live SID for the duration of the synchronous conversion. + unsafe { ConvertSidToStringSidW(sid, &mut out) } .map_err(|_| Error::new(ErrorCode::BackendFailed, "SID conversion failed"))?; if out.is_null() { return Err(Error::new( @@ -78,16 +80,24 @@ fn sid_string_from_token(token: HANDLE) -> Result { "SID conversion returned null", )); } - // SAFETY: token/process handles and TOKEN_USER/SID backing storage are live and bounded here; access is query-only and pointer-backed bytes are copied before storage is dropped. + // SAFETY: ConvertSidToStringSidW returns a NUL-terminated LocalAlloc-owned string. let value = unsafe { out.to_string() } .map_err(|_| Error::new(ErrorCode::BackendFailed, "SID string was invalid"))?; - // SAFETY: ConvertSidToStringSidW allocates this buffer with LocalAlloc. + // SAFETY: ConvertSidToStringSidW allocated this buffer with LocalAlloc. unsafe { let _ = LocalFree(Some(HLOCAL(out.0.cast()))); } Ok(value) } +fn sid_string_from_token(token: HANDLE) -> Result { + let bytes = token_user_sid(token)?; + // TOKEN_USER is at the beginning of the returned buffer and points into that same live buffer. + // SAFETY: token/process handles and TOKEN_USER/SID backing storage are live and bounded here; access is query-only and pointer-backed bytes are copied before storage is dropped. + let user = unsafe { &*(bytes.as_ptr().cast::()) }; + sid_string(user.User.Sid) +} + pub fn current_user_sid_bytes() -> Result> { let token = current_token()?; let bytes = token_user_sid(token.0)?; @@ -145,6 +155,65 @@ pub fn process_user_sid_bytes(pid: u32) -> Result> { Ok(unsafe { std::slice::from_raw_parts(user.User.Sid.0.cast::(), len) }.to_vec()) } +pub fn process_appcontainer_sid(pid: u32) -> Result { + // SAFETY: query-only access to an existing process; no mutation or inheritance rights. + let process = + unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.map_err(|_| { + Error::new( + ErrorCode::PermissionDenied, + "AppContainer process cannot be queried", + ) + })?; + let process = OwnedHandle(process); + let mut token = HANDLE::default(); + // SAFETY: query-only token access for the live process handle. + unsafe { OpenProcessToken(process.0, TOKEN_QUERY, &mut token) }.map_err(|_| { + Error::new( + ErrorCode::PermissionDenied, + "AppContainer process token cannot be queried", + ) + })?; + let token = OwnedHandle(token); + + let mut needed = 0u32; + // SAFETY: this query intentionally passes no output buffer so Windows reports the + // required TOKEN_APPCONTAINER_INFORMATION size in `needed`; token is live and query-only. + let _ = unsafe { GetTokenInformation(token.0, TokenAppContainerSid, None, 0, &mut needed) }; + if needed == 0 || needed > 65_536 { + return Err(Error::new( + ErrorCode::PermissionDenied, + "AppContainer SID information is unavailable", + )); + } + let mut storage = vec![0u8; needed as usize]; + // SAFETY: storage is a writable buffer of the exact size requested by Windows. + unsafe { + GetTokenInformation( + token.0, + TokenAppContainerSid, + Some(storage.as_mut_ptr().cast()), + needed, + &mut needed, + ) + } + .map_err(|_| { + Error::new( + ErrorCode::PermissionDenied, + "AppContainer SID lookup failed", + ) + })?; + // SAFETY: TOKEN_APPCONTAINER_INFORMATION starts at the beginning of storage and + // points into token-owned SID memory that stays valid while the token handle is live. + let information = unsafe { &*(storage.as_ptr().cast::()) }; + if information.TokenAppContainer.is_invalid() { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Process is not running inside an AppContainer", + )); + } + sid_string(information.TokenAppContainer) +} + pub fn current_session_id() -> Result { let mut session = 0u32; // SAFETY: output storage is valid; the PID is the live current process. diff --git a/crates/platform-windows-sys/src/pipe.rs b/crates/platform-windows-sys/src/pipe.rs index 7bc4fed9b..7bc6bc743 100644 --- a/crates/platform-windows-sys/src/pipe.rs +++ b/crates/platform-windows-sys/src/pipe.rs @@ -141,15 +141,19 @@ use windows::{ Foundation::{HLOCAL, LocalFree}, Security::Authorization::{ ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + SE_KERNEL_OBJECT, SetSecurityInfo, + }, + Security::{ + ACL, DACL_SECURITY_INFORMATION, GetSecurityDescriptorDacl, PSECURITY_DESCRIPTOR, + SECURITY_ATTRIBUTES, }, - Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}, Storage::FileSystem::{FILE_FLAG_FIRST_PIPE_INSTANCE, PIPE_ACCESS_DUPLEX}, System::Pipes::{ CreateNamedPipeW, PIPE_READMODE_MESSAGE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_MESSAGE, PIPE_WAIT, }, }, - core::HSTRING, + core::{BOOL, HSTRING}, }; struct OwnedSecurityDescriptor(PSECURITY_DESCRIPTOR); @@ -231,7 +235,7 @@ pub fn create_owner_only_server(name: &str) -> Result { } use std::{ - os::windows::io::AsRawHandle, + os::windows::{ffi::OsStrExt, io::AsRawHandle}, path::{Path, PathBuf}, }; use tokio::net::windows::named_pipe::{ @@ -311,6 +315,250 @@ pub fn create_tokio_server(path: &Path, first_instance: bool) -> Result Result { + let spelling = path.as_os_str().to_string_lossy(); + if !spelling.starts_with(r"\\.\pipe\semwright-loopback-") || spelling.len() > 256 { + return Err(Error::invalid( + "Invalid Semwright AppContainer loopback pipe path", + )); + } + let owner_sid = crate::identity::current_user_sid()?; + let sddl: Vec = format!("D:P(A;;GA;;;SY)(A;;GA;;;{owner_sid})") + .encode_utf16() + .chain(Some(0)) + .collect(); + let mut raw = std::ptr::null_mut(); + // SAFETY: input is NUL-terminated and the API writes one LocalAlloc-owned descriptor pointer. + if unsafe { + windows_sys::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + windows_sys::Win32::Security::Authorization::SDDL_REVISION_1, + &mut raw, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Reserved loopback pipe security descriptor creation failed", + )); + } + struct LocalDescriptor(*mut std::ffi::c_void); + impl Drop for LocalDescriptor { + fn drop(&mut self) { + // SAFETY: descriptor was allocated by ConvertStringSecurityDescriptor... + unsafe { + windows_sys::Win32::Foundation::LocalFree(self.0); + } + } + } + let descriptor = LocalDescriptor(raw); + let attrs = windows_sys::Win32::Security::SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + }; + let wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let open_mode = windows_sys::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX + | windows_sys::Win32::Storage::FileSystem::FILE_FLAG_FIRST_PIPE_INSTANCE + | windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OVERLAPPED + | windows_sys::Win32::Storage::FileSystem::WRITE_DAC; + let pipe_mode = windows_sys::Win32::System::Pipes::PIPE_TYPE_BYTE + | windows_sys::Win32::System::Pipes::PIPE_READMODE_BYTE + | windows_sys::Win32::System::Pipes::PIPE_WAIT + | windows_sys::Win32::System::Pipes::PIPE_REJECT_REMOTE_CLIENTS; + // SAFETY: the NUL-terminated name and explicit owner/SYSTEM descriptor remain live for + // CreateNamedPipeW. WRITE_DAC is requested only on this pre-spawn reservation so the Host + // can replace its DACL with the exact kernel-observed AppContainer SID after spawn. + let handle = unsafe { + windows_sys::Win32::System::Pipes::CreateNamedPipeW( + wide.as_ptr(), + open_mode, + pipe_mode, + 16, + 64 * 1024, + 64 * 1024, + 5_000, + &attrs, + ) + }; + if handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error().into()); + } + // SAFETY: CreateNamedPipeW returned one live, overlapped pipe HANDLE. Ownership transfers + // exactly once into Tokio, which closes it on drop. + unsafe { NamedPipeServer::from_raw_handle(handle as _) }.map_err(Into::into) +} + +pub fn authorize_appcontainer_loopback_server( + pipe: &NamedPipeServer, + expected_pid: u32, +) -> Result<()> { + let owner_sid = crate::identity::current_user_sid()?; + let appcontainer_sid = crate::identity::process_appcontainer_sid(expected_pid)?; + let sddl = HSTRING::from(format!( + "D:P(A;;GA;;;SY)(A;;GA;;;{owner_sid})(A;;GA;;;{appcontainer_sid})" + )); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + // SAFETY: sddl remains live and the API returns one LocalAlloc-owned descriptor. + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + &sddl, + SDDL_REVISION_1, + &mut descriptor, + None, + ) + } + .map_err(|_| { + Error::new( + ErrorCode::PermissionDenied, + "Loopback pipe AppContainer DACL creation failed", + ) + })?; + let descriptor = OwnedSecurityDescriptor(descriptor); + let mut present = BOOL(0); + let mut defaulted = BOOL(0); + let mut dacl: *mut ACL = std::ptr::null_mut(); + // SAFETY: descriptor is a valid self-relative descriptor and outputs are writable. + unsafe { GetSecurityDescriptorDacl(descriptor.0, &mut present, &mut dacl, &mut defaulted) } + .map_err(|_| { + Error::new( + ErrorCode::PermissionDenied, + "Loopback pipe AppContainer DACL lookup failed", + ) + })?; + if !present.as_bool() || dacl.is_null() { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Loopback pipe AppContainer DACL is missing", + )); + } + let raw = windows::Win32::Foundation::HANDLE(pipe.as_raw_handle()); + // SAFETY: raw is the live reserved named-pipe handle and dacl belongs to descriptor, + // which remains live for the synchronous SetSecurityInfo call. + let status = unsafe { + SetSecurityInfo( + raw, + SE_KERNEL_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + Some(dacl), + None, + ) + }; + if status.0 != 0 { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Loopback pipe AppContainer DACL could not be applied", + )); + } + Ok(()) +} + +pub fn create_appcontainer_loopback_server( + path: &Path, + expected_pid: u32, + first_instance: bool, +) -> Result { + let spelling = path.as_os_str().to_string_lossy(); + if !spelling.starts_with(r"\\.\pipe\semwright-loopback-") || spelling.len() > 256 { + return Err(Error::invalid( + "Invalid Semwright AppContainer loopback pipe path", + )); + } + let owner_sid = crate::identity::current_user_sid()?; + let appcontainer_sid = crate::identity::process_appcontainer_sid(expected_pid)?; + let sddl: Vec = + format!("D:P(A;;GA;;;SY)(A;;GA;;;{owner_sid})(A;;GA;;;{appcontainer_sid})") + .encode_utf16() + .chain(Some(0)) + .collect(); + let mut raw = std::ptr::null_mut(); + // SAFETY: input is NUL-terminated and the API writes one LocalAlloc-owned descriptor pointer. + if unsafe { + windows_sys::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + windows_sys::Win32::Security::Authorization::SDDL_REVISION_1, + &mut raw, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(Error::new( + ErrorCode::PermissionDenied, + "AppContainer loopback pipe security descriptor creation failed", + )); + } + struct LocalDescriptor(*mut std::ffi::c_void); + impl Drop for LocalDescriptor { + fn drop(&mut self) { + // SAFETY: descriptor was allocated by ConvertStringSecurityDescriptor... + unsafe { + windows_sys::Win32::Foundation::LocalFree(self.0); + } + } + } + let descriptor = LocalDescriptor(raw); + let mut attrs = windows_sys::Win32::Security::SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + }; + let mut options = ServerOptions::new(); + options + .pipe_mode(PipeMode::Byte) + .reject_remote_clients(true) + .max_instances(16) + .first_pipe_instance(first_instance); + // SAFETY: attrs and its security descriptor remain live for the synchronous create call. + let server = unsafe { + options.create_with_security_attributes_raw( + path.as_os_str(), + (&mut attrs as *mut windows_sys::Win32::Security::SECURITY_ATTRIBUTES).cast(), + ) + }?; + Ok(server) +} + +pub fn validate_appcontainer_loopback_peer( + pipe: &NamedPipeServer, + expected_pid: u32, +) -> Result<()> { + let raw = windows::Win32::Foundation::HANDLE(pipe.as_raw_handle()); + let mut pid = 0u32; + let mut session = 0u32; + // SAFETY: raw is a connected server pipe handle and outputs are live stack storage. + unsafe { GetNamedPipeClientProcessId(raw, &mut pid) }.map_err(|_| { + Error::new( + ErrorCode::PermissionDenied, + "Loopback pipe client PID unavailable", + ) + })?; + // SAFETY: raw is a connected server pipe handle and outputs are live stack storage. + unsafe { GetNamedPipeClientSessionId(raw, &mut session) }.map_err(|_| { + Error::new( + ErrorCode::PermissionDenied, + "Loopback pipe client session unavailable", + ) + })?; + if pid != expected_pid || session != crate::identity::current_session_id()? { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Loopback pipe client is not the expected sandbox child", + )); + } + if crate::identity::process_user_sid_bytes(pid)? != crate::identity::current_user_sid_bytes()? { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Loopback pipe client user SID does not match the host owner", + )); + } + // Re-read package identity after connection to defend against stale/reused process identity. + let _ = crate::identity::process_appcontainer_sid(pid)?; + Ok(()) +} + pub fn open_tokio_client(path: &Path) -> Result { let spelling = path.as_os_str().to_string_lossy(); if !spelling.starts_with(r"\\.\pipe\semwright-") || spelling.len() > 256 {