chio_guards/
internal_network.rs1use 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
21pub struct InternalNetworkGuard {
26 extra_blocked_hosts: Vec<String>,
28 dns_rebinding_detection: bool,
30}
31
32impl InternalNetworkGuard {
33 pub fn new() -> Self {
35 Self {
36 extra_blocked_hosts: Vec::new(),
37 dns_rebinding_detection: true,
38 }
39 }
40
41 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 pub fn check_host(&self, host: &str) -> Option<String> {
54 let host_lower = host.to_lowercase();
55
56 if is_cloud_metadata_host(&host_lower) {
58 return Some(format!("cloud metadata endpoint: {host}"));
59 }
60
61 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 if self.dns_rebinding_detection && is_dns_rebinding_suspect(&host_lower) {
70 return Some(format!("DNS rebinding suspect: {host}"));
71 }
72
73 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 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
120fn is_private_ip(ip: &IpAddr) -> bool {
122 match ip {
123 IpAddr::V4(v4) => {
124 let octets = v4.octets();
125 if octets[0] == 127 {
127 return true;
128 }
129 if octets[0] == 10 {
131 return true;
132 }
133 if octets[0] == 172 && (16..=31).contains(&octets[1]) {
135 return true;
136 }
137 if octets[0] == 192 && octets[1] == 168 {
139 return true;
140 }
141 if octets[0] == 169 && octets[1] == 254 {
143 return true;
144 }
145 if octets == [255, 255, 255, 255] {
147 return true;
148 }
149 if octets[0] == 0 {
151 return true;
152 }
153 false
154 }
155 IpAddr::V6(v6) => {
156 if v6.is_loopback() {
158 return true;
159 }
160 let segments = v6.segments();
161 if segments[0] & 0xffc0 == 0xfe80 {
163 return true;
164 }
165 if segments[0] & 0xfe00 == 0xfc00 {
167 return true;
168 }
169 if v6.is_unspecified() {
171 return true;
172 }
173 if let Some(v4) = v6.to_ipv4_mapped() {
175 return is_private_ip(&IpAddr::V4(v4));
176 }
177 false
178 }
179 }
180}
181
182fn is_cloud_metadata_host(host: &str) -> bool {
184 if host == "169.254.169.254" {
186 return true;
187 }
188 if host == "metadata.google.internal" {
190 return true;
191 }
192 if host == "metadata.azure.com" {
194 return true;
195 }
196 if host == "instance-data" || host.ends_with(".internal") {
198 return true;
199 }
200 if host == "kubernetes.default.svc" || host == "kubernetes.default" {
202 return true;
203 }
204 false
205}
206
207fn is_dns_rebinding_suspect(host: &str) -> bool {
212 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", "0177.", ];
228
229 for pattern in &suspicious_patterns {
230 if host.contains(pattern) {
231 if host.parse::<IpAddr>().is_ok() {
233 return false;
234 }
235 return true;
236 }
237 }
238
239 false
240}
241
242fn looks_like_encoded_ip(host: &str) -> bool {
247 if host.starts_with("0x") && host[2..].chars().all(|c| c.is_ascii_hexdigit()) {
249 return true;
250 }
251 if host.chars().all(|c| c.is_ascii_digit()) && host.len() >= 7 && host.len() <= 10 {
253 return true;
254 }
255 if host.starts_with('0')
257 && host.len() > 1
258 && host.chars().all(|c| c.is_ascii_digit() || c == '.')
259 && host.contains('.')
260 {
261 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 assert!(guard.check_host("10.0.0.1").is_some());
287 assert!(guard.check_host("10.255.255.255").is_some());
288 assert!(guard.check_host("172.16.0.1").is_some());
290 assert!(guard.check_host("172.31.255.255").is_some());
291 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 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 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 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}