Skip to main content

nono/
net_filter.rs

1//! Network host filtering for proxy-level domain matching.
2//!
3//! This module provides application-layer host filtering that complements
4//! the OS-level port restrictions from [`CapabilitySet`](crate::CapabilitySet).
5//! The proxy uses [`HostFilter`] to decide whether to allow or deny CONNECT
6//! requests based on hostname allowlists and a cloud metadata deny list.
7//!
8//! # Security Properties
9//!
10//! - **Cloud metadata endpoints are hardcoded and non-overridable**: Instance
11//!   metadata services (169.254.169.254, metadata.google.internal, etc.) are
12//!   always denied regardless of allowlist configuration.
13//! - **Link-local IP protection**: Resolved IPs in the link-local range
14//!   (169.254.0.0/16, fe80::/10) are always denied to prevent DNS rebinding
15//!   attacks targeting cloud metadata services.
16//! - **Wildcard subdomain matching**: `*.googleapis.com` matches
17//!   `storage.googleapis.com` but not `googleapis.com` itself.
18
19use std::net::IpAddr;
20
21/// Result of a host filter check.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum FilterResult {
24    /// Host is allowed by the allowlist
25    Allow,
26    /// Host is denied because a specific hostname is in the deny list
27    DenyHost {
28        /// The hostname that was denied
29        host: String,
30    },
31    /// Host is denied because a resolved IP is in the link-local range
32    DenyLinkLocal {
33        /// The resolved IP that matched the link-local range
34        ip: IpAddr,
35    },
36    /// Host is not in the allowlist (default deny)
37    DenyNotAllowed {
38        /// The hostname that was not found in any allowlist
39        host: String,
40    },
41}
42
43impl FilterResult {
44    /// Whether the result is an allow decision
45    #[must_use]
46    pub fn is_allowed(&self) -> bool {
47        matches!(self, FilterResult::Allow)
48    }
49
50    /// A human-readable reason for the decision
51    #[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
71/// Check if an IP address is in the link-local range.
72///
73/// Link-local addresses are used by cloud metadata services (169.254.169.254)
74/// and must be blocked to prevent DNS rebinding SSRF attacks.
75///
76/// - IPv4: 169.254.0.0/16
77/// - IPv6: fe80::/10
78/// - IPv4-mapped IPv6: ::ffff:169.254.x.x (prevents bypass via AAAA records)
79fn 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            // Check IPv4-mapped IPv6 (::ffff:x.x.x.x) to prevent bypass
87            // via attacker-controlled AAAA records pointing to link-local IPs
88            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
96/// Hosts that are always denied regardless of allowlist configuration.
97/// These are cloud metadata endpoints commonly targeted for SSRF attacks.
98const DENY_HOSTS: &[&str] = &[
99    "169.254.169.254",
100    "metadata.google.internal",
101    "metadata.azure.internal",
102];
103
104/// A filter for host-based network access control.
105///
106/// Supports exact domain match and wildcard subdomains (`*.googleapis.com`).
107///
108/// Cloud metadata endpoints are always denied and cannot be overridden.
109/// The allowlist determines which hosts are permitted; everything else
110/// is denied by default.
111#[derive(Debug, Clone)]
112pub struct HostFilter {
113    /// Allowed exact hosts (lowercased)
114    allowed_hosts: Vec<String>,
115    /// Allowed wildcard suffixes (e.g., ".googleapis.com", lowercased)
116    allowed_suffixes: Vec<String>,
117    /// Hostnames that are always denied
118    deny_hosts: Vec<String>,
119}
120
121impl HostFilter {
122    /// Create a new host filter with the given allowed hosts.
123    ///
124    /// Cloud metadata endpoints are automatically denied and cannot be removed.
125    ///
126    /// Hosts starting with `*.` are treated as wildcard subdomain patterns.
127    /// All other entries are exact matches. Matching is case-insensitive.
128    #[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                // *.example.com -> .example.com
137                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    /// Create a host filter that allows everything (no filtering).
151    ///
152    /// Cloud metadata endpoints are still blocked.
153    #[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    /// Check a host against the filter.
163    ///
164    /// `resolved_ips` should contain the DNS-resolved IP addresses for the host.
165    /// The caller is responsible for performing DNS resolution before calling this
166    /// method. This prevents DNS rebinding attacks: the proxy resolves once, checks
167    /// the resolved IPs here, then connects to the same resolved IP.
168    ///
169    /// # Check Order
170    ///
171    /// 1. Deny hosts (exact match against cloud metadata hostnames)
172    /// 2. Link-local IP check (resolved IPs in 169.254.0.0/16 or fe80::/10)
173    /// 3. Allowlist (exact host match, then wildcard subdomain match)
174    /// 4. Default deny (if not in allowlist and allowlist is non-empty)
175    #[must_use]
176    pub fn check_host(&self, host: &str, resolved_ips: &[IpAddr]) -> FilterResult {
177        let lower_host = host.to_lowercase();
178
179        // 1. Check deny hosts
180        if self.deny_hosts.contains(&lower_host) {
181            return FilterResult::DenyHost {
182                host: host.to_string(),
183            };
184        }
185
186        // 2. Check resolved IPs for link-local addresses (cloud metadata protection)
187        for ip in resolved_ips {
188            if is_link_local(ip) {
189                return FilterResult::DenyLinkLocal { ip: *ip };
190            }
191        }
192
193        // 3. If no allowlist is configured (allow_all mode), allow
194        if self.allowed_hosts.is_empty() && self.allowed_suffixes.is_empty() {
195            return FilterResult::Allow;
196        }
197
198        // 4. Check exact host match
199        if self.allowed_hosts.contains(&lower_host) {
200            return FilterResult::Allow;
201        }
202
203        // 5. Check wildcard subdomain match
204        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        // 6. Not in allowlist
211        FilterResult::DenyNotAllowed {
212            host: host.to_string(),
213        }
214    }
215
216    /// Number of allowed hosts (exact + wildcard)
217    #[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        // Subdomain should match
262        let result = filter.check_host("storage.googleapis.com", &public_ip());
263        assert!(result.is_allowed());
264
265        // Deep subdomain should match
266        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        // Bare domain should NOT match wildcard
275        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        // Should be denied even if in allowlist
284        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        // No allowlist = allow all (except deny list)
299        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        // RFC1918 addresses are allowed for enterprise use
308        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        // Attacker returns AAAA record ::ffff:169.254.169.254 to bypass IPv4 check
342        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        // Any link-local in mapped form must be caught
354        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        // ::ffff:104.18.7.96 is a public IP in mapped form — should be allowed
365        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        // Attacker's domain resolves to cloud metadata IP — must be blocked
376        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        // Even in allow_all mode, link-local IPs are blocked
386        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        // No resolved IPs = skip link-local check, just check hostname
396        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        // First IP is public, second is link-local
404        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}