1use std::net::{IpAddr, SocketAddr};
31use std::sync::Arc;
32use std::time::{Duration, Instant};
33
34use dashmap::DashMap;
35use serde::Deserialize;
36use tokio::sync::Mutex;
37use tracing::warn;
38
39use crate::cimd::ssrf;
40
41const MAX_DOCUMENT_BYTES: usize = 64 * 1024;
46
47const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
55
56const DNS_TIMEOUT: Duration = Duration::from_secs(3);
62
63const CACHE_TTL: Duration = Duration::from_secs(300);
65
66const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(60);
70
71const MAX_CACHE_ENTRIES: usize = 10_000;
77
78#[derive(Debug, Clone, Deserialize)]
79pub struct ClientMetadataDocument {
80 pub client_id: String,
81 pub client_name: String,
82 #[serde(default)]
83 pub redirect_uris: Vec<String>,
84}
85
86#[derive(Debug, Clone, thiserror::Error)]
87pub enum CimdError {
88 #[error(transparent)]
89 Ssrf(#[from] ssrf::SsrfError),
90 #[error("dns resolution failed for `{0}`: {1}")]
95 DnsResolutionFailed(String, String),
96 #[error(
102 "`{0}` resolved to at least one private/loopback/link-local address; blocked to prevent SSRF"
103 )]
104 DnsBlocked(String),
105 #[error("fetch failed: {0}")]
106 Fetch(String),
107 #[error(
111 "peer address {actual} did not match the validated address {expected}; possible proxy interception or DNS-rebinding attempt"
112 )]
113 PeerMismatch {
114 expected: SocketAddr,
115 actual: SocketAddr,
116 },
117 #[error("invalid client metadata document: {0}")]
118 InvalidDocument(String),
119 #[error(
120 "client metadata document client_id `{document_client_id}` does not match the requested URL `{requested_url}`"
121 )]
122 ClientIdMismatch {
123 document_client_id: String,
124 requested_url: String,
125 },
126}
127
128impl CimdError {
129 #[must_use]
139 pub fn kind(&self) -> &'static str {
140 match self {
141 Self::Ssrf(e) => e.kind(),
142 Self::DnsResolutionFailed(..) => "dns_resolution_failed",
143 Self::DnsBlocked(_) => "ssrf_blocked",
144 Self::Fetch(_) => "cimd_fetch_failed",
145 Self::PeerMismatch { .. } => "ssrf_blocked",
146 Self::InvalidDocument(_) => "invalid_client_metadata",
147 Self::ClientIdMismatch { .. } => "invalid_client_metadata",
148 }
149 }
150}
151
152#[must_use]
157pub fn is_cimd_client_id(client_id: &str) -> bool {
158 client_id.starts_with("https://")
159}
160
161pub async fn resolve_and_validate_address(host: &str, port: u16) -> Result<SocketAddr, CimdError> {
174 let lookup = tokio::time::timeout(DNS_TIMEOUT, tokio::net::lookup_host((host, port)))
175 .await
176 .map_err(|_| {
177 CimdError::DnsResolutionFailed(
178 host.to_string(),
179 format!("timed out after {DNS_TIMEOUT:?}"),
180 )
181 })?
182 .map_err(|e| CimdError::DnsResolutionFailed(host.to_string(), e.to_string()))?;
183 let addrs: Vec<SocketAddr> = lookup.collect();
184 if addrs.is_empty() {
185 return Err(CimdError::DnsResolutionFailed(
186 host.to_string(),
187 "resolved to no addresses".to_string(),
188 ));
189 }
190 if addrs
191 .iter()
192 .any(|addr| ssrf::check_ip_not_private(addr.ip(), host).is_err())
193 {
194 return Err(CimdError::DnsBlocked(host.to_string()));
195 }
196 Ok(addrs[0])
197}
198
199pub(crate) async fn fetch_via_pinned_address(
216 url: &str,
217 addr: SocketAddr,
218) -> Result<ClientMetadataDocument, CimdError> {
219 let parsed =
220 url::Url::parse(url).map_err(|e| CimdError::Fetch(format!("parse `{url}`: {e}")))?;
221 let host = parsed
222 .host_str()
223 .ok_or_else(|| CimdError::Fetch(format!("no host in `{url}`")))?;
224 let client = reqwest::Client::builder()
225 .resolve(host, addr)
226 .no_proxy()
231 .redirect(reqwest::redirect::Policy::none())
236 .timeout(FETCH_TIMEOUT)
237 .build()
238 .map_err(|e| CimdError::Fetch(format!("build pinned client for `{url}`: {e}")))?;
239 fetch_document_at(&client, url, addr).await
240}
241
242pub(crate) async fn fetch_document_at(
259 client: &reqwest::Client,
260 url: &str,
261 pinned_addr: SocketAddr,
262) -> Result<ClientMetadataDocument, CimdError> {
263 let mut response = client
264 .get(url)
265 .send()
266 .await
267 .map_err(|e| CimdError::Fetch(format!("GET `{url}`: {e}")))?;
268
269 match response.remote_addr() {
283 Some(peer) if peer == pinned_addr => {}
284 Some(peer) => {
285 return Err(CimdError::PeerMismatch {
286 expected: pinned_addr,
287 actual: peer,
288 });
289 }
290 None => {
291 return Err(CimdError::Fetch(format!(
292 "no remote peer address available for `{url}`; refusing to trust an unverified connection"
293 )));
294 }
295 }
296
297 if !response.status().is_success() {
298 return Err(CimdError::Fetch(format!(
299 "GET `{url}` returned HTTP {}",
300 response.status()
301 )));
302 }
303
304 let mut buf: Vec<u8> = Vec::new();
305 while let Some(chunk) = response
306 .chunk()
307 .await
308 .map_err(|e| CimdError::Fetch(format!("read body from `{url}`: {e}")))?
309 {
310 buf.extend_from_slice(&chunk);
311 if buf.len() > MAX_DOCUMENT_BYTES {
312 return Err(CimdError::InvalidDocument(format!(
313 "document at `{url}` exceeds the {MAX_DOCUMENT_BYTES}-byte limit"
314 )));
315 }
316 }
317
318 let document: ClientMetadataDocument = serde_json::from_slice(&buf).map_err(|e| {
319 CimdError::InvalidDocument(format!("document at `{url}` is not valid JSON: {e}"))
320 })?;
321 if document.client_id.is_empty() || document.client_name.is_empty() {
322 return Err(CimdError::InvalidDocument(format!(
323 "document at `{url}` is missing required client_id or client_name"
324 )));
325 }
326 if document.redirect_uris.is_empty() {
327 return Err(CimdError::InvalidDocument(format!(
328 "document at `{url}` declares no redirect_uris"
329 )));
330 }
331 if document.client_id != url {
332 return Err(CimdError::ClientIdMismatch {
333 document_client_id: document.client_id,
334 requested_url: url.to_string(),
335 });
336 }
337 Ok(document)
338}
339
340struct CacheEntry {
341 result: Result<ClientMetadataDocument, CimdError>,
347 fetched_at: Instant,
348 ttl: Duration,
349}
350
351pub struct DocumentCache {
371 entries: DashMap<String, CacheEntry>,
372 build_locks: DashMap<String, Arc<Mutex<()>>>,
373}
374
375impl DocumentCache {
376 #[must_use]
377 pub fn new() -> Self {
378 Self {
379 entries: DashMap::new(),
380 build_locks: DashMap::new(),
381 }
382 }
383
384 fn get_fresh(&self, url: &str) -> Option<Result<ClientMetadataDocument, CimdError>> {
385 let entry = self.entries.get(url)?;
386 if entry.fetched_at.elapsed() >= entry.ttl {
387 return None;
388 }
389 Some(entry.result.clone())
390 }
391
392 fn lock_for(&self, url: String) -> Arc<Mutex<()>> {
398 if self.build_locks.len() >= MAX_CACHE_ENTRIES {
399 self.build_locks
400 .retain(|_, lock| Arc::strong_count(lock) > 1);
401 }
402 self.build_locks
403 .entry(url)
404 .or_insert_with(|| Arc::new(Mutex::new(())))
405 .clone()
406 }
407
408 fn insert(
409 &self,
410 url: String,
411 result: &Result<ClientMetadataDocument, CimdError>,
412 ttl: Duration,
413 ) {
414 if self.entries.len() >= MAX_CACHE_ENTRIES {
415 self.entries.retain(|_, e| e.fetched_at.elapsed() < e.ttl);
416 if self.entries.len() >= MAX_CACHE_ENTRIES {
417 warn!(
422 cache_size = self.entries.len(),
423 "CIMD document cache at capacity with no expired entries to evict; not caching this result"
424 );
425 return;
426 }
427 }
428 self.entries.insert(
429 url,
430 CacheEntry {
431 result: result.clone(),
432 fetched_at: Instant::now(),
433 ttl,
434 },
435 );
436 }
437}
438
439impl Default for DocumentCache {
440 fn default() -> Self {
441 Self::new()
442 }
443}
444
445pub async fn fetch_and_validate_client_metadata(
455 cache: &DocumentCache,
456 url: &str,
457) -> Result<ClientMetadataDocument, CimdError> {
458 if let Some(cached) = cache.get_fresh(url) {
459 return cached;
460 }
461
462 let parsed = ssrf::validate_url_shape(url)?;
468
469 let lock = cache.lock_for(url.to_string());
470 let _guard = lock.lock().await;
471
472 if let Some(cached) = cache.get_fresh(url) {
475 return cached;
476 }
477
478 let result: Result<ClientMetadataDocument, CimdError> = async {
479 let host = parsed
480 .host_str()
481 .ok_or_else(|| CimdError::DnsResolutionFailed(url.to_string(), "no host".to_string()))?
482 .to_string();
483 let port = parsed.port_or_known_default().unwrap_or(443);
484
485 let addr = match parsed.host() {
486 Some(url::Host::Domain(_)) => resolve_and_validate_address(&host, port).await?,
487 Some(url::Host::Ipv4(ip)) => SocketAddr::new(IpAddr::V4(ip), port),
490 Some(url::Host::Ipv6(ip)) => SocketAddr::new(IpAddr::V6(ip), port),
491 None => unreachable!("validate_url_shape guarantees a host"),
492 };
493
494 fetch_via_pinned_address(url, addr).await
495 }
496 .await;
497
498 let ttl = if result.is_ok() {
499 CACHE_TTL
500 } else {
501 NEGATIVE_CACHE_TTL
502 };
503 cache.insert(url.to_string(), &result, ttl);
504 result
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use wiremock::matchers::{method, path};
511 use wiremock::{Mock, MockServer, ResponseTemplate};
512
513 #[test]
514 fn is_cimd_client_id_detects_https_urls_only() {
515 assert!(is_cimd_client_id(
516 "https://app.example.com/oauth/client-metadata.json"
517 ));
518 assert!(!is_cimd_client_id("abcDEF123opaque-token"));
519 assert!(!is_cimd_client_id("http://app.example.com/client.json"));
520 }
521
522 #[tokio::test]
523 async fn resolve_and_validate_address_rejects_loopback_host() {
524 let err = resolve_and_validate_address("localhost", 443)
525 .await
526 .unwrap_err();
527 assert_eq!(err.kind(), "ssrf_blocked");
528 }
529
530 #[tokio::test]
531 async fn resolve_and_validate_address_reports_dns_failure_distinctly_from_ssrf_block() {
532 let err = resolve_and_validate_address("definitely-does-not-exist.invalid", 443)
537 .await
538 .unwrap_err();
539 assert_eq!(err.kind(), "dns_resolution_failed");
540 }
541
542 #[tokio::test]
543 async fn fetch_via_pinned_address_succeeds_for_matching_client_id() {
544 let server = MockServer::start().await;
545 let addr = server.address();
546 let url = format!("{}/client.json", server.uri());
547 Mock::given(method("GET"))
548 .and(path("/client.json"))
549 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
550 "client_id": url,
551 "client_name": "Example MCP Client",
552 "redirect_uris": ["http://127.0.0.1:3000/callback"],
553 })))
554 .mount(&server)
555 .await;
556
557 let document = fetch_via_pinned_address(&url, *addr)
558 .await
559 .expect("fetch ok");
560 assert_eq!(document.client_id, url);
561 assert_eq!(document.client_name, "Example MCP Client");
562 assert_eq!(
563 document.redirect_uris,
564 vec!["http://127.0.0.1:3000/callback"]
565 );
566 }
567
568 #[tokio::test]
569 async fn fetch_document_at_rejects_peer_mismatch() {
570 let server = MockServer::start().await;
576 let real_addr = *server.address();
577 let url = format!("{}/client.json", server.uri());
578 Mock::given(method("GET"))
579 .and(path("/client.json"))
580 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
581 "client_id": url,
582 "client_name": "Example",
583 "redirect_uris": ["http://127.0.0.1:3000/callback"],
584 })))
585 .mount(&server)
586 .await;
587
588 let wrong_addr = SocketAddr::new(real_addr.ip(), real_addr.port().wrapping_add(1).max(1));
589 let client = reqwest::Client::builder()
590 .no_proxy()
591 .redirect(reqwest::redirect::Policy::none())
592 .build()
593 .unwrap();
594 let err = fetch_document_at(&client, &url, wrong_addr)
595 .await
596 .unwrap_err();
597 assert!(matches!(err, CimdError::PeerMismatch { .. }));
598 assert_eq!(err.kind(), "ssrf_blocked");
599 }
600
601 #[tokio::test]
602 async fn fetch_via_pinned_address_rejects_client_id_mismatch() {
603 let server = MockServer::start().await;
604 let addr = *server.address();
605 let url = format!("{}/client.json", server.uri());
606 Mock::given(method("GET"))
607 .and(path("/client.json"))
608 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
609 "client_id": "https://attacker.example/spoofed.json",
610 "client_name": "Spoofed Client",
611 "redirect_uris": ["http://127.0.0.1:9999/callback"],
612 })))
613 .mount(&server)
614 .await;
615
616 let err = fetch_via_pinned_address(&url, addr).await.unwrap_err();
617 assert!(matches!(err, CimdError::ClientIdMismatch { .. }));
618 assert_eq!(err.kind(), "invalid_client_metadata");
619 }
620
621 #[tokio::test]
622 async fn fetch_via_pinned_address_rejects_missing_required_fields() {
623 let server = MockServer::start().await;
624 let addr = *server.address();
625 let url = format!("{}/client.json", server.uri());
626 Mock::given(method("GET"))
627 .and(path("/client.json"))
628 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
629 "client_id": url,
630 "redirect_uris": ["http://127.0.0.1:3000/callback"],
631 })))
632 .mount(&server)
633 .await;
634
635 let err = fetch_via_pinned_address(&url, addr).await.unwrap_err();
636 assert!(matches!(err, CimdError::InvalidDocument(_)));
637 assert_eq!(err.kind(), "invalid_client_metadata");
638 }
639
640 #[tokio::test]
641 async fn fetch_via_pinned_address_rejects_malformed_json() {
642 let server = MockServer::start().await;
643 let addr = *server.address();
644 let url = format!("{}/client.json", server.uri());
645 Mock::given(method("GET"))
646 .and(path("/client.json"))
647 .respond_with(ResponseTemplate::new(200).set_body_string("{not valid json"))
648 .mount(&server)
649 .await;
650
651 let err = fetch_via_pinned_address(&url, addr).await.unwrap_err();
652 assert!(matches!(err, CimdError::InvalidDocument(_)));
653 assert_eq!(err.kind(), "invalid_client_metadata");
654 }
655
656 #[tokio::test]
657 async fn fetch_via_pinned_address_rejects_empty_redirect_uris() {
658 let server = MockServer::start().await;
659 let addr = *server.address();
660 let url = format!("{}/client.json", server.uri());
661 Mock::given(method("GET"))
662 .and(path("/client.json"))
663 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
664 "client_id": url,
665 "client_name": "Example",
666 "redirect_uris": [],
667 })))
668 .mount(&server)
669 .await;
670
671 let err = fetch_via_pinned_address(&url, addr).await.unwrap_err();
672 assert!(matches!(err, CimdError::InvalidDocument(_)));
673 assert_eq!(err.kind(), "invalid_client_metadata");
674 }
675
676 #[tokio::test]
677 async fn fetch_via_pinned_address_rejects_non_success_status() {
678 let server = MockServer::start().await;
679 let addr = *server.address();
680 let url = format!("{}/missing.json", server.uri());
681 Mock::given(method("GET"))
682 .and(path("/missing.json"))
683 .respond_with(ResponseTemplate::new(404))
684 .mount(&server)
685 .await;
686
687 let err = fetch_via_pinned_address(&url, addr).await.unwrap_err();
688 assert!(matches!(err, CimdError::Fetch(_)));
689 assert_eq!(err.kind(), "cimd_fetch_failed");
690 }
691
692 #[tokio::test]
693 async fn fetch_via_pinned_address_does_not_follow_redirects() {
694 let server = MockServer::start().await;
695 let addr = *server.address();
696 let url = format!("{}/redirecting.json", server.uri());
697 Mock::given(method("GET"))
698 .and(path("/redirecting.json"))
699 .respond_with(
700 ResponseTemplate::new(302)
701 .insert_header("Location", "https://attacker.example/elsewhere.json"),
702 )
703 .mount(&server)
704 .await;
705
706 let err = fetch_via_pinned_address(&url, addr).await.unwrap_err();
707 assert!(matches!(err, CimdError::Fetch(_)));
708 assert_eq!(err.kind(), "cimd_fetch_failed");
709 }
710
711 #[tokio::test]
712 async fn fetch_via_pinned_address_rejects_oversized_body_without_hanging() {
713 let server = MockServer::start().await;
714 let addr = *server.address();
715 let url = format!("{}/big.json", server.uri());
716 let oversized = "x".repeat(MAX_DOCUMENT_BYTES + 1024);
717 Mock::given(method("GET"))
718 .and(path("/big.json"))
719 .respond_with(ResponseTemplate::new(200).set_body_string(oversized))
720 .mount(&server)
721 .await;
722
723 let err = fetch_via_pinned_address(&url, addr).await.unwrap_err();
724 assert!(matches!(err, CimdError::InvalidDocument(_)));
725 assert_eq!(err.kind(), "invalid_client_metadata");
726 }
727
728 #[test]
729 fn cache_returns_none_when_expired() {
730 let cache = DocumentCache::new();
731 let doc = ClientMetadataDocument {
732 client_id: "https://app.example.com/client.json".to_string(),
733 client_name: "Example".to_string(),
734 redirect_uris: vec!["http://127.0.0.1:3000/callback".to_string()],
735 };
736 cache.insert(
737 "https://app.example.com/client.json".to_string(),
738 &Ok(doc),
739 Duration::from_millis(1),
740 );
741 std::thread::sleep(Duration::from_millis(20));
742 assert!(
743 cache
744 .get_fresh("https://app.example.com/client.json")
745 .is_none()
746 );
747 }
748
749 #[test]
750 fn cache_returns_document_when_fresh() {
751 let cache = DocumentCache::new();
752 let doc = ClientMetadataDocument {
753 client_id: "https://app.example.com/client.json".to_string(),
754 client_name: "Example".to_string(),
755 redirect_uris: vec!["http://127.0.0.1:3000/callback".to_string()],
756 };
757 cache.insert(
758 "https://app.example.com/client.json".to_string(),
759 &Ok(doc),
760 CACHE_TTL,
761 );
762 assert!(
763 cache
764 .get_fresh("https://app.example.com/client.json")
765 .is_some()
766 );
767 }
768
769 #[test]
770 fn cache_caches_negative_results_too() {
771 let cache = DocumentCache::new();
772 let err = CimdError::DnsBlocked("app.example.com".to_string());
773 cache.insert(
774 "https://app.example.com/client.json".to_string(),
775 &Err(err),
776 NEGATIVE_CACHE_TTL,
777 );
778 let cached = cache
779 .get_fresh("https://app.example.com/client.json")
780 .expect("negative result should be cached");
781 assert!(matches!(cached, Err(CimdError::DnsBlocked(_))));
786 assert_eq!(cached.unwrap_err().kind(), "ssrf_blocked");
787 }
788
789 #[test]
790 fn entries_insert_skips_caching_once_at_capacity_with_nothing_expired() {
791 let cache = DocumentCache::new();
792 for i in 0..MAX_CACHE_ENTRIES {
793 let url = format!("https://app.example.com/{i}.json");
794 let doc = ClientMetadataDocument {
795 client_id: url.clone(),
796 client_name: "Example".to_string(),
797 redirect_uris: vec!["http://127.0.0.1:3000/callback".to_string()],
798 };
799 cache.insert(url, &Ok(doc), CACHE_TTL);
800 }
801 assert_eq!(cache.entries.len(), MAX_CACHE_ENTRIES);
802
803 let overflow_url = "https://app.example.com/overflow.json".to_string();
804 let doc = ClientMetadataDocument {
805 client_id: overflow_url.clone(),
806 client_name: "Example".to_string(),
807 redirect_uris: vec!["http://127.0.0.1:3000/callback".to_string()],
808 };
809 cache.insert(overflow_url.clone(), &Ok(doc), CACHE_TTL);
810
811 assert_eq!(cache.entries.len(), MAX_CACHE_ENTRIES);
815 assert!(cache.get_fresh(&overflow_url).is_none());
816 }
817
818 #[test]
819 fn lock_for_sweeps_idle_locks_through_the_real_entry_point_when_at_capacity() {
820 let cache = DocumentCache::new();
821 for i in 0..MAX_CACHE_ENTRIES {
822 cache.build_locks.insert(
823 format!("https://idle.example/{i}.json"),
824 Arc::new(Mutex::new(())),
825 );
826 }
827 assert_eq!(cache.build_locks.len(), MAX_CACHE_ENTRIES);
828
829 let _ = cache.lock_for("https://new.example/client.json".to_string());
830
831 assert_eq!(cache.build_locks.len(), 1);
835 assert!(
836 cache
837 .build_locks
838 .get("https://new.example/client.json")
839 .is_some()
840 );
841 }
842
843 #[tokio::test]
844 async fn build_locks_coalesces_two_concurrent_misses_for_the_same_url_to_one_fetch() {
845 use std::sync::atomic::{AtomicUsize, Ordering};
846
847 async fn simulate_fetch(
858 cache: &DocumentCache,
859 url: &str,
860 fetch_count: &AtomicUsize,
861 ) -> ClientMetadataDocument {
862 if let Some(Ok(doc)) = cache.get_fresh(url) {
863 return doc;
864 }
865 let lock = cache.lock_for(url.to_string());
866 let _guard = lock.lock().await;
867 if let Some(Ok(doc)) = cache.get_fresh(url) {
868 return doc;
869 }
870 fetch_count.fetch_add(1, Ordering::SeqCst);
871 tokio::time::sleep(Duration::from_millis(30)).await;
874 let doc = ClientMetadataDocument {
875 client_id: url.to_string(),
876 client_name: "Example".to_string(),
877 redirect_uris: vec!["http://127.0.0.1:3000/callback".to_string()],
878 };
879 cache.insert(url.to_string(), &Ok(doc.clone()), CACHE_TTL);
880 doc
881 }
882
883 let cache = DocumentCache::new();
884 let url = "https://app.example.com/client.json";
885 let fetch_count = AtomicUsize::new(0);
886 let (a, b) = tokio::join!(
887 simulate_fetch(&cache, url, &fetch_count),
888 simulate_fetch(&cache, url, &fetch_count),
889 );
890 assert_eq!(a.client_id, url);
891 assert_eq!(b.client_id, url);
892 assert_eq!(
893 fetch_count.load(Ordering::SeqCst),
894 1,
895 "two concurrent misses for the same URL must coalesce to exactly one fetch"
896 );
897 }
898
899 #[test]
900 fn build_locks_retain_keeps_only_locks_with_an_active_holder() {
901 let cache = DocumentCache::new();
906 let idle = Arc::new(Mutex::new(()));
907 let busy = Arc::new(Mutex::new(()));
908 let _busy_holder = busy.clone(); cache.build_locks.insert("idle.example".to_string(), idle);
910 cache.build_locks.insert("busy.example".to_string(), busy);
911 cache
912 .build_locks
913 .retain(|_, lock| Arc::strong_count(lock) > 1);
914 assert!(cache.build_locks.get("idle.example").is_none());
915 assert!(cache.build_locks.get("busy.example").is_some());
916 }
917}