feat(sandbox): 编码原生命令行参数并明确隔离环境
This commit is contained in:
@@ -349,3 +349,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
|||||||
- 初次要求 Winsock 10013 的断言失败;进一步探测明确为 TCP 超时,并分别保留超时、访问拒绝、其他错误退出码。当前断言检查允许的网络失败类型和监听端无接收,并排除加载失败、参数错误、控制端不可用。Microsoft 文档说明环回隔离可能表现为丢包:[Windows 防火墙 UWP 连接排查](https://learn.microsoft.com/zh-cn/windows/security/operating-system-security/network-security/windows-firewall/troubleshooting-uwp-firewall)。此次未捕获 WFP 事件,不能将超时唯一归因到某条过滤规则。
|
- 初次要求 Winsock 10013 的断言失败;进一步探测明确为 TCP 超时,并分别保留超时、访问拒绝、其他错误退出码。当前断言检查允许的网络失败类型和监听端无接收,并排除加载失败、参数错误、控制端不可用。Microsoft 文档说明环回隔离可能表现为丢包:[Windows 防火墙 UWP 连接排查](https://learn.microsoft.com/zh-cn/windows/security/operating-system-security/network-security/windows-firewall/troubleshooting-uwp-firewall)。此次未捕获 WFP 事件,不能将超时唯一归因到某条过滤规则。
|
||||||
- 全部 5 项 AppContainer 测试和全目标 Clippy -D warnings 通过,日志 `.build/extension-container-network-tests.log` 与 `.build/extension-container-network-clippy.log`。测试没有增加 loopback exemption 或修改系统防火墙设置。
|
- 全部 5 项 AppContainer 测试和全目标 Clippy -D warnings 通过,日志 `.build/extension-container-network-tests.log` 与 `.build/extension-container-network-clippy.log`。测试没有增加 loopback exemption 或修改系统防火墙设置。
|
||||||
- 这只是当前机器上 TCP/UDP 环回行为的有限时间观察,不覆盖公网、私网实际服务、DNS、原始 socket、跨机器或最低 OS 版本,也不能据此通过完整 C-01/C-03。生产启动器、受限 broker、scratch 配额等仍待完成,整体目标继续进行。
|
- 这只是当前机器上 TCP/UDP 环回行为的有限时间观察,不覆盖公网、私网实际服务、DNS、原始 socket、跨机器或最低 OS 版本,也不能据此通过完整 C-01/C-03。生产启动器、受限 broker、scratch 配额等仍待完成,整体目标继续进行。
|
||||||
|
|
||||||
|
|
||||||
|
## 增量:原生启动参数与隔离环境构造
|
||||||
|
|
||||||
|
- 新增 Windows LaunchData,为明确的绝对 .exe 入口编码 CRT 参数数组,正确处理空参数、空格、引号前反斜杠和末尾反斜杠;拒绝相对入口、非 .exe、路径引号/NUL、参数 NUL、数量与长度超限。入口仍须由调用方校验并持有,构造器本身不验证包、许可或沙箱能力,也不允许据此执行 shell 配置。
|
||||||
|
- 环境块只使用 Host 提供的 SystemRoot、容器 LOCALAPPDATA、scratch TEMP/TMP 和明确声明/解析的变量,不读取父环境。变量名限 ASCII、大小写归一排序,拒绝重复和覆盖保留变量、非法名称与 NUL。原生路径使用 UTF-16 保留未配对代理项,避免 display() 有损转换。环境块含双 NUL,并要求调用方使用 CREATE_UNICODE_ENVIRONMENT。
|
||||||
|
- 参数与环境均限制在 32767 个 UTF-16 单元内,预分配容量并在加入内容前检查界限,避免构造过程因扩容留下额外值副本;Drop 清零两个缓冲区。此清理不替代调用方对已解析凭据的生命周期和清理责任。
|
||||||
|
- 真实 AppContainer 探针使用该构造器启动,比较完整 argv 和完整环境变量集合:空串、空格、中文/emoji、引号/反斜杠、换行及 &|%PATH% 均原样收到,环境恰好为四个基础变量加 CUSTOM,未继承 PATH 等 Host 环境。测试也保留容器 SID/零能力/先挂起后入 Job 的核对。
|
||||||
|
- 45 项扩展回归通过、3 项 Job 辅助入口 ignored(由父测试实际调用),全目标 Clippy -D warnings 通过。日志 `.build/extension-launch-regression-tests.log`、`.build/extension-launch-clippy.log`。API 依据 [Microsoft CRT 参数解析](https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments?view=msvc-170) 和 [CreateProcessW](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw)。尚未覆盖所有第三方运行时的自定义命令行解析。
|
||||||
|
- 当前仍未开放第三方执行:正式启动器的许可/包句柄绑定、生产环境凭据解析、scratch 限额、broker 与完整端到端验收继续实施,不能据本轮参数测试认定生产化完成。
|
||||||
|
|||||||
@@ -318,6 +318,16 @@ mod tests {
|
|||||||
executable: &std::path::Path,
|
executable: &std::path::Path,
|
||||||
command: Option<String>,
|
command: Option<String>,
|
||||||
) -> Option<u32> {
|
) -> Option<u32> {
|
||||||
|
checked_executable_data(profile, executable, command, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checked_executable_data(
|
||||||
|
profile: &Profile,
|
||||||
|
executable: &std::path::Path,
|
||||||
|
command: Option<String>,
|
||||||
|
mut data: Option<crate::extension_launch_data::LaunchData>,
|
||||||
|
) -> Option<u32> {
|
||||||
|
let resume = command.is_some() || data.is_some();
|
||||||
let mut attributes = Attributes::new();
|
let mut attributes = Attributes::new();
|
||||||
let caps = SECURITY_CAPABILITIES {
|
let caps = SECURITY_CAPABILITIES {
|
||||||
AppContainerSid: profile.sid(),
|
AppContainerSid: profile.sid(),
|
||||||
@@ -361,16 +371,25 @@ mod tests {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|command| command.encode_utf16().chain(Some(0)).collect())
|
.map(|command| command.encode_utf16().chain(Some(0)).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let environment_ptr = data
|
||||||
|
.as_ref()
|
||||||
|
.map_or(environment.as_ptr(), |d| d.environment().as_ptr());
|
||||||
|
let command_ptr = data.as_mut().map_or_else(
|
||||||
|
|| {
|
||||||
|
if command_line.is_empty() {
|
||||||
|
std::ptr::null_mut()
|
||||||
|
} else {
|
||||||
|
command_line.as_mut_ptr()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|d| d.command_mut().as_mut_ptr(),
|
||||||
|
);
|
||||||
let mut info = PROCESS_INFORMATION::default();
|
let mut info = PROCESS_INFORMATION::default();
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
unsafe {
|
unsafe {
|
||||||
CreateProcessW(
|
CreateProcessW(
|
||||||
executable.as_ptr(),
|
executable.as_ptr(),
|
||||||
if command_line.is_empty() {
|
command_ptr,
|
||||||
std::ptr::null_mut()
|
|
||||||
} else {
|
|
||||||
command_line.as_mut_ptr()
|
|
||||||
},
|
|
||||||
std::ptr::null(),
|
std::ptr::null(),
|
||||||
std::ptr::null(),
|
std::ptr::null(),
|
||||||
0,
|
0,
|
||||||
@@ -378,7 +397,7 @@ mod tests {
|
|||||||
| CREATE_NO_WINDOW
|
| CREATE_NO_WINDOW
|
||||||
| EXTENDED_STARTUPINFO_PRESENT
|
| EXTENDED_STARTUPINFO_PRESENT
|
||||||
| CREATE_UNICODE_ENVIRONMENT,
|
| CREATE_UNICODE_ENVIRONMENT,
|
||||||
environment.as_ptr().cast(),
|
environment_ptr.cast(),
|
||||||
std::ptr::null(),
|
std::ptr::null(),
|
||||||
&startup.StartupInfo,
|
&startup.StartupInfo,
|
||||||
&mut info,
|
&mut info,
|
||||||
@@ -475,7 +494,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(unsafe { *capabilities.as_ptr().cast::<u32>() }, 0);
|
assert_eq!(unsafe { *capabilities.as_ptr().cast::<u32>() }, 0);
|
||||||
|
|
||||||
let exit = command.map(|_| {
|
let exit = resume.then(|| {
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
unsafe { ResumeThread(process._thread.as_raw_handle()) },
|
unsafe { ResumeThread(process._thread.as_raw_handle()) },
|
||||||
u32::MAX
|
u32::MAX
|
||||||
@@ -625,6 +644,39 @@ mod tests {
|
|||||||
let entry = open(&executable);
|
let entry = open(&executable);
|
||||||
profile.grant_package_read_execute(&root).unwrap();
|
profile.grant_package_read_execute(&root).unwrap();
|
||||||
profile.grant_package_read_execute(&entry).unwrap();
|
profile.grant_package_read_execute(&entry).unwrap();
|
||||||
|
// Exercise the actual builder, not a test-side quote decoder. The child
|
||||||
|
// compares argv and its entire environment without logging values.
|
||||||
|
let args = [
|
||||||
|
"launch",
|
||||||
|
"",
|
||||||
|
"space value",
|
||||||
|
"引号🦀",
|
||||||
|
"trailing\\",
|
||||||
|
"a\"b",
|
||||||
|
"slash\\\"quote",
|
||||||
|
"&|%PATH%",
|
||||||
|
"line\nbreak",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let folder = profile.folder().unwrap();
|
||||||
|
let system = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap());
|
||||||
|
let data = crate::extension_launch_data::LaunchData::new(
|
||||||
|
&executable,
|
||||||
|
&args,
|
||||||
|
&system,
|
||||||
|
&folder,
|
||||||
|
&folder.join("Temp"),
|
||||||
|
&[("CUSTOM".to_owned(), "declared=值".to_owned())]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
checked_executable_data(&profile, &executable, None, Some(data)),
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
for ip in ["127.0.0.1:0", "[::1]:0"] {
|
for ip in ["127.0.0.1:0", "[::1]:0"] {
|
||||||
let tcp = TcpListener::bind(ip).unwrap();
|
let tcp = TcpListener::bind(ip).unwrap();
|
||||||
let udp = UdpSocket::bind(ip).unwrap();
|
let udp = UdpSocket::bind(ip).unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//! Native argv/environment encoding. This does not authorize or launch a process.
|
||||||
|
use crate::workspace::{HostError, Result};
|
||||||
|
use std::{collections::BTreeMap, os::windows::ffi::OsStrExt, path::Path};
|
||||||
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
|
pub struct LaunchData {
|
||||||
|
command: Vec<u16>,
|
||||||
|
environment: Vec<u16>,
|
||||||
|
}
|
||||||
|
impl Drop for LaunchData {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.command.zeroize();
|
||||||
|
self.environment.zeroize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl LaunchData {
|
||||||
|
/// The Host supplies verified absolute paths and explicitly declared/resolved
|
||||||
|
/// environment values. Never reads the parent environment. CRT argv rules
|
||||||
|
/// apply to native executables, not cmd.exe, batch files or shell interpreters.
|
||||||
|
pub fn new(
|
||||||
|
executable: &Path,
|
||||||
|
arguments: &[String],
|
||||||
|
system_root: &Path,
|
||||||
|
local_app_data: &Path,
|
||||||
|
scratch: &Path,
|
||||||
|
declared: &BTreeMap<String, String>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let bad = || HostError::new("EXTENSION_LAUNCH_DATA_INVALID");
|
||||||
|
if !executable.is_absolute()
|
||||||
|
|| !executable
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|v| v.eq_ignore_ascii_case("exe"))
|
||||||
|
|| arguments.len() > 128
|
||||||
|
|| declared.len() > 128
|
||||||
|
{
|
||||||
|
return Err(bad());
|
||||||
|
}
|
||||||
|
let executable: Vec<u16> = executable.as_os_str().encode_wide().collect();
|
||||||
|
if executable.contains(&0) || executable.contains(&34) || executable.len() > 32764 {
|
||||||
|
return Err(bad());
|
||||||
|
}
|
||||||
|
let mut result = Self {
|
||||||
|
command: Vec::with_capacity(32767),
|
||||||
|
environment: Vec::with_capacity(32767),
|
||||||
|
};
|
||||||
|
result.command.push(34);
|
||||||
|
result.command.extend(executable);
|
||||||
|
result.command.push(34);
|
||||||
|
for argument in arguments {
|
||||||
|
if argument.len() > 8192 || argument.contains('\0') {
|
||||||
|
return Err(bad());
|
||||||
|
}
|
||||||
|
// Reserve the full bounded buffers once: do not leave earlier
|
||||||
|
// copies of resolved values behind through Vec reallocations.
|
||||||
|
let mut encoded_len = 0;
|
||||||
|
let mut trailing = 0;
|
||||||
|
for unit in argument.encode_utf16() {
|
||||||
|
if unit == 92 {
|
||||||
|
trailing += 1;
|
||||||
|
} else {
|
||||||
|
encoded_len += if unit == 34 {
|
||||||
|
trailing * 2 + 2
|
||||||
|
} else {
|
||||||
|
trailing + 1
|
||||||
|
};
|
||||||
|
trailing = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded_len += trailing * 2;
|
||||||
|
if result.command.len() + 3 + encoded_len + 1 > 32767 {
|
||||||
|
return Err(bad());
|
||||||
|
}
|
||||||
|
result.command.extend([32, 34]);
|
||||||
|
let mut slashes = 0;
|
||||||
|
for unit in argument.encode_utf16() {
|
||||||
|
if unit == 92 {
|
||||||
|
slashes += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.command.extend(std::iter::repeat_n(
|
||||||
|
92,
|
||||||
|
if unit == 34 { slashes * 2 + 1 } else { slashes },
|
||||||
|
));
|
||||||
|
result.command.push(unit);
|
||||||
|
slashes = 0;
|
||||||
|
}
|
||||||
|
result.command.extend(std::iter::repeat_n(92, slashes * 2));
|
||||||
|
result.command.push(34);
|
||||||
|
if result.command.len() >= 32767 {
|
||||||
|
return Err(bad());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.command.push(0);
|
||||||
|
if result.command.len() > 32767 {
|
||||||
|
return Err(bad());
|
||||||
|
}
|
||||||
|
// ASCII names give a deterministic Windows case-insensitive order.
|
||||||
|
// Values remain borrowed until encoded so there are no secret clones.
|
||||||
|
let mut fields: BTreeMap<String, &std::ffi::OsStr> = BTreeMap::new();
|
||||||
|
for (name, path) in [
|
||||||
|
("SYSTEMROOT", system_root),
|
||||||
|
("LOCALAPPDATA", local_app_data),
|
||||||
|
("TEMP", scratch),
|
||||||
|
("TMP", scratch),
|
||||||
|
] {
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err(bad());
|
||||||
|
}
|
||||||
|
fields.insert(name.to_owned(), path.as_os_str());
|
||||||
|
}
|
||||||
|
for (name, value) in declared {
|
||||||
|
if name.is_empty()
|
||||||
|
|| name.len() > 128
|
||||||
|
|| !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||||
|
|| value.len() > 8192
|
||||||
|
{
|
||||||
|
return Err(bad());
|
||||||
|
}
|
||||||
|
if fields
|
||||||
|
.insert(name.to_ascii_uppercase(), value.as_ref())
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(bad());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (name, value) in fields {
|
||||||
|
if result.environment.len() + name.len() + value.encode_wide().count() + 3 > 32767 {
|
||||||
|
return Err(bad());
|
||||||
|
}
|
||||||
|
result.environment.extend(name.encode_utf16());
|
||||||
|
result.environment.push(61);
|
||||||
|
for unit in value.encode_wide() {
|
||||||
|
if unit == 0 {
|
||||||
|
return Err(bad());
|
||||||
|
}
|
||||||
|
result.environment.push(unit);
|
||||||
|
}
|
||||||
|
result.environment.push(0);
|
||||||
|
if result.environment.len() >= 32767 {
|
||||||
|
return Err(bad());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.environment.push(0);
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
pub fn command_mut(&mut self) -> &mut [u16] {
|
||||||
|
&mut self.command
|
||||||
|
}
|
||||||
|
/// Pass with CREATE_UNICODE_ENVIRONMENT; never substitute a null pointer.
|
||||||
|
pub fn environment(&self) -> &[u16] {
|
||||||
|
&self.environment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn build(args: &[String], vars: &BTreeMap<String, String>) -> Result<LaunchData> {
|
||||||
|
LaunchData::new(
|
||||||
|
Path::new(r"C:\package\probe.exe"),
|
||||||
|
args,
|
||||||
|
Path::new(r"C:\Windows"),
|
||||||
|
Path::new(r"C:\container"),
|
||||||
|
Path::new(r"C:\scratch"),
|
||||||
|
vars,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rejects_ambiguous_environment_names_reserved_overrides_nul_and_limits() {
|
||||||
|
for fields in [
|
||||||
|
vec![("temp", "x")],
|
||||||
|
vec![("a", "1"), ("A", "2")],
|
||||||
|
vec![("=C:", "x")],
|
||||||
|
vec![("SAFE", "x\0y")],
|
||||||
|
] {
|
||||||
|
assert!(build(
|
||||||
|
&[],
|
||||||
|
&fields
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k.into(), v.into()))
|
||||||
|
.collect()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
assert!(build(&["x\0y".into()], &BTreeMap::new()).is_err());
|
||||||
|
assert!(build(&vec!["x".repeat(8192); 4], &BTreeMap::new()).is_err());
|
||||||
|
assert!(build(
|
||||||
|
&[],
|
||||||
|
&(0..4)
|
||||||
|
.map(|i| (format!("V{i}"), "x".repeat(8192)))
|
||||||
|
.collect()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert!(build(&vec![String::new(); 129], &BTreeMap::new()).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn refuses_relative_or_non_native_entry_and_preserves_wide_paths() {
|
||||||
|
for entry in [
|
||||||
|
r"relative.exe",
|
||||||
|
r"C:\package\entry.cmd",
|
||||||
|
"C:\\bad\"name.exe",
|
||||||
|
] {
|
||||||
|
assert!(LaunchData::new(
|
||||||
|
Path::new(entry),
|
||||||
|
&[],
|
||||||
|
Path::new(r"C:\Windows"),
|
||||||
|
Path::new(r"C:\container"),
|
||||||
|
Path::new(r"C:\scratch"),
|
||||||
|
&BTreeMap::new()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
use std::os::windows::ffi::OsStringExt;
|
||||||
|
let wide = std::ffi::OsString::from_wide(&[67, 58, 92, 0xd800]);
|
||||||
|
let data = LaunchData::new(
|
||||||
|
Path::new(r"C:\probe.exe"),
|
||||||
|
&[],
|
||||||
|
Path::new(r"C:\Windows"),
|
||||||
|
Path::new(&wide),
|
||||||
|
Path::new(&wide),
|
||||||
|
&BTreeMap::new(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(data.environment().contains(&0xd800));
|
||||||
|
assert!(data.environment().ends_with(&[0, 0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,3 +57,6 @@ pub mod extension_job;
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub mod extension_container;
|
pub mod extension_container;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub mod extension_launch_data;
|
||||||
|
|||||||
@@ -3,6 +3,37 @@ use std::net::{SocketAddr, TcpStream, UdpSocket};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
fn main() {
|
fn main() {
|
||||||
let args: Vec<_> = std::env::args().collect();
|
let args: Vec<_> = std::env::args().collect();
|
||||||
|
if args.get(1).is_some_and(|s| s == "launch") {
|
||||||
|
let expected = [
|
||||||
|
"launch",
|
||||||
|
"",
|
||||||
|
"space value",
|
||||||
|
"引号🦀",
|
||||||
|
"trailing\\",
|
||||||
|
"a\"b",
|
||||||
|
"slash\\\"quote",
|
||||||
|
"&|%PATH%",
|
||||||
|
"line\nbreak",
|
||||||
|
];
|
||||||
|
if args[1..] != expected {
|
||||||
|
std::process::exit(82);
|
||||||
|
}
|
||||||
|
let environment: std::collections::BTreeMap<_, _> = std::env::vars_os().collect();
|
||||||
|
let names: std::collections::BTreeSet<_> =
|
||||||
|
environment.keys().map(|k| k.to_str().unwrap()).collect();
|
||||||
|
if names
|
||||||
|
!= ["CUSTOM", "LOCALAPPDATA", "SYSTEMROOT", "TEMP", "TMP"]
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
|| std::env::var("CUSTOM").as_deref() != Ok("declared=值")
|
||||||
|
|| std::env::var_os("TEMP") != std::env::var_os("TMP")
|
||||||
|
|| std::path::PathBuf::from(std::env::var_os("LOCALAPPDATA").unwrap()).join("Temp")
|
||||||
|
!= std::path::PathBuf::from(std::env::var_os("TEMP").unwrap())
|
||||||
|
{
|
||||||
|
std::process::exit(83);
|
||||||
|
}
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
if args.len() != 3 {
|
if args.len() != 3 {
|
||||||
std::process::exit(79);
|
std::process::exit(79);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user