diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs index 0089130..0f2b6a2 100644 --- a/frontend/src-tauri/src/extension_container.rs +++ b/frontend/src-tauri/src/extension_container.rs @@ -54,6 +54,100 @@ impl Profile { pub fn sid(&self) -> PSID { self.sid } + /// Grant this instance read/execute access to one Host-owned package object. + /// The caller must open it without following reparse points and retain the + /// verified package handles for the entire launch. No recursive inheritance + /// is used: every directory and file must be checked and granted separately. + /// This adds an ACE; it does not sanitize pre-existing permissions. + pub fn grant_package_read_execute(&self, object: &std::fs::File) -> Result<()> { + use std::os::windows::{fs::MetadataExt, io::AsRawHandle}; + use windows_sys::Win32::{ + Foundation::LocalFree, + Security::{Authorization::*, DACL_SECURITY_INFORMATION}, + Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + FILE_ATTRIBUTE_REPARSE_POINT, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, + }, + }; + struct LocalAllocation(*mut core::ffi::c_void); + impl Drop for LocalAllocation { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + LocalFree(self.0); + } + } + } + } + let metadata = object + .metadata() + .map_err(|_| HostError::new("EXTENSION_CONTAINER_ACL_FAILED"))?; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || !(metadata.is_file() || metadata.is_dir()) + { + return Err(HostError::new("EXTENSION_CONTAINER_ACL_OBJECT_INVALID")); + } + if metadata.is_file() { + let mut info = BY_HANDLE_FILE_INFORMATION::default(); + if unsafe { GetFileInformationByHandle(object.as_raw_handle(), &mut info) } == 0 + || info.nNumberOfLinks != 1 + { + return Err(HostError::new("EXTENSION_CONTAINER_ACL_OBJECT_INVALID")); + } + } + let mut old_acl = std::ptr::null_mut(); + let mut descriptor = std::ptr::null_mut(); + let status = unsafe { + GetSecurityInfo( + object.as_raw_handle(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut old_acl, + std::ptr::null_mut(), + &mut descriptor, + ) + }; + let _descriptor = LocalAllocation(descriptor); + // A null DACL grants everyone full access, so fail closed rather than + // silently treating it as a suitably isolated package object. + if status != 0 || old_acl.is_null() { + return Err(HostError::new("EXTENSION_CONTAINER_ACL_FAILED")); + } + let entry = EXPLICIT_ACCESS_W { + grfAccessPermissions: FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, + grfAccessMode: GRANT_ACCESS, + grfInheritance: 0, + Trustee: TRUSTEE_W { + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: self.sid.cast(), + ..Default::default() + }, + }; + let mut acl = std::ptr::null_mut(); + let status = unsafe { SetEntriesInAclW(1, &entry, old_acl, &mut acl) }; + let _acl = LocalAllocation(acl.cast()); + if status != 0 || acl.is_null() { + return Err(HostError::new("EXTENSION_CONTAINER_ACL_FAILED")); + } + let status = unsafe { + SetSecurityInfo( + object.as_raw_handle(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + acl, + std::ptr::null(), + ) + }; + if status != 0 { + return Err(HostError::new("EXTENSION_CONTAINER_ACL_FAILED")); + } + Ok(()) + } pub fn folder(&self) -> Result { use std::os::windows::ffi::OsStringExt; use windows_sys::Win32::{ @@ -73,6 +167,11 @@ impl Profile { LocalFree(string.cast()); } if status < 0 || folder.is_null() { + if !folder.is_null() { + unsafe { + CoTaskMemFree(folder.cast()); + } + } return Err(HostError::new("EXTENSION_CONTAINER_FOLDER_FAILED")); } let mut length = 0; @@ -198,6 +297,13 @@ mod tests { #[test] fn real_suspended_process_has_appcontainer_token_before_job_resume() { let profile = Profile::create().unwrap(); + checked_process(&profile, None); + profile.remove().unwrap(); + } + + // Only tests use cmd.exe, with fixed commands and controlled temporary paths. + // A production extension launcher must use a verified entry, never a shell. + fn checked_process(profile: &Profile, command: Option<&str>) -> Option { let mut attributes = Attributes::new(); let caps = SECURITY_CAPABILITIES { AppContainerSid: profile.sid(), @@ -239,12 +345,24 @@ mod tests { ) .encode_utf16() .collect(); + let mut command_line: Vec = command + .map(|command| { + format!("cmd.exe /d /c {command}") + .encode_utf16() + .chain(Some(0)) + .collect() + }) + .unwrap_or_default(); let mut info = PROCESS_INFORMATION::default(); assert_ne!( unsafe { CreateProcessW( executable.as_ptr(), - std::ptr::null_mut(), + if command_line.is_empty() { + std::ptr::null_mut() + } else { + command_line.as_mut_ptr() + }, std::ptr::null(), std::ptr::null(), 0, @@ -349,12 +467,118 @@ mod tests { ); assert_eq!(unsafe { *capabilities.as_ptr().cast::() }, 0); - // No command interpreter instruction was resumed by this identity test. + let exit = command.map(|_| { + assert_ne!( + unsafe { ResumeThread(process._thread.as_raw_handle()) }, + u32::MAX + ); + assert_eq!( + unsafe { WaitForSingleObject(process.process.as_raw_handle(), 10_000) }, + 0 + ); + let mut exit = 0; + assert_ne!( + unsafe { GetExitCodeProcess(process.process.as_raw_handle(), &mut exit) }, + 0 + ); + exit + }); job.terminate().unwrap(); drop(token); drop(process); drop(job); drop(attributes); + exit + } + + #[test] + fn real_container_can_read_only_explicitly_granted_package_objects() { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::*; + let profile = Profile::create().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let payload = directory.path().join("payload.txt"); + let hidden = directory.path().join("ungranted.txt"); + std::fs::write(&payload, b"verified package content").unwrap(); + std::fs::write(&hidden, b"private sibling").unwrap(); + let open = |path: &std::path::Path| { + std::fs::OpenOptions::new() + .access_mode(READ_CONTROL | WRITE_DAC) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + .unwrap() + }; + let root_handle = open(directory.path()); + let file_handle = open(&payload); + let read = format!("set /p value=<\"{}\"", payload.display()); + assert_ne!(checked_process(&profile, Some(&read)), Some(0)); + profile.grant_package_read_execute(&root_handle).unwrap(); + // The directory ACE does not propagate to existing children. + assert_ne!(checked_process(&profile, Some(&read)), Some(0)); + profile.grant_package_read_execute(&file_handle).unwrap(); + assert_eq!(checked_process(&profile, Some(&read)), Some(0)); + let other = Profile::create().unwrap(); + assert_ne!(checked_process(&other, Some(&read)), Some(0)); + other.remove().unwrap(); + let created = directory.path().join("new.txt"); + assert_ne!( + checked_process( + &profile, + Some(&format!("echo changed>\"{}\"", created.display())) + ), + Some(0) + ); + assert!(!created.exists()); + assert_ne!( + checked_process( + &profile, + Some(&format!("set /p value=<\"{}\"", hidden.display())) + ), + Some(0) + ); + assert_ne!( + checked_process( + &profile, + Some(&format!("echo changed>\"{}\"", payload.display())) + ), + Some(0) + ); + assert_eq!( + std::fs::read(&payload).unwrap(), + b"verified package content" + ); + drop(file_handle); + drop(root_handle); + profile.remove().unwrap(); + } + #[test] + fn package_grant_rejects_hardlinks_and_handles_without_acl_write_access() { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::*; + let profile = Profile::create().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let payload = directory.path().join("payload.txt"); + let alias = directory.path().join("alias.txt"); + std::fs::write(&payload, b"unchanged").unwrap(); + let read_only = std::fs::File::open(&payload).unwrap(); + assert!(profile.grant_package_read_execute(&read_only).is_err()); + drop(read_only); + std::fs::hard_link(&payload, &alias).unwrap(); + let handle = std::fs::OpenOptions::new() + .access_mode(READ_CONTROL | WRITE_DAC) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(&payload) + .unwrap(); + assert_eq!( + profile + .grant_package_read_execute(&handle) + .unwrap_err() + .code, + "EXTENSION_CONTAINER_ACL_OBJECT_INVALID" + ); + assert_eq!(std::fs::read(&alias).unwrap(), b"unchanged"); + drop(handle); profile.remove().unwrap(); } }