1use std::net::IpAddr;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum FilterResult {
24 Allow,
26 DenyHost {
28 host: String,
30 },
31 DenyLinkLocal {
33 ip: IpAddr,
35 },
36 DenyNotAllowed {
38 host: String,
40 },
41}
42
43impl FilterResult {
44 #[must_use]
46 pub fn is_allowed(&self) -> bool {
47 matches!(self, FilterResult::Allow)
48 }
49
50 #[must_use]
52 pub fn reason(&self) -> String {
53 match self {
54 FilterResult::Allow => "allowed by host filter".to_string(),
55 FilterResult::DenyHost { host } => {
56 format!("host {} is in the deny list", host)
57 }
58 FilterResult::DenyLinkLocal { ip } => {
59 format!(
60 "resolved IP {} is in the link-local range (cloud metadata protection)",
61 ip
62 )
63 }
64 FilterResult::DenyNotAllowed { host } => {
65 format!("host {} is not in the allowlist", host)
66 }
67 }
68 }
69}
70
71fn is_link_local(ip: &IpAddr) -> bool {
80 match ip {
81 IpAddr::V4(v4) => v4.octets()[0] == 169 && v4.octets()[1] == 254,
82 IpAddr::V6(v6) => {
83 if (v6.segments()[0] & 0xffc0) == 0xfe80 {
84 return true;
85 }
86 if let Some(v4) = v6.to_ipv4_mapped() {
89 return v4.octets()[0] == 169 && v4.octets()[1] == 254;
90 }
91 false
92 }
93 }
94}
95
96const DENY_HOSTS: &[&str] = &[
99 "169.254.169.254",
100 "metadata.google.internal",
101 "metadata.azure.internal",
102];
103
104#[derive(Debug, Clone)]
112pub struct HostFilter {
113 allowed_hosts: Vec<String>,
115 allowed_suffixes: Vec<String>,
117 deny_hosts: Vec<String>,
119}
120
121impl HostFilter {
122 #[must_use]
129 pub fn new(allowed_hosts: &[String]) -> Self {
130 let mut exact = Vec::new();
131 let mut suffixes = Vec::new();
132
133 for host in allowed_hosts {
134 let lower = host.to_lowercase();
135 if let Some(suffix) = lower.strip_prefix('*') {
136 suffixes.push(suffix.to_string());
138 } else {
139 exact.push(lower);
140 }
141 }
142
143 Self {
144 allowed_hosts: exact,
145 allowed_suffixes: suffixes,
146 deny_hosts: DENY_HOSTS.iter().map(|s| s.to_lowercase()).collect(),
147 }
148 }
149
150 #[must_use]
154 pub fn allow_all() -> Self {
155 Self {
156 allowed_hosts: Vec::new(),
157 allowed_suffixes: Vec::new(),
158 deny_hosts: DENY_HOSTS.iter().map(|s| s.to_lowercase()).collect(),
159 }
160 }
161
162 #[must_use]
176 pub fn check_host(&self, host: &str, resolved_ips: &[IpAddr]) -> FilterResult {
177 let lower_host = host.to_lowercase();
178
179 if self.deny_hosts.contains(&lower_host) {
181 return FilterResult::DenyHost {
182 host: host.to_string(),
183 };
184 }
185
186 for ip in resolved_ips {
188 if is_link_local(ip) {
189 return FilterResult::DenyLinkLocal { ip: *ip };
190 }
191 }
192
193 if self.allowed_hosts.is_empty() && self.allowed_suffixes.is_empty() {
195 return FilterResult::Allow;
196 }
197
198 if self.allowed_hosts.contains(&lower_host) {
200 return FilterResult::Allow;
201 }
202
203 for suffix in &self.allowed_suffixes {
205 if lower_host.ends_with(suffix.as_str()) && lower_host.len() > suffix.len() {
206 return FilterResult::Allow;
207 }
208 }
209
210 FilterResult::DenyNotAllowed {
212 host: host.to_string(),
213 }
214 }
215
216 #[must_use]
218 pub fn allowed_count(&self) -> usize {
219 self.allowed_hosts
220 .len()
221 .saturating_add(self.allowed_suffixes.len())
222 }
223}
224
225#[cfg(test)]
226#[allow(clippy::unwrap_used)]
227mod tests {
228 use super::*;
229 use std::net::{Ipv4Addr, Ipv6Addr};
230
231 fn public_ip() -> Vec<IpAddr> {
232 vec![IpAddr::V4(Ipv4Addr::new(104, 18, 7, 96))]
233 }
234
235 #[test]
236 fn test_exact_host_allowed() {
237 let filter = HostFilter::new(&["api.openai.com".to_string()]);
238 let result = filter.check_host("api.openai.com", &public_ip());
239 assert!(result.is_allowed());
240 }
241
242 #[test]
243 fn test_exact_host_case_insensitive() {
244 let filter = HostFilter::new(&["API.OpenAI.COM".to_string()]);
245 let result = filter.check_host("api.openai.com", &public_ip());
246 assert!(result.is_allowed());
247 }
248
249 #[test]
250 fn test_host_not_in_allowlist() {
251 let filter = HostFilter::new(&["api.openai.com".to_string()]);
252 let result = filter.check_host("evil.com", &public_ip());
253 assert!(!result.is_allowed());
254 assert!(matches!(result, FilterResult::DenyNotAllowed { .. }));
255 }
256
257 #[test]
258 fn test_wildcard_subdomain_match() {
259 let filter = HostFilter::new(&["*.googleapis.com".to_string()]);
260
261 let result = filter.check_host("storage.googleapis.com", &public_ip());
263 assert!(result.is_allowed());
264
265 let result = filter.check_host("us-central1-aiplatform.googleapis.com", &public_ip());
267 assert!(result.is_allowed());
268 }
269
270 #[test]
271 fn test_wildcard_does_not_match_bare_domain() {
272 let filter = HostFilter::new(&["*.googleapis.com".to_string()]);
273
274 let result = filter.check_host("googleapis.com", &public_ip());
276 assert!(!result.is_allowed());
277 }
278
279 #[test]
280 fn test_deny_cloud_metadata_hostname() {
281 let filter = HostFilter::new(&["169.254.169.254".to_string()]);
282
283 let result = filter.check_host("169.254.169.254", &public_ip());
285 assert!(!result.is_allowed());
286 assert!(matches!(result, FilterResult::DenyHost { .. }));
287 }
288
289 #[test]
290 fn test_deny_google_metadata() {
291 let filter = HostFilter::new(&["metadata.google.internal".to_string()]);
292 let result = filter.check_host("metadata.google.internal", &public_ip());
293 assert!(!result.is_allowed());
294 }
295
296 #[test]
297 fn test_allow_all_mode() {
298 let filter = HostFilter::allow_all();
300 let result = filter.check_host("any-host.example.com", &public_ip());
301 assert!(result.is_allowed());
302 }
303
304 #[test]
305 fn test_allow_all_allows_private_networks() {
306 let filter = HostFilter::allow_all();
307 let private_ip = vec![IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))];
309 let result = filter.check_host("internal.corp.com", &private_ip);
310 assert!(result.is_allowed());
311 }
312
313 #[test]
314 fn test_allow_all_allows_192_168() {
315 let filter = HostFilter::allow_all();
316 let private_ip = vec![IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))];
317 let result = filter.check_host("nas.local", &private_ip);
318 assert!(result.is_allowed());
319 }
320
321 #[test]
322 fn test_deny_link_local_ipv4() {
323 let filter = HostFilter::new(&["*.example.com".to_string()]);
324 let link_local = vec![IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1))];
325 let result = filter.check_host("api.example.com", &link_local);
326 assert!(!result.is_allowed());
327 assert!(matches!(result, FilterResult::DenyLinkLocal { .. }));
328 }
329
330 #[test]
331 fn test_deny_link_local_ipv6() {
332 let filter = HostFilter::new(&["*.example.com".to_string()]);
333 let link_local = vec![IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1))];
334 let result = filter.check_host("api.example.com", &link_local);
335 assert!(!result.is_allowed());
336 assert!(matches!(result, FilterResult::DenyLinkLocal { .. }));
337 }
338
339 #[test]
340 fn test_deny_ipv4_mapped_ipv6_link_local() {
341 let filter = HostFilter::new(&["attacker.com".to_string()]);
343 let mapped = vec![IpAddr::V6(Ipv6Addr::new(
344 0, 0, 0, 0, 0, 0xffff, 0xa9fe, 0xa9fe,
345 ))];
346 let result = filter.check_host("attacker.com", &mapped);
347 assert!(!result.is_allowed());
348 assert!(matches!(result, FilterResult::DenyLinkLocal { .. }));
349 }
350
351 #[test]
352 fn test_deny_ipv4_mapped_ipv6_other_link_local() {
353 let filter = HostFilter::allow_all();
355 let mapped = vec![IpAddr::V6(Ipv6Addr::new(
356 0, 0, 0, 0, 0, 0xffff, 0xa9fe, 0x0001,
357 ))];
358 let result = filter.check_host("evil.com", &mapped);
359 assert!(!result.is_allowed());
360 }
361
362 #[test]
363 fn test_ipv4_mapped_ipv6_non_link_local_allowed() {
364 let filter = HostFilter::allow_all();
366 let mapped = vec![IpAddr::V6(Ipv6Addr::new(
367 0, 0, 0, 0, 0, 0xffff, 0x6812, 0x0760,
368 ))];
369 let result = filter.check_host("example.com", &mapped);
370 assert!(result.is_allowed());
371 }
372
373 #[test]
374 fn test_dns_rebinding_to_metadata_ip() {
375 let filter = HostFilter::new(&["attacker.com".to_string()]);
377 let metadata_ip = vec![IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))];
378 let result = filter.check_host("attacker.com", &metadata_ip);
379 assert!(!result.is_allowed());
380 assert!(matches!(result, FilterResult::DenyLinkLocal { .. }));
381 }
382
383 #[test]
384 fn test_dns_rebinding_allow_all_blocked() {
385 let filter = HostFilter::allow_all();
387 let metadata_ip = vec![IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))];
388 let result = filter.check_host("evil.com", &metadata_ip);
389 assert!(!result.is_allowed());
390 }
391
392 #[test]
393 fn test_empty_resolved_ips_skips_link_local_check() {
394 let filter = HostFilter::new(&["api.openai.com".to_string()]);
395 let result = filter.check_host("api.openai.com", &[]);
397 assert!(result.is_allowed());
398 }
399
400 #[test]
401 fn test_multiple_ips_any_link_local_denied() {
402 let filter = HostFilter::new(&["multi.example.com".to_string()]);
403 let ips = vec![
405 IpAddr::V4(Ipv4Addr::new(104, 18, 7, 96)),
406 IpAddr::V4(Ipv4Addr::new(169, 254, 0, 1)),
407 ];
408 let result = filter.check_host("multi.example.com", &ips);
409 assert!(!result.is_allowed());
410 }
411
412 #[test]
413 fn test_allowed_count() {
414 let filter = HostFilter::new(&[
415 "api.openai.com".to_string(),
416 "*.googleapis.com".to_string(),
417 "github.com".to_string(),
418 ]);
419 assert_eq!(filter.allowed_count(), 3);
420 }
421
422 #[test]
423 fn test_filter_result_reason() {
424 let allow = FilterResult::Allow;
425 assert!(allow.reason().contains("allowed"));
426
427 let deny = FilterResult::DenyNotAllowed {
428 host: "evil.com".to_string(),
429 };
430 assert!(deny.reason().contains("evil.com"));
431
432 let link_local = FilterResult::DenyLinkLocal {
433 ip: IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
434 };
435 assert!(link_local.reason().contains("link-local"));
436 }
437}