Skip to main content

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}