mur_common/net.rs
1//! Shared host-pattern matcher for outbound-network allow/deny checks.
2//!
3//! This is the single source of truth for host-pattern matching used by every
4//! layer that gates outbound requests: the agent runtime's egress proxy
5//! (`mur-agent-runtime::sandbox::reqwest_guard`) and the research gateway's
6//! SSRF guard (`mur-research-gateway::net_guard`). A security boundary must
7//! not have two copies of this logic that can silently drift apart.
8//!
9//! IP-range predicates (loopback/private/link-local/unspecified) are
10//! deliberately NOT shared here — different callers apply different IP
11//! policies (e.g. the runtime allows loopback/RFC1918 for local LLMs while
12//! the research gateway forbids them). Only the host-pattern string matcher
13//! is common.
14
15/// Match a host against an allowlist pattern.
16///
17/// Canonical wildcard syntax is `*.example.com` (matches `api.example.com`
18/// and `example.com`). For backward compatibility the legacy leading-dot
19/// form `.example.com` is also accepted and treated identically.
20///
21/// Both layers that perform host-allowlist checks (`HostGuard` DNS resolver
22/// and the B0 safety hook) must call this function so they share a single
23/// interpretation of wildcard patterns.
24pub fn host_matches_pattern(host: &str, pattern: &str) -> bool {
25 let host = host.to_ascii_lowercase();
26 let pattern = pattern.to_ascii_lowercase();
27 // Strip leading `*.` (canonical) or leading `.` (legacy) to get the suffix.
28 let suffix = if let Some(s) = pattern.strip_prefix("*.") {
29 s
30 } else if let Some(s) = pattern.strip_prefix('.') {
31 s
32 } else {
33 // Exact match only.
34 return host == pattern;
35 };
36 host == suffix || host.ends_with(&format!(".{suffix}"))
37}
38
39/// True if `host` matches any allowlist pattern. An empty allowlist denies all
40/// (fail-closed). Reuses the same matcher the agent's reqwest guard uses, so
41/// per-MCP-server egress allowlisting behaves identically to agent-level hosts.
42pub fn host_allowed(host: &str, allow: &[String]) -> bool {
43 allow.iter().any(|p| host_matches_pattern(host, p))
44}
45
46#[cfg(test)]
47mod tests {
48 use super::*;
49
50 #[test]
51 fn host_matches_pattern_wildcard_legacy_and_exact() {
52 assert!(host_matches_pattern("api.example.com", "*.example.com"));
53 assert!(host_matches_pattern("example.com", "*.example.com"));
54 assert!(host_matches_pattern("api.example.com", ".example.com"));
55 assert!(host_matches_pattern("example.com", ".example.com"));
56 assert!(host_matches_pattern("example.com", "example.com"));
57 assert!(!host_matches_pattern("evil.com", "example.com"));
58 assert!(!host_matches_pattern(
59 "example.com.evil.com",
60 "*.example.com"
61 ));
62 }
63
64 #[test]
65 fn host_allowed_is_fail_closed_and_pattern_aware() {
66 let allow = vec!["example.com".to_string(), "*.api.example.com".to_string()];
67 assert!(host_allowed("example.com", &allow));
68 assert!(host_allowed("v1.api.example.com", &allow));
69 assert!(!host_allowed("evil.com", &allow));
70 assert!(!host_allowed("example.com", &[]), "empty allowlist denies");
71 }
72}