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