| 1 | //! Request-time authority for MCP transports and every OAuth HTTP operation. |
| 2 | //! |
| 3 | //! Direct public endpoints use validated DNS pins even when configured by an |
| 4 | //! operator. Explicit local endpoints/private-network opt-ins and selected |
| 5 | //! operator proxy routes carry authority only on their exact configured origin. |
| 6 | //! Model-added endpoints and server-selected secondary origins stay public. |
| 7 | |
| 8 | use std::collections::HashMap; |
| 9 | use std::net::{IpAddr, SocketAddr}; |
| 10 | use std::sync::{Arc, Mutex}; |
| 11 | use std::time::Duration; |
| 12 | |
| 13 | use anyhow::{Context, Result, bail}; |
| 14 | use reqwest::{Method, Request, Response, Url, header}; |
| 15 | |
| 16 | use crate::network_policy::{Decision, NetworkPolicyDecider}; |
| 17 | use crate::tools::web::guard::{guarded_reqwest_client_builder, is_restricted_ip}; |
| 18 | |
| 19 | #[derive(Clone)] |
| 20 | pub(super) struct McpHttpClient { |
| 21 | origin: String, |
| 22 | operator_configured: bool, |
| 23 | private_origin_allowed: bool, |
| 24 | #[cfg(test)] |
| 25 | dns_answers: Arc<Mutex<Option<std::collections::VecDeque<Vec<SocketAddr>>>>>, |
| 26 | reviewed_plugin: bool, |
| 27 | network_policy: Option<NetworkPolicyDecider>, |
| 28 | connect_timeout: Duration, |
| 29 | read_timeout: Duration, |
| 30 | default_headers: header::HeaderMap, |
| 31 | request_builder: reqwest::Client, |
| 32 | clients: Arc<Mutex<HashMap<String, reqwest::Client>>>, |
| 33 | } |
| 34 | |
| 35 | impl McpHttpClient { |
| 36 | pub(super) fn new( |
| 37 | url: &str, |
| 38 | runtime_added: bool, |
| 39 | reviewed_plugin: bool, |
| 40 | allow_private_network: bool, |
| 41 | network_policy: Option<&NetworkPolicyDecider>, |
| 42 | connect_timeout: Duration, |
| 43 | read_timeout: Duration, |
| 44 | ) -> Result<Self> { |
| 45 | let url = Url::parse(url).context("invalid MCP HTTP endpoint")?; |
| 46 | validate_url(&url)?; |
| 47 | validate_network_policy(&url, network_policy)?; |
| 48 | if (runtime_added || reviewed_plugin) && url_has_credentials(&url) { |
| 49 | bail!("MCP HTTP URL must not contain credentials; use configured headers"); |
| 50 | } |
| 51 | Ok(Self { |
| 52 | origin: url.origin().ascii_serialization(), |
| 53 | operator_configured: !runtime_added, |
| 54 | private_origin_allowed: !runtime_added |
| 55 | && (allow_private_network || explicit_local_target(&url)), |
| 56 | #[cfg(test)] |
| 57 | dns_answers: Arc::new(Mutex::new(None)), |
| 58 | reviewed_plugin, |
| 59 | network_policy: network_policy.cloned(), |
| 60 | connect_timeout, |
| 61 | read_timeout, |
| 62 | default_headers: header::HeaderMap::new(), |
| 63 | request_builder: guarded_reqwest_client_builder().build()?, |
| 64 | clients: Arc::new(Mutex::new(HashMap::new())), |
| 65 | }) |
| 66 | } |
| 67 | |
| 68 | pub(super) fn with_default_headers(mut self, headers: header::HeaderMap) -> Self { |
| 69 | self.default_headers = headers; |
| 70 | self |
| 71 | } |
| 72 | |
| 73 | pub(super) fn get(&self, url: &str) -> reqwest::RequestBuilder { |
| 74 | self.request_builder.get(url) |
| 75 | } |
| 76 | |
| 77 | pub(super) fn post(&self, url: &str) -> reqwest::RequestBuilder { |
| 78 | self.request_builder.post(url) |
| 79 | } |
| 80 | |
| 81 | pub(super) async fn send(&self, request: reqwest::RequestBuilder) -> Result<Response> { |
| 82 | self.execute(request.build()?, true).await |
| 83 | } |
| 84 | |
| 85 | pub(super) async fn execute( |
| 86 | &self, |
| 87 | mut request: Request, |
| 88 | follow_redirects: bool, |
| 89 | ) -> Result<Response> { |
| 90 | if request.url().origin().ascii_serialization() == self.origin { |
| 91 | for (name, value) in &self.default_headers { |
| 92 | if !request.headers().contains_key(name) { |
| 93 | request.headers_mut().insert(name.clone(), value.clone()); |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | let timeout = request.timeout().copied().unwrap_or(self.read_timeout); |
| 98 | tokio::time::timeout(timeout, self.execute_inner(request, follow_redirects)) |
| 99 | .await |
| 100 | .context("MCP HTTP request timed out")? |
| 101 | } |
| 102 | |
| 103 | async fn execute_inner( |
| 104 | &self, |
| 105 | mut request: Request, |
| 106 | follow_redirects: bool, |
| 107 | ) -> Result<Response> { |
| 108 | for redirect_count in 0..=5 { |
| 109 | let url = request.url().clone(); |
| 110 | let client = self.client_for_target(&url).await?; |
| 111 | // MCP and OAuth requests have buffered bodies. Keep the exact request |
| 112 | // to replay only after the Location has passed the same guard. |
| 113 | let next_request = request |
| 114 | .try_clone() |
| 115 | .context("MCP request body cannot be replayed")?; |
| 116 | let response = client.execute(request).await?; |
| 117 | if !follow_redirects |
| 118 | || !matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308) |
| 119 | { |
| 120 | return Ok(response); |
| 121 | } |
| 122 | let Some(location) = response.headers().get(header::LOCATION) else { |
| 123 | return Ok(response); |
| 124 | }; |
| 125 | if redirect_count == 5 { |
| 126 | bail!("MCP HTTP redirect limit exceeded"); |
| 127 | } |
| 128 | let next_url = url.join(location.to_str().context("invalid MCP redirect Location")?)?; |
| 129 | validate_url(&next_url)?; |
| 130 | if url_has_credentials(&next_url) { |
| 131 | bail!("MCP HTTP redirect must not contain credentials"); |
| 132 | } |
| 133 | if url.scheme() == "https" && next_url.scheme() != "https" { |
| 134 | bail!("MCP HTTP redirect would downgrade HTTPS"); |
| 135 | } |
| 136 | request = next_request; |
| 137 | if (matches!(response.status().as_u16(), 301 | 302) && request.method() == Method::POST) |
| 138 | || (response.status().as_u16() == 303 && request.method() != Method::HEAD) |
| 139 | { |
| 140 | *request.method_mut() = Method::GET; |
| 141 | *request.body_mut() = None; |
| 142 | request.headers_mut().remove(header::CONTENT_TYPE); |
| 143 | request.headers_mut().remove(header::CONTENT_LENGTH); |
| 144 | request.headers_mut().remove(header::TRANSFER_ENCODING); |
| 145 | } |
| 146 | if next_url.origin() != url.origin() { |
| 147 | // Custom headers can contain credentials under arbitrary names; |
| 148 | // retaining just Authorization/ Cookie exclusions is insufficient. |
| 149 | let mut headers = header::HeaderMap::new(); |
| 150 | for name in [header::ACCEPT, header::CONTENT_TYPE] { |
| 151 | if let Some(value) = request.headers().get(&name) { |
| 152 | headers.insert(name, value.clone()); |
| 153 | } |
| 154 | } |
| 155 | *request.headers_mut() = headers; |
| 156 | } |
| 157 | *request.url_mut() = next_url; |
| 158 | } |
| 159 | unreachable!("redirect loop is bounded") |
| 160 | } |
| 161 | |
| 162 | async fn client_for_target(&self, url: &Url) -> Result<reqwest::Client> { |
| 163 | validate_url(url)?; |
| 164 | let same_origin = url.origin().ascii_serialization() == self.origin; |
| 165 | if self.reviewed_plugin && !super::reviewed_redirect_matches_origin(url, &self.origin) { |
| 166 | bail!("MCP redirect leaves the reviewed plugin origin"); |
| 167 | } |
| 168 | validate_network_policy(url, self.network_policy.as_ref())?; |
| 169 | let operator_origin = self.operator_configured && same_origin; |
| 170 | if !operator_origin && url_has_credentials(url) { |
| 171 | bail!("MCP HTTP discovered URL must not contain credentials"); |
| 172 | } |
| 173 | let proxy = |
| 174 | super::configured_mcp_proxy(url, !operator_origin || self.reviewed_plugin, |key| { |
| 175 | std::env::var(key) |
| 176 | })?; |
| 177 | // A selected operator proxy resolves its own destinations. This is |
| 178 | // delegated proxy authority, never evidence of a local DNS pin. |
| 179 | let pin = if (self.private_origin_allowed && same_origin) || proxy.is_some() { |
| 180 | None |
| 181 | } else { |
| 182 | self.public_dns_pin(url).await? |
| 183 | }; |
| 184 | // Validate DNS before reusing a client too: a new private answer revokes |
| 185 | // this request. Each cached client itself remains pinned to its old public |
| 186 | // address, including reconnects after a keep-alive socket expires. |
| 187 | let key = format!("{}:{pin:?}", url.origin().ascii_serialization()); |
| 188 | if proxy.is_none() |
| 189 | && let Some(client) = self |
| 190 | .clients |
| 191 | .lock() |
| 192 | .unwrap_or_else(std::sync::PoisonError::into_inner) |
| 193 | .get(&key) |
| 194 | { |
| 195 | return Ok(client.clone()); |
| 196 | } |
| 197 | let mut builder = guarded_reqwest_client_builder() |
| 198 | .redirect(reqwest::redirect::Policy::none()) |
| 199 | .connect_timeout(self.connect_timeout) |
| 200 | .timeout(self.read_timeout); |
| 201 | let proxied = proxy.is_some(); |
| 202 | if let Some(proxy) = proxy { |
| 203 | builder = builder.proxy(proxy); |
| 204 | } else if let Some((host, address)) = pin { |
| 205 | builder = builder.resolve(&host, address); |
| 206 | } |
| 207 | let client = builder |
| 208 | .build() |
| 209 | .context("building guarded MCP HTTP client")?; |
| 210 | let mut clients = self |
| 211 | .clients |
| 212 | .lock() |
| 213 | .unwrap_or_else(std::sync::PoisonError::into_inner); |
| 214 | if !proxied && clients.len() < 32 { |
| 215 | clients.insert(key, client.clone()); |
| 216 | } |
| 217 | Ok(client) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | fn validate_network_policy(url: &Url, network_policy: Option<&NetworkPolicyDecider>) -> Result<()> { |
| 222 | let host = url.host_str().context("MCP URL has no host")?; |
| 223 | if let Some(policy) = network_policy { |
| 224 | match policy.evaluate(host, "mcp") { |
| 225 | Decision::Allow => {} |
| 226 | Decision::Deny => bail!("MCP HTTP destination blocked by network policy"), |
| 227 | Decision::Prompt => bail!("MCP HTTP destination requires network approval"), |
| 228 | } |
| 229 | } |
| 230 | Ok(()) |
| 231 | } |
| 232 | |
| 233 | fn validate_url(url: &Url) -> Result<()> { |
| 234 | if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { |
| 235 | bail!("MCP HTTP requires an http:// or https:// URL with a host"); |
| 236 | } |
| 237 | Ok(()) |
| 238 | } |
| 239 | |
| 240 | fn url_has_credentials(url: &Url) -> bool { |
| 241 | !url.username().is_empty() || url.password().is_some() |
| 242 | } |
| 243 | |
| 244 | impl McpHttpClient { |
| 245 | async fn public_dns_pin(&self, url: &Url) -> Result<Option<(String, SocketAddr)>> { |
| 246 | let host = url.host_str().context("MCP URL has no host")?; |
| 247 | let literal = host.trim_start_matches('[').trim_end_matches(']'); |
| 248 | if let Ok(ip) = literal.parse::<IpAddr>() { |
| 249 | if is_restricted_ip(&ip) { |
| 250 | bail!("MCP HTTP destination is a restricted IP address"); |
| 251 | } |
| 252 | return Ok(None); |
| 253 | } |
| 254 | let port = url.port_or_known_default().context("MCP URL has no port")?; |
| 255 | #[cfg(test)] |
| 256 | let injected = self |
| 257 | .dns_answers |
| 258 | .lock() |
| 259 | .unwrap() |
| 260 | .as_mut() |
| 261 | .map(|answers| answers.pop_front().expect("DNS fixture answer available")); |
| 262 | #[cfg(not(test))] |
| 263 | let injected: Option<Vec<SocketAddr>> = None; |
| 264 | let addresses: Vec<_> = if let Some(addresses) = injected { |
| 265 | addresses |
| 266 | } else { |
| 267 | tokio::time::timeout(self.connect_timeout, tokio::net::lookup_host((host, port))) |
| 268 | .await |
| 269 | .context("MCP HTTP DNS resolution timed out")? |
| 270 | .context("MCP HTTP DNS resolution failed")? |
| 271 | .collect() |
| 272 | }; |
| 273 | let address = validated_public_address(&addresses)?; |
| 274 | Ok(Some((host.to_string(), address))) |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | fn explicit_local_target(url: &Url) -> bool { |
| 279 | let Some(host) = url.host_str() else { |
| 280 | return false; |
| 281 | }; |
| 282 | let host = host.trim_end_matches('.'); |
| 283 | host.eq_ignore_ascii_case("localhost") |
| 284 | || host.to_ascii_lowercase().ends_with(".localhost") |
| 285 | || host |
| 286 | .trim_start_matches('[') |
| 287 | .trim_end_matches(']') |
| 288 | .parse::<IpAddr>() |
| 289 | .is_ok_and(|ip| is_restricted_ip(&ip)) |
| 290 | } |
| 291 | |
| 292 | fn validated_public_address(addresses: &[SocketAddr]) -> Result<SocketAddr> { |
| 293 | if addresses |
| 294 | .iter() |
| 295 | .any(|address| is_restricted_ip(&address.ip())) |
| 296 | { |
| 297 | bail!("MCP HTTP DNS resolved to a restricted IP address"); |
| 298 | } |
| 299 | addresses |
| 300 | .first() |
| 301 | .copied() |
| 302 | .context("MCP HTTP DNS resolved to no addresses") |
| 303 | } |
| 304 | |
| 305 | #[cfg(test)] |
| 306 | mod tests { |
| 307 | use super::*; |
| 308 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; |
| 309 | use tokio::net::TcpListener; |
| 310 | |
| 311 | fn client(url: &str, runtime_added: bool) -> McpHttpClient { |
| 312 | McpHttpClient::new( |
| 313 | url, |
| 314 | runtime_added, |
| 315 | false, |
| 316 | false, |
| 317 | None, |
| 318 | Duration::from_secs(1), |
| 319 | Duration::from_secs(2), |
| 320 | ) |
| 321 | .unwrap() |
| 322 | } |
| 323 | |
| 324 | async fn reply_once(listener: TcpListener, response: String) -> String { |
| 325 | let (mut socket, _) = listener.accept().await.unwrap(); |
| 326 | let mut bytes = Vec::new(); |
| 327 | let mut buffer = [0u8; 2048]; |
| 328 | loop { |
| 329 | let n = socket.read(&mut buffer).await.unwrap(); |
| 330 | assert!(n > 0); |
| 331 | bytes.extend_from_slice(&buffer[..n]); |
| 332 | if bytes.windows(4).any(|part| part == b"\r\n\r\n") { |
| 333 | break; |
| 334 | } |
| 335 | } |
| 336 | socket.write_all(response.as_bytes()).await.unwrap(); |
| 337 | String::from_utf8(bytes).unwrap() |
| 338 | } |
| 339 | |
| 340 | #[tokio::test] |
| 341 | async fn model_added_http_rejects_private_literals_and_local_dns_before_connecting() { |
| 342 | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 343 | let port = listener.local_addr().unwrap().port(); |
| 344 | for host in [ |
| 345 | "127.0.0.1", |
| 346 | "127.1", |
| 347 | "2130706433", |
| 348 | "0x7f000001", |
| 349 | "localhost", |
| 350 | "[::1]", |
| 351 | "[::ffff:127.0.0.1]", |
| 352 | "169.254.169.254", |
| 353 | "10.0.0.1", |
| 354 | ] { |
| 355 | let url = format!("http://{host}:{port}/mcp"); |
| 356 | let client = client(&url, true); |
| 357 | for method in [Method::GET, Method::POST] { |
| 358 | let request = client.request_builder.request(method, &url); |
| 359 | let error = client.send(request).await.unwrap_err(); |
| 360 | assert!( |
| 361 | format!("{error:#}").contains("restricted"), |
| 362 | "{host}: {error:#}" |
| 363 | ); |
| 364 | } |
| 365 | } |
| 366 | assert!( |
| 367 | tokio::time::timeout(Duration::from_millis(30), listener.accept()) |
| 368 | .await |
| 369 | .is_err() |
| 370 | ); |
| 371 | } |
| 372 | |
| 373 | #[test] |
| 374 | fn mixed_dns_answers_and_empty_resolution_fail_closed() { |
| 375 | let public = "8.8.8.8:443".parse().unwrap(); |
| 376 | for private in [ |
| 377 | "127.0.0.1:443", |
| 378 | "10.0.0.2:443", |
| 379 | "169.254.169.254:443", |
| 380 | "[fc00::1]:443", |
| 381 | ] { |
| 382 | let private = private.parse().unwrap(); |
| 383 | assert!(validated_public_address(&[public, private]).is_err()); |
| 384 | assert!(validated_public_address(&[private, public]).is_err()); |
| 385 | } |
| 386 | assert!(validated_public_address(&[]).is_err()); |
| 387 | assert_eq!(validated_public_address(&[public]).unwrap(), public); |
| 388 | } |
| 389 | |
| 390 | #[tokio::test] |
| 391 | async fn operator_origin_remains_usable_but_does_not_authorize_private_redirects() { |
| 392 | let _env = crate::test_support::lock_test_env(); |
| 393 | let _proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*"); |
| 394 | let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 395 | for status in [301, 302, 303, 307, 308] { |
| 396 | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 397 | let url = format!("http://{}/mcp", listener.local_addr().unwrap()); |
| 398 | let response = format!( |
| 399 | "HTTP/1.1 {status} Redirect\r\nLocation: http://{}/private\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", |
| 400 | destination.local_addr().unwrap() |
| 401 | ); |
| 402 | let server = tokio::spawn(reply_once(listener, response)); |
| 403 | let client = client(&url, false); |
| 404 | let error = client |
| 405 | .send( |
| 406 | client |
| 407 | .post(&url) |
| 408 | .header("Authorization", "Bearer fixture") |
| 409 | .body("{}"), |
| 410 | ) |
| 411 | .await |
| 412 | .unwrap_err(); |
| 413 | assert!(format!("{error:#}").contains("restricted"), "{error:#}"); |
| 414 | assert!(server.await.unwrap().contains("Bearer fixture")); |
| 415 | } |
| 416 | assert!( |
| 417 | tokio::time::timeout(Duration::from_millis(30), destination.accept()) |
| 418 | .await |
| 419 | .is_err() |
| 420 | ); |
| 421 | } |
| 422 | |
| 423 | #[tokio::test] |
| 424 | async fn redirect_stop_returns_response_without_following_even_same_origin() { |
| 425 | let _env = crate::test_support::lock_test_env(); |
| 426 | let _proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*"); |
| 427 | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 428 | let addr = listener.local_addr().unwrap(); |
| 429 | let url = format!("http://{addr}/token"); |
| 430 | let server = tokio::spawn(async move { |
| 431 | let (mut socket, _) = listener.accept().await.unwrap(); |
| 432 | let mut buf = [0u8; 2048]; |
| 433 | let read = socket.read(&mut buf).await.unwrap(); |
| 434 | assert!(read > 0, "fixture request must contain bytes"); |
| 435 | socket.write_all(b"HTTP/1.1 307 Redirect\r\nLocation: /capture\r\nConnection: close\r\nContent-Length: 0\r\n\r\n").await.unwrap(); |
| 436 | drop(socket); |
| 437 | tokio::time::timeout(Duration::from_millis(80), listener.accept()) |
| 438 | .await |
| 439 | .is_err() |
| 440 | }); |
| 441 | let client = client(&url, false); |
| 442 | let response = client |
| 443 | .execute( |
| 444 | client.post(&url).body("code=fixture").build().unwrap(), |
| 445 | false, |
| 446 | ) |
| 447 | .await |
| 448 | .unwrap(); |
| 449 | assert_eq!(response.status(), 307); |
| 450 | assert!(server.await.unwrap()); |
| 451 | } |
| 452 | |
| 453 | #[tokio::test] |
| 454 | async fn configured_local_same_origin_redirect_and_connection_reuse_work() { |
| 455 | let _env = crate::test_support::lock_test_env(); |
| 456 | let _proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*"); |
| 457 | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 458 | let url = format!("http://{}/mcp", listener.local_addr().unwrap()); |
| 459 | let server = tokio::spawn(async move { |
| 460 | let (mut socket, _) = listener.accept().await.unwrap(); |
| 461 | let mut buf = [0u8; 2048]; |
| 462 | let read = socket.read(&mut buf).await.unwrap(); |
| 463 | assert!(read > 0, "fixture request must contain bytes"); |
| 464 | socket |
| 465 | .write_all( |
| 466 | b"HTTP/1.1 307 Redirect\r\nLocation: /mcp/v2\r\nContent-Length: 0\r\n\r\n", |
| 467 | ) |
| 468 | .await |
| 469 | .unwrap(); |
| 470 | let size = socket.read(&mut buf).await.unwrap(); |
| 471 | assert!(String::from_utf8_lossy(&buf[..size]).starts_with("GET /mcp/v2 ")); |
| 472 | socket |
| 473 | .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nok") |
| 474 | .await |
| 475 | .unwrap(); |
| 476 | }); |
| 477 | let client = client(&url, false); |
| 478 | let response = client.send(client.get(&url)).await.unwrap(); |
| 479 | assert_eq!(response.text().await.unwrap(), "ok"); |
| 480 | server.await.unwrap(); |
| 481 | } |
| 482 | |
| 483 | #[tokio::test] |
| 484 | async fn model_added_configuration_marker_cannot_be_spoofed_or_lost_on_clone() { |
| 485 | let config: super::super::McpServerConfig = serde_json::from_value(serde_json::json!({ |
| 486 | "url":"http://127.0.0.1:1/mcp", "runtime_added": false, "allow_private_network": true |
| 487 | })) |
| 488 | .unwrap(); |
| 489 | let pool = super::super::McpPool::new(super::super::McpConfig::default()); |
| 490 | pool.add_runtime_server_config("dynamic".to_string(), config) |
| 491 | .unwrap(); |
| 492 | let config = pool.dynamic_servers.read().get("dynamic").unwrap().clone(); |
| 493 | assert!(config.runtime_added); |
| 494 | assert!(config.allow_private_network); |
| 495 | let client = McpHttpClient::new( |
| 496 | config.url.as_deref().unwrap(), |
| 497 | config.runtime_added, |
| 498 | false, |
| 499 | config.allow_private_network, |
| 500 | None, |
| 501 | Duration::from_secs(1), |
| 502 | Duration::from_secs(2), |
| 503 | ) |
| 504 | .unwrap(); |
| 505 | assert!( |
| 506 | client |
| 507 | .client_for_target(&Url::parse(config.url.as_deref().unwrap()).unwrap()) |
| 508 | .await |
| 509 | .is_err() |
| 510 | ); |
| 511 | assert!( |
| 512 | serde_json::to_value(&config) |
| 513 | .unwrap() |
| 514 | .get("runtime_added") |
| 515 | .is_none() |
| 516 | ); |
| 517 | } |
| 518 | |
| 519 | #[tokio::test] |
| 520 | async fn model_added_endpoint_cannot_use_ambient_proxy_but_operator_can() { |
| 521 | let _env = crate::test_support::lock_test_env(); |
| 522 | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 523 | let proxy_url = format!("http://{}", listener.local_addr().unwrap()); |
| 524 | let _https_proxy = crate::test_support::EnvVarGuard::set("HTTPS_PROXY", &proxy_url); |
| 525 | let _http_proxy = crate::test_support::EnvVarGuard::set("HTTP_PROXY", &proxy_url); |
| 526 | let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", ""); |
| 527 | let _lower_no_proxy = crate::test_support::EnvVarGuard::set("no_proxy", ""); |
| 528 | let url = "http://mcp-guard-fixture.invalid/mcp"; |
| 529 | let strict = client(url, true); |
| 530 | assert!(strict.send(strict.get(url)).await.is_err()); |
| 531 | // This documentation-only address passes the public-IP classifier. A |
| 532 | // mistakenly enabled proxy would receive it without any DNS lookup. |
| 533 | let public_literal = "http://192.0.2.1:9/mcp"; |
| 534 | let strict_literal = client(public_literal, true); |
| 535 | assert!( |
| 536 | strict_literal |
| 537 | .send(strict_literal.get(public_literal)) |
| 538 | .await |
| 539 | .is_err() |
| 540 | ); |
| 541 | // A mistakenly enabled proxy would connect immediately; the generous |
| 542 | // window only absorbs full-suite scheduler load. |
| 543 | assert!( |
| 544 | tokio::time::timeout(Duration::from_millis(500), listener.accept()) |
| 545 | .await |
| 546 | .is_err() |
| 547 | ); |
| 548 | let server = tokio::spawn(reply_once( |
| 549 | listener, |
| 550 | "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nok".to_string(), |
| 551 | )); |
| 552 | let configured = client(url, false); |
| 553 | assert_eq!( |
| 554 | configured |
| 555 | .send(configured.get(url)) |
| 556 | .await |
| 557 | .unwrap() |
| 558 | .text() |
| 559 | .await |
| 560 | .unwrap(), |
| 561 | "ok" |
| 562 | ); |
| 563 | assert!( |
| 564 | server |
| 565 | .await |
| 566 | .unwrap() |
| 567 | .starts_with("GET http://mcp-guard-fixture.invalid/mcp ") |
| 568 | ); |
| 569 | } |
| 570 | |
| 571 | #[tokio::test] |
| 572 | async fn configured_public_origin_rejects_rebinding_before_reusing_its_pinned_client() { |
| 573 | let _env = crate::test_support::lock_test_env(); |
| 574 | let _proxy = crate::test_support::EnvVarGuard::set("HTTPS_PROXY", "http://127.0.0.1:9"); |
| 575 | let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "mcp-guard-fixture.test"); |
| 576 | let url = Url::parse("https://mcp-guard-fixture.test/mcp").unwrap(); |
| 577 | let configured = client(url.as_str(), false); |
| 578 | *configured.dns_answers.lock().unwrap() = Some(std::collections::VecDeque::from([ |
| 579 | vec!["8.8.8.8:443".parse().unwrap()], |
| 580 | vec!["127.0.0.1:443".parse().unwrap()], |
| 581 | ])); |
| 582 | configured.client_for_target(&url).await.unwrap(); |
| 583 | assert_eq!(configured.clients.lock().unwrap().len(), 1); |
| 584 | let error = configured.client_for_target(&url).await.unwrap_err(); |
| 585 | assert!(error.to_string().contains("restricted"), "{error:#}"); |
| 586 | assert!( |
| 587 | configured |
| 588 | .dns_answers |
| 589 | .lock() |
| 590 | .unwrap() |
| 591 | .as_ref() |
| 592 | .unwrap() |
| 593 | .is_empty() |
| 594 | ); |
| 595 | } |
| 596 | |
| 597 | #[tokio::test] |
| 598 | async fn private_dns_requires_an_explicit_operator_opt_in() { |
| 599 | let _env = crate::test_support::lock_test_env(); |
| 600 | let _proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*"); |
| 601 | let url = Url::parse("https://internal-service.example.test/mcp").unwrap(); |
| 602 | let configured = client(url.as_str(), false); |
| 603 | *configured.dns_answers.lock().unwrap() = Some(std::collections::VecDeque::from([vec![ |
| 604 | "10.0.0.3:443".parse().unwrap(), |
| 605 | ]])); |
| 606 | assert!(configured.client_for_target(&url).await.is_err()); |
| 607 | let approved = McpHttpClient::new( |
| 608 | url.as_str(), |
| 609 | false, |
| 610 | false, |
| 611 | true, |
| 612 | None, |
| 613 | Duration::from_secs(1), |
| 614 | Duration::from_secs(2), |
| 615 | ) |
| 616 | .unwrap(); |
| 617 | approved.client_for_target(&url).await.unwrap(); |
| 618 | } |
| 619 | } |
| 620 |