chio_guards/
internal_network.rs1use std::net::IpAddr;
14
15use chio_kernel::{Guard, GuardContext, KernelError, Verdict};
16
17use crate::action::{extract_action, ToolAction};
18
19pub struct InternalNetworkGuard {
24 extra_blocked_hosts: Vec<String>,
26 dns_rebinding_detection: bool,
28}
29
30impl InternalNetworkGuard {
31 pub fn new() -> Self {
33 Self {
34 extra_blocked_hosts: Vec::new(),
35 dns_rebinding_detection: true,
36 }
37 }
38
39 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 pub fn check_host(&self, host: &str) -> Option<String> {
52 let host_lower = host.to_lowercase();
53
54 if is_cloud_metadata_host(&host_lower) {
56 return Some(format!("cloud metadata endpoint: {host}"));
57 }
58
59 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 if self.dns_rebinding_detection && is_dns_rebinding_suspect(&host_lower) {
68 return Some(format!("DNS rebinding suspect: {host}"));
69 }
70
71 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 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
115fn is_private_ip(ip: &IpAddr) -> bool {
117 match ip {
118 IpAddr::V4(v4) => {
119 let octets = v4.octets();
120 if octets[0] == 127 {
122 return true;
123 }
124 if octets[0] == 10 {
126 return true;
127 }
128 if octets[0] == 172 && (16..=31).contains(&octets[1]) {
130 return true;
131 }
132 if octets[0] == 192 && octets[1] == 168 {
134 return true;
135 }
136 if octets[0] == 169 && octets[1] == 254 {
138 return true;
139 }
140 if octets == [255, 255, 255, 255] {
142 return true;
143 }
144 if octets[0] == 0 {
146 return true;
147 }
148 false
149 }
150 IpAddr::V6(v6) => {
151 if v6.is_loopback() {
153 return true;
154 }
155 let segments = v6.segments();
156 if segments[0] & 0xffc0 == 0xfe80 {
158 return true;
159 }
160 if segments[0] & 0xfe00 == 0xfc00 {
162 return true;
163 }
164 if v6.is_unspecified() {
166 return true;
167 }
168 if let Some(v4) = v6.to_ipv4_mapped() {
170 return is_private_ip(&IpAddr::V4(v4));
171 }
172 false
173 }
174 }
175}
176
177fn is_cloud_metadata_host(host: &str) -> bool {
179 if host == "169.254.169.254" {
181 return true;
182 }
183 if host == "metadata.google.internal" {
185 return true;
186 }
187 if host == "metadata.azure.com" {
189 return true;
190 }
191 if host == "instance-data" || host.ends_with(".internal") {
193 return true;
194 }
195 if host == "kubernetes.default.svc" || host == "kubernetes.default" {
197 return true;
198 }
199 false
200}
201
202fn is_dns_rebinding_suspect(host: &str) -> bool {
207 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", "0177.", ];
223
224 for pattern in &suspicious_patterns {
225 if host.contains(pattern) {
226 if host.parse::<IpAddr>().is_ok() {
228 return false;
229 }
230 return true;
231 }
232 }
233
234 false
235}
236
237fn looks_like_encoded_ip(host: &str) -> bool {
242 if host.starts_with("0x") && host[2..].chars().all(|c| c.is_ascii_hexdigit()) {
244 return true;
245 }
246 if host.chars().all(|c| c.is_ascii_digit()) && host.len() >= 7 && host.len() <= 10 {
248 return true;
249 }
250 if host.starts_with('0')
252 && host.len() > 1
253 && host.chars().all(|c| c.is_ascii_digit() || c == '.')
254 && host.contains('.')
255 {
256 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 assert!(guard.check_host("10.0.0.1").is_some());
282 assert!(guard.check_host("10.255.255.255").is_some());
283 assert!(guard.check_host("172.16.0.1").is_some());
285 assert!(guard.check_host("172.31.255.255").is_some());
286 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 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 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 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}