From 627e4552d58a28e1658b473a0b71725e431511cb Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 22:40:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(sandbox):=20=E6=8C=89=E5=8F=A5=E6=9F=84?= =?UTF-8?q?=E6=8E=88=E4=BA=88=E5=8C=85=E8=AE=BF=E9=97=AE=E6=9D=83=E9=99=90?= =?UTF-8?q?=E5=B9=B6=E9=AA=8C=E8=AF=81=E5=AE=B9=E5=99=A8=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenNexus生产化实施进度-2026-09-08.md | 10 + frontend/src-tauri/src/extension_container.rs | 228 +++++++++++++++++- 2 files changed, 236 insertions(+), 2 deletions(-) diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index bda7167..0bfe589 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -330,3 +330,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 空环境及仅 SystemRoot 的启动尝试返回 Windows 203;使用明确的 SystemRoot 和指向容器自身目录的 LOCALAPPDATA/TEMP/TMP 后通过。此处是测试启动环境,不是完整生产环境/参数构造器。 - 两项 AppContainer 原生测试与全目标 Clippy -D warnings 通过,日志 `.build/extension-container-tests.log`。API 依据 Microsoft CreateAppContainerProfile / DeleteAppContainerProfile / Launch an AppContainer 文档。所有测试创建的配置通过所属对象清理,未借用既有用户配置。 - 生产启动器、包只读 ACL、独立 scratch 配额、原始网络拒绝实测、文件与网络 broker、崩溃后孤立配置清理以及恶意程序矩阵仍待完成。当前不声明完整沙箱,也未开启 extensions capability。 + + +## 增量:按句柄授予 AppContainer 包只读执行权限 + +- Profile 新增 grant_package_read_execute,只对 Host 已打开并验证的单个文件或目录增加该实例 SID 的读取/执行 ACE,不启用子项继承。通过 GetSecurityInfo / SetEntriesInAclW / SetSecurityInfo 操作同一对象句柄,分配的安全描述符与 ACL 由 RAII 释放;拒绝重解析对象、硬链接、空 DACL 和权限不足的句柄。调用方仍须按句柄验证整个包并在启动期间持有这些句柄,不接受 renderer 任意路径。 +- 关闭 ACL 继承是为了避免 OS 自动向未检查子项传播授权;每个包目录和文件必须逐项验证和授权。该原语只增加权限,不会清除预先存在的宽泛 ACL,也尚未接入生产启动器,因此不构成对恶意并发替换或任意现有目录的完整安全证明。 +- 4 项真实 Windows AppContainer 测试通过:指定文件授权前无法读取,仅授权目录仍不能读取子文件,文件单独授权后读取成功;相邻未授权文件和另一实例仍被拒绝;新建文件、覆盖包文件失败,原内容保持不变;硬链接与只读句柄不能用于授权。每次试验均先以零能力挂起创建,检查精确容器 SID,加入 Job 后再执行受控测试命令。 +- 首轮使用 type 输出到 nul 的测试返回非零,改用 set /p 从文件重定向读取后验证成功;没有改变生产权限掩码来迁就该测试。系统 cmd.exe 仅用于这些固定测试命令,生产扩展入口仍禁止 shell 拼接。 +- API 依据 [Microsoft SetSecurityInfo 文档](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setsecurityinfo)。当前未完成生产启动器、包树授权编排、scratch 配额、网络/文件 broker 和恶意程序矩阵;extensions capability 仍关闭,整体生产化目标继续进行。 +- 本轮完整回归:cargo test --features desktop 共 98 项通过、5 项 ignored;其中 4 项为父测试实际驱动的 Job/Sync 辅助进程入口,另一项是需要单独执行的打包 Core 20 次冷启动,本轮没有重跑该项。真实 Core/Stronghold、笔记桥接、凭据所有权和 Sync 中断恢复集成全部通过。前端全量 101 个文件、525 项通过;Rust 全目标 Clippy -D warnings 通过。日志分别为 `.build/host-production-full-tests.log`、`.build/frontend-production-full-tests.log`、`.build/host-production-clippy.log`。全库 diff 检查提示用户 Vault 文件已有尾部空行,未修改用户内容;本次代码与文档单独检查通过。 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(); } }