feat: 添加扩展授权HTTPS代理

This commit is contained in:
2026-09-10 06:01:52 +08:00
parent f834805b68
commit d01688bdea
9 changed files with 367 additions and 6 deletions
+3
View File
@@ -1774,6 +1774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -4264,7 +4265,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
+1 -1
View File
@@ -26,7 +26,7 @@ tempfile = "3"
fs2 = "0.4"
tauri = { version = "2", optional = true, features = ["tray-icon"] }
rfd = { version = "0.15", optional = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true }
base64 = { version = "0.22", optional = true }
tokio = { version = "1", features = ["rt", "sync", "time", "macros"], optional = true }
hmac = { version = "0.12", default-features = false }
+40 -2
View File
@@ -433,6 +433,20 @@ fn run_with_access(
now_ms()?,
)?
};
let network = {
let credentials = spec
.credentials
.lock()
.map_err(|_| HostError::new("CREDENTIALS_LOCKED"))?;
let mut lease = spec
.authority
.lease(&spec.permit, &spec.claims, now_ms()?)?;
lease.bind_credential(credentials.lock_signal());
if credentials.is_locked() {
return Err(HostError::new("CREDENTIALS_LOCKED"));
}
crate::extension_network_broker::Broker::new(lease, &spec.claims)?
};
let (suspended, io) = prepared.create_suspended_with_stdio(profile, &entry)?;
(spec.before_resume)(&spec.claims)?;
if control.stop.load(Ordering::Acquire) {
@@ -444,7 +458,7 @@ fn run_with_access(
{
*control.job.lock().unwrap() = Some(running.test_job()?);
}
let mut session = Session::new(&running, io)?;
let mut session = Session::new_with_network(&running, io, Some(network))?;
session.initialize(&control.stop)?;
let tools = session.refresh_tools(&control.stop)?;
*control.identity.lock().unwrap_or_else(|e| e.into_inner()) = Some(running.call_identity()?);
@@ -575,7 +589,13 @@ mod tests {
entry: "entry.exe".into(),
arguments: vec![mode.into()],
environment: BTreeMap::new(),
permissions: Default::default(),
permissions: if mode == "mcp_network_denied" {
["network.https:https://127.0.0.1/".into()]
.into_iter()
.collect()
} else {
Default::default()
},
vault_id: vault_id.clone(),
platform: "windows".into(),
policy_version: "1".into(),
@@ -657,6 +677,24 @@ mod tests {
.unwrap(),
0
);
let denied_network = unsafe { registry.start(make("mcp_network_denied")) }.unwrap();
wait_for(|| denied_network.snapshot().status != Status::Starting);
assert_eq!(denied_network.snapshot().status, Status::Ready);
let review = denied_network
.review("echo".into(), json!({}))
.unwrap()
.wait(Duration::from_secs(5))
.unwrap();
assert!(denied_network
.invoke_confirmed(review.review_id)
.unwrap()
.wait(Duration::from_secs(5))
.is_ok());
denied_network.stop();
wait_for(|| {
registry.reap();
registry.entries.is_empty()
});
let second = unsafe { registry.start(make("mcp_cancel")) }.unwrap();
wait_for(|| second.snapshot().status != Status::Starting);
assert_eq!(
+33 -2
View File
@@ -93,9 +93,17 @@ pub struct Session<'a, 'p> {
tools_changed: bool,
catalog: Option<crate::extension_mcp_tools::Catalog>,
calls: crate::extension_call_authorization::Gate,
network: Option<crate::extension_network_broker::Broker>,
}
impl<'a, 'p> Session<'a, 'p> {
pub fn new(process: &'a Running<'p>, io: HostIo) -> Result<Self> {
Self::new_with_network(process, io, None)
}
pub fn new_with_network(
process: &'a Running<'p>,
io: HostIo,
network: Option<crate::extension_network_broker::Broker>,
) -> Result<Self> {
Ok(Self {
process,
pump: process.start_io(io)?,
@@ -106,6 +114,7 @@ impl<'a, 'p> Session<'a, 'p> {
tools_changed: false,
catalog: None,
calls: crate::extension_call_authorization::Gate::new(process.call_identity()?),
network,
})
}
pub fn initialize(&mut self, cancel: &AtomicBool) -> Result<String> {
@@ -303,8 +312,30 @@ impl<'a, 'p> Session<'a, 'p> {
return Ok(false);
};
if let Some(id) = &message.id {
self.send(if method == "ping" { json!({"jsonrpc":"2.0","id":id,"result":{}}) }
else { json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"Method not supported"}}) })?;
let response = if method == "ping" {
json!({"jsonrpc":"2.0","id":id,"result":{}})
} else if method == "opennexus/network.fetch" {
let result = message
.params
.clone()
.ok_or_else(invalid)
.and_then(|params| serde_json::from_value(params).map_err(|_| invalid()))
.and_then(|request| {
self.network
.as_mut()
.ok_or_else(|| HostError::new("EXTENSION_NETWORK_PERMISSION_DENIED"))?
.fetch(request)
});
match result {
Ok(value) => json!({"jsonrpc":"2.0","id":id,"result":value}),
Err(error) => {
json!({"jsonrpc":"2.0","id":id,"error":{"code":-32001,"message":error.code}})
}
}
} else {
json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"Method not supported"}})
};
self.send(response)?;
} else if method == "notifications/tools/list_changed" {
self.tools_changed = true;
self.catalog = None;
@@ -0,0 +1,273 @@
//! 由 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::BTreeSet,
io::Read,
net::{IpAddr, SocketAddr, ToSocketAddrs},
time::{Duration, Instant},
};
const PERMISSION_PREFIX: &str = "network.https:";
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<String>,
}
fn default_method() -> String {
"GET".into()
}
#[derive(Serialize)]
pub struct FetchResponse {
pub status: u16,
pub body: String,
pub content_type: Option<String>,
}
pub struct Broker {
lease: Lease,
origins: BTreeSet<String>,
calls: Vec<Instant>,
}
impl Broker {
pub fn new(lease: Lease, claims: &Claims) -> Result<Self> {
let mut origins = BTreeSet::new();
for permission in &claims.permissions {
let Some(value) = permission.strip_prefix(PERMISSION_PREFIX) else {
continue;
};
origins.insert(canonical_origin(value)?);
}
Ok(Self {
lease,
origins,
calls: Vec::new(),
})
}
pub fn fetch(&mut self, request: FetchRequest) -> Result<FetchResponse> {
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<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 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 canonical_origin(value: &str) -> Result<String> {
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<String> {
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 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::*;
#[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()));
}
}
+3
View File
@@ -79,6 +79,9 @@ mod extension_revocation;
#[cfg(all(windows, feature = "desktop"))]
pub mod extension_file_broker;
#[cfg(all(windows, feature = "desktop"))]
pub mod extension_network_broker;
#[cfg(windows)]
pub mod extension_stdio;
@@ -77,6 +77,12 @@ fn main() {
request = read(&mut input);
assert!(request.contains("tools/call"));
}
if args[1] == "mcp_network_denied" {
println!("{}", r#"{"jsonrpc":"2.0","id":"network-request","method":"opennexus/network.fetch","params":{"url":"https://127.0.0.1/","method":"GET"}}"#);
std::io::stdout().flush().unwrap();
let response = read(&mut input);
assert!(response.contains("network-request") && response.contains("EXTENSION_NETWORK_ADDRESS_DENIED"));
}
println!(r#"{{"jsonrpc":"2.0","id":"server-ping","method":"ping"}}"#);
std::io::stdout().flush().unwrap();
let ping = read(&mut input);