feat(sandbox): 按句柄授予包访问权限并验证容器隔离

This commit is contained in:
2026-09-08 22:40:07 +08:00
parent dca0c7bff4
commit 627e4552d5
2 changed files with 236 additions and 2 deletions
@@ -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 文件已有尾部空行,未修改用户内容;本次代码与文档单独检查通过。
+226 -2
View File
@@ -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<std::path::PathBuf> {
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<u32> {
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<u16> = 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::<u32>() }, 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();
}
}