test: 补全C-02沙箱验收矩阵
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user