feat(sandbox): 按句柄授予包访问权限并验证容器隔离
This commit is contained in:
@@ -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 文件已有尾部空行,未修改用户内容;本次代码与文档单独检查通过。
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user