Skip to main content

chio_guards/
internal_network.rs

1//! Internal network guard -- blocks SSRF targeting private/reserved addresses.
2//!
3//! This guard prevents Server-Side Request Forgery (SSRF) by blocking
4//! network egress to:
5//! - RFC 1918 private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
6//! - Loopback addresses (127.0.0.0/8, ::1)
7//! - Link-local addresses (169.254.0.0/16, fe80::/10)
8//! - Cloud metadata endpoints (169.254.169.254, metadata.google.internal, etc.)
9//! - DNS rebinding detection via suspicious hostname patterns
10//!
11//! The guard fails closed: any parse error or ambiguous address is denied.
12
13use std::net::IpAddr;
14
15#[cfg(test)]
16use chio_kernel::Verdict;
17use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError};
18
19use crate::action::{extract_action_checked, ToolAction};
20
21/// Guard that blocks SSRF targeting internal/private network addresses.
22///
23/// Inspects network egress actions and denies requests to private, loopback,
24/// link-local, and cloud metadata addresses.
25pub struct InternalNetworkGuard {
26    /// Additional hostnames to block (beyond the built-in list).
27    extra_blocked_hosts: Vec<String>,
28    /// Enable DNS rebinding detection heuristics.
29    dns_rebinding_detection: bool,
30}
31
32impl InternalNetworkGuard {
33    /// Create a new guard with default settings.
34    pub fn new() -> Self {
35        Self {
36            extra_blocked_hosts: Vec::new(),
37            dns_rebinding_detection: true,
38        }
39    }
40
41    /// Create a new guard with additional blocked hostnames and DNS rebinding
42    /// detection toggle.
43    pub fn with_config(extra_blocked_hosts: Vec<String>, dns_rebinding_detection: bool) -> Self {
44        Self {
45            extra_blocked_hosts,
46            dns_rebinding_detection,
47        }
48    }
49
50    /// Check whether a host string targets an internal/private address.
51    ///
52    /// Returns `Some(reason)` if blocked, `None` if allowed.
53    pub fn check_host(&self, host: &str) -> Option<String> {
54        let host_lower = host.to_lowercase();
55
56        // Check cloud metadata hostnames.
57        if is_cloud_metadata_host(&host_lower) {
58            return Some(format!("cloud metadata endpoint: {host}"));
59        }
60
61        // Check extra blocked hosts.
62        for blocked in &self.extra_blocked_hosts {
63            if host_lower == blocked.to_lowercase() {
64                return Some(format!("blocked host: {host}"));
65            }
66        }
67
68        // DNS rebinding detection: suspicious patterns in hostnames.
69        if self.dns_rebinding_detection && is_dns_rebinding_suspect(&host_lower) {
70            return Some(format!("DNS rebinding suspect: {host}"));
71        }
72
73        // Try to parse as IP address directly.
74        if let Ok(ip) = host.parse::<IpAddr>() {
75            if is_private_ip(&ip) {
76                return Some(format!("private/reserved IP: {ip}"));
77            }
78            return None;
79        }
80
81        // For hostnames, check if they resolve to numeric-looking patterns
82        // that could bypass DNS resolution. Accept non-IP hostnames.
83        if looks_like_encoded_ip(&host_lower) {
84            return Some(format!("encoded IP pattern in hostname: {host}"));
85        }
86
87        None
88    }
89}
90
91impl Default for InternalNetworkGuard {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97impl Guard for InternalNetworkGuard {
98    fn name(&self) -> &str {
99        "internal-network"
100    }
101
102    fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
103        let action = match extract_action_checked(&ctx.request.tool_name, &ctx.request.arguments) {
104            Ok(action) => action,
105            Err(_) => return Ok(GuardDecision::deny(Vec::new())),
106        };
107
108        let host = match &action {
109            ToolAction::NetworkEgress(h, _) => h.as_str(),
110            _ => return Ok(GuardDecision::allow()),
111        };
112
113        match self.check_host(host) {
114            Some(_reason) => Ok(GuardDecision::deny(Vec::new())),
115            None => Ok(GuardDecision::allow()),
116        }
117    }
118}
119
120/// Check whether an IP address is in a private/reserved range.
121fn is_private_ip(ip: &IpAddr) -> bool {
122    match ip {
123        IpAddr::V4(v4) => {
124            let octets = v4.octets();
125            // Loopback: 127.0.0.0/8
126            if octets[0] == 127 {
127                return true;
128            }
129            // RFC 1918: 10.0.0.0/8
130            if octets[0] == 10 {
131                return true;
132            }
133            // RFC 1918: 172.16.0.0/12
134            if octets[0] == 172 && (16..=31).contains(&octets[1]) {
135                return true;
136            }
137            // RFC 1918: 192.168.0.0/16
138            if octets[0] == 192 && octets[1] == 168 {
139                return true;
140            }
141            // Link-local: 169.254.0.0/16
142            if octets[0] == 169 && octets[1] == 254 {
143                return true;
144            }
145            // Broadcast
146            if octets == [255, 255, 255, 255] {
147                return true;
148            }
149            // 0.0.0.0/8 (current network)
150            if octets[0] == 0 {
151                return true;
152            }
153            false
154        }
155        IpAddr::V6(v6) => {
156            // Loopback: ::1
157            if v6.is_loopback() {
158                return true;
159            }
160            let segments = v6.segments();
161            // Link-local: fe80::/10
162            if segments[0] & 0xffc0 == 0xfe80 {
163                return true;
164            }
165            // Unique local: fc00::/7
166            if segments[0] & 0xfe00 == 0xfc00 {
167                return true;
168            }
169            // Unspecified: ::
170            if v6.is_unspecified() {
171                return true;
172            }
173            // IPv4-mapped IPv6 addresses: check the mapped v4 portion.
174            if let Some(v4) = v6.to_ipv4_mapped() {
175                return is_private_ip(&IpAddr::V4(v4));
176            }
177            false
178        }
179    }
180}
181
182/// Check whether a hostname is a well-known cloud metadata endpoint.
183fn is_cloud_metadata_host(host: &str) -> bool {
184    // AWS/GCP/Azure metadata endpoint IP
185    if host == "169.254.169.254" {
186        return true;
187    }
188    // GCP metadata hostname
189    if host == "metadata.google.internal" {
190        return true;
191    }
192    // Azure metadata hostname
193    if host == "metadata.azure.com" {
194        return true;
195    }
196    // AWS EC2 metadata via hostname
197    if host == "instance-data" || host.ends_with(".internal") {
198        return true;
199    }
200    // Kubernetes metadata
201    if host == "kubernetes.default.svc" || host == "kubernetes.default" {
202        return true;
203    }
204    false
205}
206
207/// DNS rebinding detection: check for suspicious hostname patterns.
208///
209/// This catches hostnames that embed IP-like octets or use tricks to
210/// resolve to private addresses.
211fn is_dns_rebinding_suspect(host: &str) -> bool {
212    // Hostnames containing raw IP octets separated by dashes or dots
213    // that look like private ranges.
214    let suspicious_patterns = [
215        "127-0-0-1",
216        "127.0.0.1",
217        "10-0-",
218        "10.0.",
219        "192-168-",
220        "192.168.",
221        "172-16-",
222        "172.16.",
223        "169-254-",
224        "169.254.",
225        "0x7f",  // hex-encoded 127
226        "0177.", // octal 127
227    ];
228
229    for pattern in &suspicious_patterns {
230        if host.contains(pattern) {
231            // But don't flag if the host itself IS an IP (already handled).
232            if host.parse::<IpAddr>().is_ok() {
233                return false;
234            }
235            return true;
236        }
237    }
238
239    false
240}
241
242/// Check if a hostname looks like an encoded/obfuscated IP address.
243///
244/// Catches hex (0x7f000001), octal (0177.0.0.1), and decimal (2130706433)
245/// representations of IP addresses.
246fn looks_like_encoded_ip(host: &str) -> bool {
247    // Hex-encoded IP: 0x followed by hex digits
248    if host.starts_with("0x") && host[2..].chars().all(|c| c.is_ascii_hexdigit()) {
249        return true;
250    }
251    // Decimal-encoded IP: pure digits that could be an IP
252    if host.chars().all(|c| c.is_ascii_digit()) && host.len() >= 7 && host.len() <= 10 {
253        return true;
254    }
255    // Octal components: starts with 0 followed by octal digits and dots
256    if host.starts_with('0')
257        && host.len() > 1
258        && host.chars().all(|c| c.is_ascii_digit() || c == '.')
259        && host.contains('.')
260    {
261        // Could be octal IP notation like 0177.0.0.1
262        let parts: Vec<&str> = host.split('.').collect();
263        if parts.len() >= 2 && parts.iter().any(|p| p.starts_with('0') && p.len() > 1) {
264            return true;
265        }
266    }
267    false
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn blocks_loopback() {
276        let guard = InternalNetworkGuard::new();
277        assert!(guard.check_host("127.0.0.1").is_some());
278        assert!(guard.check_host("127.0.0.2").is_some());
279        assert!(guard.check_host("127.255.255.255").is_some());
280    }
281
282    #[test]
283    fn blocks_rfc_1918() {
284        let guard = InternalNetworkGuard::new();
285        // 10.0.0.0/8
286        assert!(guard.check_host("10.0.0.1").is_some());
287        assert!(guard.check_host("10.255.255.255").is_some());
288        // 172.16.0.0/12
289        assert!(guard.check_host("172.16.0.1").is_some());
290        assert!(guard.check_host("172.31.255.255").is_some());
291        // 192.168.0.0/16
292        assert!(guard.check_host("192.168.0.1").is_some());
293        assert!(guard.check_host("192.168.255.255").is_some());
294    }
295
296    #[test]
297    fn allows_public_ips() {
298        let guard = InternalNetworkGuard::new();
299        assert!(guard.check_host("8.8.8.8").is_none());
300        assert!(guard.check_host("1.1.1.1").is_none());
301        assert!(guard.check_host("203.0.113.1").is_none());
302    }
303
304    #[test]
305    fn blocks_link_local() {
306        let guard = InternalNetworkGuard::new();
307        assert!(guard.check_host("169.254.1.1").is_some());
308        assert!(guard.check_host("169.254.169.254").is_some());
309    }
310
311    #[test]
312    fn blocks_cloud_metadata() {
313        let guard = InternalNetworkGuard::new();
314        assert!(guard.check_host("169.254.169.254").is_some());
315        assert!(guard.check_host("metadata.google.internal").is_some());
316    }
317
318    #[test]
319    fn blocks_ipv6_loopback() {
320        let guard = InternalNetworkGuard::new();
321        assert!(guard.check_host("::1").is_some());
322    }
323
324    #[test]
325    fn blocks_ipv6_link_local() {
326        let guard = InternalNetworkGuard::new();
327        assert!(guard.check_host("fe80::1").is_some());
328    }
329
330    #[test]
331    fn blocks_ipv6_unique_local() {
332        let guard = InternalNetworkGuard::new();
333        assert!(guard.check_host("fc00::1").is_some());
334        assert!(guard.check_host("fd00::1").is_some());
335    }
336
337    #[test]
338    fn blocks_hex_encoded_ip() {
339        let guard = InternalNetworkGuard::new();
340        assert!(guard.check_host("0x7f000001").is_some());
341    }
342
343    #[test]
344    fn blocks_decimal_encoded_ip() {
345        let guard = InternalNetworkGuard::new();
346        // 2130706433 == 127.0.0.1
347        assert!(guard.check_host("2130706433").is_some());
348    }
349
350    #[test]
351    fn allows_normal_hostnames() {
352        let guard = InternalNetworkGuard::new();
353        assert!(guard.check_host("api.example.com").is_none());
354        assert!(guard.check_host("github.com").is_none());
355    }
356
357    #[test]
358    fn blocks_dns_rebinding_patterns() {
359        let guard = InternalNetworkGuard::new();
360        assert!(guard.check_host("evil.127-0-0-1.example.com").is_some());
361        assert!(guard.check_host("evil.192-168-1.attacker.com").is_some());
362    }
363
364    #[test]
365    fn dns_rebinding_detection_can_be_disabled() {
366        let guard = InternalNetworkGuard::with_config(vec![], false);
367        // Without rebinding detection, suspicious hostnames are allowed
368        // (they're not actual IPs).
369        assert!(guard.check_host("evil.127-0-0-1.example.com").is_none());
370    }
371
372    #[test]
373    fn extra_blocked_hosts() {
374        let guard = InternalNetworkGuard::with_config(vec!["evil.internal".to_string()], true);
375        assert!(guard.check_host("evil.internal").is_some());
376        assert!(guard.check_host("safe.external.com").is_none());
377    }
378
379    #[test]
380    fn blocks_broadcast() {
381        let guard = InternalNetworkGuard::new();
382        assert!(guard.check_host("255.255.255.255").is_some());
383    }
384
385    #[test]
386    fn blocks_zero_network() {
387        let guard = InternalNetworkGuard::new();
388        assert!(guard.check_host("0.0.0.0").is_some());
389    }
390
391    #[test]
392    fn blocks_kubernetes_metadata() {
393        let guard = InternalNetworkGuard::new();
394        assert!(guard.check_host("kubernetes.default.svc").is_some());
395        assert!(guard.check_host("kubernetes.default").is_some());
396    }
397
398    #[test]
399    fn blocks_ipv4_mapped_ipv6() {
400        let guard = InternalNetworkGuard::new();
401        // ::ffff:127.0.0.1 is an IPv4-mapped IPv6 address
402        assert!(guard.check_host("::ffff:127.0.0.1").is_some());
403    }
404
405    #[test]
406    fn guard_name() {
407        let guard = InternalNetworkGuard::new();
408        assert_eq!(guard.name(), "internal-network");
409    }
410
411    #[test]
412    fn non_network_actions_pass() {
413        let guard = InternalNetworkGuard::new();
414
415        let kp = chio_core::crypto::Keypair::generate();
416        let scope = chio_core::capability::scope::ChioScope::default();
417        let agent = kp.public_key().to_hex();
418        let server = "srv".to_string();
419
420        let cap_body = chio_core::capability::token::CapabilityTokenBody {
421            id: "cap-test".to_string(),
422            issuer: kp.public_key(),
423            subject: kp.public_key(),
424            scope: scope.clone(),
425            issued_at: 0,
426            expires_at: u64::MAX,
427            delegation_chain: vec![],
428            aggregate_invocation_budget: None,
429        };
430        let cap =
431            chio_core::capability::token::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
432
433        let request = chio_kernel::ToolCallRequest {
434            request_id: "req-1".to_string(),
435            capability: cap,
436            tool_name: "read_file".to_string(),
437            server_id: server.clone(),
438            agent_id: agent.clone(),
439            arguments: serde_json::json!({"path": "/etc/passwd"}),
440            dpop_proof: None,
441            execution_nonce: None,
442            governed_intent: None,
443            approval_token: None,
444            approval_tokens: Vec::new(),
445            threshold_approval_proposal: None,
446            supplemental_authorization: None,
447            model_metadata: None,
448            federated_origin_kernel_id: None,
449        };
450
451        let ctx = chio_kernel::GuardContext {
452            request: &request,
453            scope: &scope,
454            agent_id: &agent,
455            server_id: &server,
456            session_filesystem_roots: None,
457            matched_grant_index: None,
458        };
459
460        let result = guard.evaluate(&ctx).expect("should not error");
461        assert_eq!(result, Verdict::Allow, "non-network action should pass");
462    }
463}