feat(sandbox): 编码原生命令行参数并明确隔离环境

This commit is contained in:
2026-09-08 22:51:38 +08:00
parent 71585486e2
commit dd92b15b9b
5 changed files with 330 additions and 7 deletions
+59 -7
View File
@@ -318,6 +318,16 @@ mod tests {
executable: &std::path::Path,
command: Option<String>,
) -> 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 caps = SECURITY_CAPABILITIES {
AppContainerSid: profile.sid(),
@@ -361,16 +371,25 @@ mod tests {
.as_ref()
.map(|command| command.encode_utf16().chain(Some(0)).collect())
.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();
assert_ne!(
unsafe {
CreateProcessW(
executable.as_ptr(),
if command_line.is_empty() {
std::ptr::null_mut()
} else {
command_line.as_mut_ptr()
},
command_ptr,
std::ptr::null(),
std::ptr::null(),
0,
@@ -378,7 +397,7 @@ mod tests {
| CREATE_NO_WINDOW
| EXTENDED_STARTUPINFO_PRESENT
| CREATE_UNICODE_ENVIRONMENT,
environment.as_ptr().cast(),
environment_ptr.cast(),
std::ptr::null(),
&startup.StartupInfo,
&mut info,
@@ -475,7 +494,7 @@ mod tests {
);
assert_eq!(unsafe { *capabilities.as_ptr().cast::<u32>() }, 0);
let exit = command.map(|_| {
let exit = resume.then(|| {
assert_ne!(
unsafe { ResumeThread(process._thread.as_raw_handle()) },
u32::MAX
@@ -625,6 +644,39 @@ mod tests {
let entry = open(&executable);
profile.grant_package_read_execute(&root).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"] {
let tcp = TcpListener::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]));
}
}
+3
View File
@@ -57,3 +57,6 @@ pub mod extension_job;
#[cfg(windows)]
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;
fn main() {
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 {
std::process::exit(79);
}