//! 由 Host 执行的扩展 HTTPS 代理。沙箱进程本身始终不获得网络能力。 use crate::{ extension_permit::{Claims, Lease}, workspace::{HostError, Result}, }; use reqwest::{blocking::Client, redirect::Policy, Method, Url}; use serde::{Deserialize, Serialize}; use std::{ 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; #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub struct FetchRequest { pub url: String, #[serde(default = "default_method")] pub method: String, #[serde(default)] pub body: String, #[serde(default)] pub content_type: Option, } fn default_method() -> String { "GET".into() } #[derive(Serialize)] pub struct FetchResponse { pub status: u16, pub body: String, pub content_type: Option, } 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 { 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(), }) } pub fn fetch(&mut self, request: FetchRequest) -> Result { self.lease.check()?; let now = Instant::now(); self.calls .retain(|called| now.duration_since(*called) < Duration::from_secs(60)); if self.calls.len() >= MAX_CALLS_PER_MINUTE { return Err(HostError::new("EXTENSION_NETWORK_RATE_LIMITED")); } self.calls.push(now); if request.url.len() > 4096 || request.body.len() > MAX_REQUEST_BYTES || request .content_type .as_ref() .is_some_and(|value| value.len() > 128 || value.chars().any(char::is_control)) { return Err(HostError::new("EXTENSION_NETWORK_REQUEST_INVALID")); } let url = Url::parse(&request.url) .map_err(|_| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; validate_url(&url)?; if !self.origins.contains(&origin(&url)?) { return Err(HostError::new("EXTENSION_NETWORK_PERMISSION_DENIED")); } let host = url .host_str() .ok_or_else(|| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; let port = url .port_or_known_default() .ok_or_else(|| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; 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() .redirect(Policy::none()) .connect_timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(15)) .resolve_to_addrs(host, &addresses) .build() .map_err(|_| HostError::new("EXTENSION_NETWORK_UNAVAILABLE"))?; let method = match request.method.as_str() { "GET" => Method::GET, "POST" => Method::POST, _ => return Err(HostError::new("EXTENSION_NETWORK_METHOD_DENIED")), }; if method == Method::GET && !request.body.is_empty() { return Err(HostError::new("EXTENSION_NETWORK_REQUEST_INVALID")); } let mut builder = client.request(method, url); if !request.body.is_empty() { builder = builder.body(request.body); } if let Some(content_type) = request.content_type { builder = builder.header(reqwest::header::CONTENT_TYPE, content_type); } let response = builder .send() .map_err(|_| HostError::new("EXTENSION_NETWORK_REQUEST_FAILED"))?; let status = response.status().as_u16(); let content_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .map(|value| value.chars().take(128).collect()); let mut bytes = Vec::new(); response .take((MAX_RESPONSE_BYTES + 1) as u64) .read_to_end(&mut bytes) .map_err(|_| HostError::new("EXTENSION_NETWORK_RESPONSE_INVALID"))?; if bytes.len() > MAX_RESPONSE_BYTES { return Err(HostError::new("EXTENSION_NETWORK_RESPONSE_TOO_LARGE")); } let body = String::from_utf8(bytes) .map_err(|_| HostError::new("EXTENSION_NETWORK_RESPONSE_INVALID"))?; self.lease.check()?; Ok(FetchResponse { status, body, content_type, }) } } 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"))?; validate_url(&url).map_err(|_| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"))?; if url.path() != "/" || url.query().is_some() || url.fragment().is_some() { return Err(HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID")); } origin(&url) } fn origin(url: &Url) -> Result { let host = url .host_str() .ok_or_else(|| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; let port = url .port_or_known_default() .ok_or_else(|| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; 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() || !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { return Err(HostError::new("EXTENSION_NETWORK_REQUEST_INVALID")); } Ok(()) } fn prohibited(ip: IpAddr) -> bool { match ip { IpAddr::V4(ip) => { let octets = ip.octets(); ip.is_private() || ip.is_loopback() || ip.is_link_local() || ip.is_unspecified() || ip.is_multicast() || octets[0] == 0 || octets[0] >= 224 || (octets[0] == 100 && (64..=127).contains(&octets[1])) || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) || (octets[0] == 192 && octets[1] == 0 && octets[2] == 2) || (octets[0] == 192 && octets[1] == 88 && octets[2] == 99) || (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19)) || (octets[0] == 198 && octets[1] == 51 && octets[2] == 100) || (octets[0] == 203 && octets[1] == 0 && octets[2] == 113) } IpAddr::V6(ip) => { let segments = ip.segments(); let first = segments[0]; ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() || (first & 0xe000) != 0x2000 || (first & 0xfe00) == 0xfc00 || (first & 0xffc0) == 0xfe80 || (segments[0] == 0x2001 && segments[1] == 0x0db8) || ip .to_ipv4_mapped() .is_some_and(|mapped| prohibited(mapped.into())) } } } #[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() { assert_eq!( canonical_origin("https://example.com/").unwrap(), "https://example.com:443" ); assert_eq!( canonical_origin("https://example.com:8443/").unwrap(), "https://example.com:8443" ); for value in [ "http://example.com/", "https://user@example.com/", "https://example.com/path", ] { assert_eq!( canonical_origin(value).unwrap_err().code, "EXTENSION_NETWORK_PERMISSION_INVALID" ); } } #[test] fn local_metadata_and_special_addresses_are_denied() { for value in [ "127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "100.64.0.1", "169.254.169.254", "192.0.2.1", "198.18.0.1", "198.51.100.1", "203.0.113.1", "0.0.0.0", "::1", "fc00::1", "fe80::1", "2001:db8::1", ] { assert!(prohibited(value.parse().unwrap()), "{value}"); } 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()); } } } }