返回 CodeWhale
http_client.rs
根目录 / crates / tui / src / mcp / http_client.rs
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
620 lines RUST