test: 补全C-02沙箱验收矩阵

This commit is contained in:
2026-09-10 06:13:22 +08:00
parent d01688bdea
commit 7030c10855
7 changed files with 452 additions and 25 deletions
@@ -799,6 +799,12 @@ mod tests {
.into_iter()
.map(str::to_owned)
.collect::<Vec<_>>();
let mut args = args;
args.extend(
(8..100).map(|index| {
format!(r#"attack-{index} & | < > ^ %COMSPEC% $(echo injected) \" \\"#)
}),
);
let folder = profile.folder().unwrap();
let system = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap());
#[cfg(not(feature = "desktop"))]
@@ -998,6 +1004,7 @@ mod tests {
"mcp_bad_result",
"mcp_idle_change",
"mcp_review_lock",
"mcp_twenty",
];
if _mcp_deadline {
mcp_modes.push("mcp_deadline");
@@ -1214,6 +1221,18 @@ mod tests {
"native MCP success"
);
}
"mcp_twenty" => {
assert_eq!(result.unwrap()["structuredContent"]["ok"], true);
for _ in 1..20 {
assert_eq!(
session
.test_call_tool("echo", serde_json::json!({}), &cancel)
.unwrap()["structuredContent"]["ok"],
true
);
}
assert!(!session.take_tools_changed());
}
_ => {
assert_eq!(result.unwrap()["content"][0]["text"], "native MCP success");
assert!(session.take_tools_changed());
@@ -1526,6 +1545,28 @@ mod tests {
}
}
}
let descendant_listener = UdpSocket::bind("127.0.0.1:0").unwrap();
descendant_listener.set_nonblocking(true).unwrap();
let data = crate::extension_launch_data::LaunchData::new(
&executable,
&[
"child_udp_100".to_owned(),
descendant_listener.local_addr().unwrap().to_string(),
],
&system,
&folder,
&folder.join("Temp"),
&std::collections::BTreeMap::new(),
)
.unwrap();
assert_eq!(
checked_executable_data(&profile, &executable, None, Some(data)),
Some(0)
);
assert_eq!(
descendant_listener.recv(&mut [0u8; 8]).unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
drop(entry);
drop(root);
profile.remove().unwrap();
+59 -10
View File
@@ -280,6 +280,17 @@ mod tests {
)
.unwrap();
assert_eq!(read["content"], "original");
for _ in 1..20 {
assert_eq!(
call(
&mut broker,
&mut ws,
json!({"method":"notes.read","path":"note.md"}),
)
.unwrap()["content"],
"original"
);
}
let operation = uuid::Uuid::new_v4().to_string();
let write = json!({"method":"notes.write","path":"note.md","expected_hash":read["expected_hash"],"content":"extension update","operation_id":operation});
let receipt = call(&mut broker, &mut ws, write.clone()).unwrap();
@@ -395,16 +406,54 @@ mod tests {
);
let outside = tempfile::tempdir().unwrap();
std::fs::hard_link(ws.root.join("note.md"), outside.path().join("alias.md")).unwrap();
assert_eq!(
call(
&mut broker,
&mut ws,
json!({"method":"notes.read","path":"note.md"})
)
.unwrap_err()
.code,
"UNSAFE_PATH"
);
for _ in 0..100 {
let mut linked =
Broker::bind(&authority, &permit, &claims, &credentials, &ws, "1", 2).unwrap();
assert_eq!(
call(
&mut linked,
&mut ws,
json!({"method":"notes.read","path":"note.md"})
)
.unwrap_err()
.code,
"UNSAFE_PATH"
);
}
let alternate = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("secret.md"), b"outside-one").unwrap();
std::fs::write(alternate.path().join("secret.md"), b"outside-two").unwrap();
let redirect = ws.root.join("redirect");
for round in 0..100 {
if redirect.exists() {
std::fs::remove_dir(&redirect).unwrap();
}
let target = if round % 2 == 0 {
outside.path()
} else {
alternate.path()
};
let created = std::process::Command::new("cmd.exe")
.args(["/d", "/c", "mklink", "/J"])
.arg(&redirect)
.arg(target)
.output()
.unwrap();
assert!(created.status.success());
let mut linked =
Broker::bind(&authority, &permit, &claims, &credentials, &ws, "1", 2).unwrap();
assert_eq!(
call(
&mut linked,
&mut ws,
json!({"method":"notes.read","path":"redirect/secret.md"})
)
.unwrap_err()
.code,
"UNSAFE_PATH"
);
}
std::fs::remove_dir(&redirect).unwrap();
let second = tempfile::tempdir().unwrap();
let mut other = Workspace::open(second.path()).unwrap();
assert_eq!(
@@ -262,4 +262,33 @@ mod tests {
assert_eq!(local, Path::new(r"C:\Users\tester\AppData\Local"));
assert_eq!(scratch, Path::new(r"C:\Users\tester\AppData\Local\Temp"));
}
#[test]
fn shell_arguments_and_environment_injection_pass_hundred_round_matrix() {
let arguments = (0..100)
.map(|index| format!(r#"attack-{index} & | < > ^ %COMSPEC% $(echo injected) \" \\"#))
.collect::<Vec<_>>();
let mut launch = build(&arguments, &BTreeMap::new()).unwrap();
let encoded = launch.command_mut();
assert!(encoded
.windows(9)
.any(|value| value == "attack-99".encode_utf16().collect::<Vec<_>>()));
for index in 0..100 {
let name = match index % 5 {
0 => "TEMP".to_owned(),
1 => "tmp".to_owned(),
2 => "SystemRoot".to_owned(),
3 => "LOCALAPPDATA".to_owned(),
_ => format!("BAD={index}"),
};
assert_eq!(
build(&[], &BTreeMap::from([(name, "injected".into())]))
.err()
.unwrap()
.code,
"EXTENSION_LAUNCH_DATA_INVALID"
);
}
}
}
@@ -6,13 +6,14 @@ use crate::{
use reqwest::{blocking::Client, redirect::Policy, Method, Url};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeSet,
collections::{BTreeMap, BTreeSet},
io::Read,
net::{IpAddr, SocketAddr, ToSocketAddrs},
time::{Duration, Instant},
};
const PERMISSION_PREFIX: &str = "network.https:";
const ADDRESS_PREFIX: &str = "network.https-address:";
const MAX_REQUEST_BYTES: usize = 1024 * 1024;
const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
const MAX_CALLS_PER_MINUTE: usize = 60;
@@ -43,21 +44,41 @@ pub struct FetchResponse {
pub struct Broker {
lease: Lease,
origins: BTreeSet<String>,
addresses: BTreeMap<String, Vec<IpAddr>>,
calls: Vec<Instant>,
}
impl Broker {
pub fn new(lease: Lease, claims: &Claims) -> Result<Self> {
let mut origins = BTreeSet::new();
let mut addresses: BTreeMap<String, Vec<IpAddr>> = BTreeMap::new();
for permission in &claims.permissions {
let Some(value) = permission.strip_prefix(PERMISSION_PREFIX) else {
continue;
};
origins.insert(canonical_origin(value)?);
if let Some(value) = permission.strip_prefix(PERMISSION_PREFIX) {
origins.insert(canonical_origin(value)?);
} else if let Some(value) = permission.strip_prefix(ADDRESS_PREFIX) {
let (host, address) = value
.split_once('=')
.ok_or_else(|| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"))?;
let normalized = normalized_host(host)?;
let address: IpAddr = address
.parse()
.map_err(|_| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"))?;
if prohibited(address) {
return Err(HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"));
}
addresses.entry(normalized).or_default().push(address);
}
}
if addresses
.keys()
.any(|host| !origins.iter().any(|allowed| origin_host(allowed) == host))
{
return Err(HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"));
}
Ok(Self {
lease,
origins,
addresses,
calls: Vec::new(),
})
}
@@ -93,13 +114,18 @@ impl Broker {
let port = url
.port_or_known_default()
.ok_or_else(|| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?;
let addresses: Vec<SocketAddr> = (host, port)
.to_socket_addrs()
.map_err(|_| HostError::new("EXTENSION_NETWORK_DNS_FAILED"))?
.collect();
if addresses.is_empty() || addresses.iter().any(|address| prohibited(address.ip())) {
return Err(HostError::new("EXTENSION_NETWORK_ADDRESS_DENIED"));
}
let addresses: Vec<SocketAddr> = if let Some(pinned) = self.addresses.get(host) {
pinned
.iter()
.map(|address| SocketAddr::new(*address, port))
.collect()
} else {
(host, port)
.to_socket_addrs()
.map_err(|_| HostError::new("EXTENSION_NETWORK_DNS_FAILED"))?
.collect()
};
validate_addresses(&addresses)?;
let client = Client::builder()
.no_proxy()
@@ -152,6 +178,13 @@ impl Broker {
}
}
fn validate_addresses(addresses: &[SocketAddr]) -> Result<()> {
if addresses.is_empty() || addresses.iter().any(|address| prohibited(address.ip())) {
return Err(HostError::new("EXTENSION_NETWORK_ADDRESS_DENIED"));
}
Ok(())
}
fn canonical_origin(value: &str) -> Result<String> {
let url =
Url::parse(value).map_err(|_| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"))?;
@@ -172,6 +205,26 @@ fn origin(url: &Url) -> Result<String> {
Ok(format!("https://{host}:{port}"))
}
fn origin_host(origin: &str) -> &str {
origin
.strip_prefix("https://")
.unwrap_or(origin)
.rsplit_once(':')
.map_or(origin, |(host, _)| host)
}
fn normalized_host(value: &str) -> Result<String> {
if value.is_empty() || value.contains(['/', '@', ':', '[', ']']) {
return Err(HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"));
}
let url = Url::parse(&format!("https://{value}/"))
.map_err(|_| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"))?;
url.host_str()
.filter(|host| *host == value.to_ascii_lowercase())
.map(str::to_owned)
.ok_or_else(|| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"))
}
fn validate_url(url: &Url) -> Result<()> {
if url.scheme() != "https"
|| url.host_str().is_none()
@@ -223,6 +276,34 @@ fn prohibited(ip: IpAddr) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::extension_permit::{Authority, ExecutionKind};
use std::collections::BTreeMap;
fn claims(origin: &str, address: Option<&str>) -> Claims {
let host = Url::parse(origin).unwrap().host_str().unwrap().to_owned();
let mut permissions = BTreeSet::from([format!("{PERMISSION_PREFIX}{origin}")]);
if let Some(address) = address {
permissions.insert(format!("{ADDRESS_PREFIX}{host}={address}"));
}
Claims {
kind: ExecutionKind::Mcp,
source: "https://catalog.example/".into(),
namespace: "examples".into(),
package_id: "network-probe".into(),
version: "1.0.0".into(),
archive_sha256: "a".repeat(64),
tree_sha256: "b".repeat(64),
signer_sha256: "c".repeat(64),
entry: "probe.exe".into(),
arguments: Vec::new(),
environment: BTreeMap::new(),
permissions,
vault_id: uuid::Uuid::new_v4().to_string(),
platform: "windows".into(),
policy_version: "1".into(),
expires_at_ms: 120_000,
}
}
#[test]
fn only_exact_https_origins_are_accepted() {
@@ -269,5 +350,56 @@ mod tests {
}
assert!(!prohibited("8.8.8.8".parse().unwrap()));
assert!(!prohibited("2606:4700:4700::1111".parse().unwrap()));
for _ in 0..100 {
let rebound = [
"160.202.254.170:443".parse().unwrap(),
"169.254.169.254:443".parse().unwrap(),
];
assert_eq!(
validate_addresses(&rebound).unwrap_err().code,
"EXTENSION_NETWORK_ADDRESS_DENIED"
);
}
}
#[test]
#[ignore = "需要公开 DNS 和 TLS;由 C-02 生产验收驱动显式执行"]
fn authorized_public_https_and_redirect_policy_pass_production_matrix() {
let authority = Authority::default();
let claims = claims("https://acm.kronecker.cc:18443/", Some("160.202.254.170"));
let make = || {
let lease = authority
.lease(&authority.issue(&claims, 1).unwrap(), &claims, 1)
.unwrap();
Broker::new(lease, &claims).unwrap()
};
let mut broker = make();
for _ in 0..20 {
let response = broker
.fetch(FetchRequest {
url: "https://acm.kronecker.cc:18443/ok".into(),
method: "GET".into(),
body: String::new(),
content_type: None,
})
.unwrap();
assert_eq!(response.status, 200);
assert_eq!(response.body, "OpenNexus C-02 controlled TLS endpoint");
}
for _ in 0..2 {
let mut broker = make();
for _ in 0..50 {
let response = broker
.fetch(FetchRequest {
url: "https://acm.kronecker.cc:18443/redirect".into(),
method: "GET".into(),
body: String::new(),
content_type: None,
})
.unwrap();
assert_eq!(response.status, 302);
assert!(response.body.is_empty());
}
}
}
}
+28 -3
View File
@@ -87,8 +87,15 @@ fn main() {
std::io::stdout().flush().unwrap();
let ping = read(&mut input);
assert!(ping.contains("server-ping") && ping.contains("result"));
if args[1] != "mcp_idle_change" { println!(r#"{{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}}"#); }
if args[1] != "mcp_idle_change" && args[1] != "mcp_twenty" { println!(r#"{{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}}"#); }
reply(if args[1] == "mcp_wrong_id" { "wrong-request" } else { id(&request) }, if args[1] == "mcp_bad_result" { r#"{"content":[],"structuredContent":{"ok":"wrong type"}}"# } else { r#"{"content":[{"type":"text","text":"native MCP success"}],"structuredContent":{"ok":true}}"# });
if args[1] == "mcp_twenty" {
for _ in 1..20 {
let request = read(&mut input);
assert!(request.contains("tools/call"));
reply(id(&request), r#"{"content":[{"type":"text","text":"native MCP success"}],"structuredContent":{"ok":true}}"#);
}
}
if args[1] == "mcp_idle_change" {
std::thread::sleep(Duration::from_millis(50));
println!(r#"{{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}}"#);
@@ -148,12 +155,24 @@ fn main() {
let _ = child.wait();
std::process::exit(84);
}
if args.get(1).is_some_and(|s| s == "child_udp_100") {
for _ in 0..100 {
let status = std::process::Command::new(std::env::current_exe().unwrap())
.args(["udp", &args[2]])
.status()
.unwrap();
if !matches!(status.code(), Some(0 | 77)) {
std::process::exit(87);
}
}
std::process::exit(0);
}
if args.get(1).is_some_and(|s| s == "wait") {
std::thread::sleep(Duration::from_secs(120));
std::process::exit(84);
}
if args.get(1).is_some_and(|s| s == "launch") {
let expected = [
let mut expected = [
"launch",
"",
"space value",
@@ -163,7 +182,13 @@ fn main() {
"slash\\\"quote",
"&|%PATH%",
"line\nbreak",
];
]
.into_iter()
.map(str::to_owned)
.collect::<Vec<_>>();
expected.extend((8..100).map(|index| {
format!(r#"attack-{index} & | < > ^ %COMSPEC% $(echo injected) \" \\"#)
}));
if args[1..] != expected {
std::process::exit(82);
}