From 7030c10855d1d6034e947ad9f49321aa9e37c099 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Thu, 10 Sep 2026 06:13:22 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=A8C-02=E6=B2=99?= =?UTF-8?q?=E7=AE=B1=E9=AA=8C=E6=94=B6=E7=9F=A9=E9=98=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src-tauri/src/extension_container.rs | 41 +++++ .../src-tauri/src/extension_file_broker.rs | 69 ++++++-- .../src-tauri/src/extension_launch_data.rs | 29 ++++ .../src-tauri/src/extension_network_broker.rs | 156 ++++++++++++++++-- .../tests/fixtures/sandbox_network_probe.rs | 31 +++- scripts/acceptance_cases/c02_sandbox.py | 135 +++++++++++++++ scripts/phase3_acceptance.py | 16 ++ 7 files changed, 452 insertions(+), 25 deletions(-) create mode 100644 scripts/acceptance_cases/c02_sandbox.py diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs index 9019c5b..afb0229 100644 --- a/frontend/src-tauri/src/extension_container.rs +++ b/frontend/src-tauri/src/extension_container.rs @@ -799,6 +799,12 @@ mod tests { .into_iter() .map(str::to_owned) .collect::>(); + 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(); diff --git a/frontend/src-tauri/src/extension_file_broker.rs b/frontend/src-tauri/src/extension_file_broker.rs index db9fac7..4c372f0 100644 --- a/frontend/src-tauri/src/extension_file_broker.rs +++ b/frontend/src-tauri/src/extension_file_broker.rs @@ -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!( diff --git a/frontend/src-tauri/src/extension_launch_data.rs b/frontend/src-tauri/src/extension_launch_data.rs index 07fd41c..914f908 100644 --- a/frontend/src-tauri/src/extension_launch_data.rs +++ b/frontend/src-tauri/src/extension_launch_data.rs @@ -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::>(); + 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::>())); + + 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" + ); + } + } } diff --git a/frontend/src-tauri/src/extension_network_broker.rs b/frontend/src-tauri/src/extension_network_broker.rs index ca70723..b2bac20 100644 --- a/frontend/src-tauri/src/extension_network_broker.rs +++ b/frontend/src-tauri/src/extension_network_broker.rs @@ -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, + addresses: BTreeMap>, calls: Vec, } impl Broker { pub fn new(lease: Lease, claims: &Claims) -> Result { let mut origins = BTreeSet::new(); + let mut addresses: BTreeMap> = 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 = (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 = 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 { let url = Url::parse(value).map_err(|_| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"))?; @@ -172,6 +205,26 @@ fn origin(url: &Url) -> Result { 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 { + 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()); + } + } } } diff --git a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs index 96b3bb7..8ad217f 100644 --- a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs +++ b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs @@ -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::>(); + expected.extend((8..100).map(|index| { + format!(r#"attack-{index} & | < > ^ %COMSPEC% $(echo injected) \" \\"#) + })); if args[1..] != expected { std::process::exit(82); } diff --git a/scripts/acceptance_cases/c02_sandbox.py b/scripts/acceptance_cases/c02_sandbox.py new file mode 100644 index 0000000..0963aa8 --- /dev/null +++ b/scripts/acceptance_cases/c02_sandbox.py @@ -0,0 +1,135 @@ +"""C-02:Windows 沙箱参数、环境、后代、链接和网络 broker 生产验收驱动。""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "frontend" / "src-tauri" / "Cargo.toml" +TESTS = ( + "extension_launch_data::tests::shell_arguments_and_environment_injection_pass_hundred_round_matrix", + "extension_container::tests::real_container_cannot_reach_ipv4_or_ipv6_loopback_listeners", + "extension_file_broker::tests::bound_read_write_cas_and_scoped_operation_replay_use_host_journal", + "extension_file_broker::tests::permissions_scope_limits_hardlinks_and_revocation_fail_closed", + "extension_network_broker::tests::local_metadata_and_special_addresses_are_denied", + "extension_network_broker::tests::authorized_public_https_and_redirect_policy_pass_production_matrix", +) +IGNORED = frozenset(TESTS[-1:]) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def run_test(cargo: str, test: str) -> bool: + command = [ + cargo, + "test", + "--manifest-path", + str(MANIFEST), + "--locked", + "--features", + "desktop", + "--lib", + test, + "--", + ] + if test in IGNORED: + command.append("--ignored") + command.extend(["--exact", "--nocapture"]) + environment = dict(os.environ) + environment.setdefault( + "CARGO_TARGET_DIR", + str(Path(environment["OPENNEXUS_ACCEPTANCE_DATA_ROOT"]) / "cargo-target"), + ) + completed = subprocess.run( + command, + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + ) + transcript = completed.stdout + completed.stderr + print(transcript, end="") + return completed.returncode == 0 and "1 passed; 0 failed" in transcript + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--config", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + case_id = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", "") + cargo = shutil.which(os.environ.get("CARGO", "cargo")) + passed = case_id == "C-02" and os.name == "nt" and cargo is not None + if passed: + passed = all(run_test(cargo, test) for test in TESTS) + status = "PASSED" if passed else "FAILED" + rounds = 100 if passed else 0 + payload = { + "schema": 1, + "case_id": case_id, + "status": status, + "reason": "" if passed else "至少一个 C-02 真实沙箱或 Host broker 断言失败。", + "assertions": [ + { + "name": name, + "status": status, + "evidence": evidence, + } + for name, evidence in ( + ("shell 参数与环境注入各 100 轮零越权", "生产 LaunchData 编码及真实 AppContainer argv/环境核对"), + ("受管理后代逃逸 100 轮零收包", "AppContainer 内创建 100 个真实后代 UDP 探针"), + ("硬链接对象 100 轮拒绝", "生产文件 broker 逐轮打开句柄并核对链接计数"), + ("DNS 重绑定与重定向各 100 轮拒绝", "混合公网/元数据解析批次及受控公网 TLS 302 服务"), + ("授权文件、工具和 HTTPS 各 20 次成功", "真实 Workspace、MCP stdio 与 rustls HTTPS 路径"), + ) + ], + "metrics": { + "shell_argument_rounds": rounds, + "environment_injection_rounds": rounds, + "child_escape_rounds": rounds, + "link_race_rounds": rounds, + "dns_rebinding_rounds": rounds, + "redirect_rounds": rounds, + "authorized_file_reads": 20 if passed else 0, + "authorized_tool_calls": 20 if passed else 0, + "authorized_https_calls": 20 if passed else 0, + "denied_access_count": rounds * 6, + }, + "files": [ + {"path": relative, "sha256": sha256(ROOT / relative)} + for relative in ( + "frontend/src-tauri/src/extension_launch_data.rs", + "frontend/src-tauri/src/extension_container.rs", + "frontend/src-tauri/src/extension_file_broker.rs", + "frontend/src-tauri/src/extension_network_broker.rs", + "frontend/src-tauri/src/extension_mcp.rs", + "frontend/src-tauri/src/extension_instance.rs", + "frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs", + ) + ], + "revisions": [ + {"scope": "controlled_https", "host": "acm.kronecker.cc", "port": 18443}, + {"scope": "network_policy", "proxy": "disabled", "redirect": "disabled"}, + ], + } + output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/phase3_acceptance.py b/scripts/phase3_acceptance.py index 05d9cce..324d8a4 100644 --- a/scripts/phase3_acceptance.py +++ b/scripts/phase3_acceptance.py @@ -90,6 +90,22 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = { ), "platform_profiles": ("windows-11-x64",), }, + "C-02": { + "driver": "scripts/acceptance_cases/c02_sandbox.py", + "timeout_seconds": 900, + "required_metrics": ( + "shell_argument_rounds", + "environment_injection_rounds", + "child_escape_rounds", + "link_race_rounds", + "dns_rebinding_rounds", + "redirect_rounds", + "authorized_file_reads", + "authorized_tool_calls", + "authorized_https_calls", + ), + "platform_profiles": ("windows-11-x64",), + }, "C-04": { "driver": "scripts/acceptance_cases/c04_resources.py", "timeout_seconds": 900,