Skip to main content

pylon_plugin/builtin/
net_guard.rs

1/// Network security utilities for preventing SSRF attacks.
2///
3/// Provides a private/reserved IP blocklist check that should be called before
4/// any outbound network connection (webhooks, SMTP, etc.) to prevent
5/// Server-Side Request Forgery.
6
7/// Returns `true` if the given host (with optional `:port` suffix) resolves to
8/// a private, loopback, link-local, or otherwise reserved IP address range.
9///
10/// This blocks:
11/// - `localhost`, `127.x.x.x`, `::1`, `0.0.0.0` (loopback/unspecified)
12/// - `10.0.0.0/8` (RFC 1918)
13/// - `172.16.0.0/12` (RFC 1918)
14/// - `192.168.0.0/16` (RFC 1918)
15/// - `169.254.0.0/16` (link-local, includes AWS metadata at 169.254.169.254)
16/// - `0.0.0.0/8` (current network)
17/// - `::1`, `::0`, `::` (IPv6 loopback/unspecified)
18/// - `::ffff:x.x.x.x` (IPv4-mapped IPv6)
19/// - `fc00::/7` (IPv6 Unique Local)
20/// - `fe80::/10` (IPv6 Link-Local)
21pub fn is_private_ip(host: &str) -> bool {
22    // Extract the host portion, handling IPv6 brackets and port suffixes.
23    let clean_host = if host.starts_with('[') {
24        // Bracketed IPv6: [::1]:port or [::1]
25        host.trim_start_matches('[')
26            .split(']')
27            .next()
28            .unwrap_or(host)
29    } else if host.contains("::") || host.matches(':').count() > 1 {
30        // Bare IPv6 without brackets (multiple colons means IPv6, not host:port)
31        host
32    } else {
33        // IPv4 or hostname — split on last colon for port
34        host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host)
35    };
36
37    // Check well-known hostnames.
38    if clean_host == "localhost" || clean_host == "0.0.0.0" {
39        return true;
40    }
41
42    // Check IPv6 loopback and unspecified.
43    if clean_host == "::1" || clean_host == "::0" || clean_host == "::" {
44        return true;
45    }
46
47    // IPv4-mapped IPv6: ::ffff:127.0.0.1
48    if let Some(mapped) = clean_host.strip_prefix("::ffff:") {
49        return is_private_ipv4(mapped);
50    }
51
52    // IPv6 Unique Local (fc00::/7) — covers fc00:: through fdff::
53    if clean_host.starts_with("fc") || clean_host.starts_with("fd") {
54        return true;
55    }
56
57    // IPv6 Link-Local (fe80::/10)
58    if clean_host.starts_with("fe80") {
59        return true;
60    }
61
62    // Check IPv4
63    is_private_ipv4(clean_host)
64}
65
66/// Returns `true` if the given IPv4 address string is in a private/reserved range.
67fn is_private_ipv4(host: &str) -> bool {
68    let octets: Vec<u8> = host.split('.').filter_map(|s| s.parse().ok()).collect();
69    if octets.len() == 4 {
70        match (octets[0], octets[1]) {
71            (127, _) => true,       // 127.0.0.0/8 loopback
72            (10, _) => true,        // 10.0.0.0/8
73            (172, 16..=31) => true, // 172.16.0.0/12
74            (192, 168) => true,     // 192.168.0.0/16
75            (169, 254) => true,     // 169.254.0.0/16 (link-local + AWS metadata)
76            (0, _) => true,         // 0.0.0.0/8
77            _ => false,
78        }
79    } else {
80        false
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    // --- Loopback and special addresses ---
89
90    #[test]
91    fn blocks_localhost() {
92        assert!(is_private_ip("localhost"));
93        assert!(is_private_ip("localhost:8080"));
94    }
95
96    #[test]
97    fn blocks_loopback_127() {
98        assert!(is_private_ip("127.0.0.1"));
99        assert!(is_private_ip("127.0.0.1:80"));
100        assert!(is_private_ip("127.255.255.255"));
101        assert!(is_private_ip("127.0.0.2"));
102    }
103
104    #[test]
105    fn blocks_ipv6_loopback() {
106        assert!(is_private_ip("::1"));
107        assert!(is_private_ip("[::1]:8080"));
108    }
109
110    #[test]
111    fn blocks_ipv6_unspecified() {
112        assert!(is_private_ip("::0"));
113        assert!(is_private_ip("::"));
114    }
115
116    #[test]
117    fn blocks_unspecified() {
118        assert!(is_private_ip("0.0.0.0"));
119        assert!(is_private_ip("0.0.0.0:443"));
120    }
121
122    // --- RFC 1918 private ranges ---
123
124    #[test]
125    fn blocks_10_network() {
126        assert!(is_private_ip("10.0.0.1"));
127        assert!(is_private_ip("10.255.255.255"));
128        assert!(is_private_ip("10.0.0.1:9090"));
129    }
130
131    #[test]
132    fn blocks_172_16_network() {
133        assert!(is_private_ip("172.16.0.1"));
134        assert!(is_private_ip("172.31.255.255"));
135        assert!(is_private_ip("172.20.0.1:443"));
136    }
137
138    #[test]
139    fn allows_172_outside_range() {
140        assert!(!is_private_ip("172.15.0.1"));
141        assert!(!is_private_ip("172.32.0.1"));
142    }
143
144    #[test]
145    fn blocks_192_168_network() {
146        assert!(is_private_ip("192.168.0.1"));
147        assert!(is_private_ip("192.168.1.100:8080"));
148        assert!(is_private_ip("192.168.255.255"));
149    }
150
151    // --- Link-local / AWS metadata ---
152
153    #[test]
154    fn blocks_link_local() {
155        assert!(is_private_ip("169.254.0.1"));
156        assert!(is_private_ip("169.254.169.254")); // AWS metadata endpoint
157        assert!(is_private_ip("169.254.169.254:80"));
158    }
159
160    // --- 0.0.0.0/8 (current network) ---
161
162    #[test]
163    fn blocks_zero_network() {
164        assert!(is_private_ip("0.1.2.3"));
165        assert!(is_private_ip("0.255.255.255"));
166    }
167
168    // --- IPv6 private ranges ---
169
170    #[test]
171    fn blocks_ipv4_mapped_ipv6() {
172        assert!(is_private_ip("::ffff:127.0.0.1"));
173        assert!(is_private_ip("::ffff:10.0.0.1"));
174        assert!(is_private_ip("::ffff:192.168.1.1"));
175    }
176
177    #[test]
178    fn allows_ipv4_mapped_ipv6_public() {
179        assert!(!is_private_ip("::ffff:8.8.8.8"));
180    }
181
182    #[test]
183    fn blocks_ipv6_unique_local() {
184        assert!(is_private_ip("fc00::1"));
185        assert!(is_private_ip("fd12::1"));
186        assert!(is_private_ip("fdab:cdef::1"));
187    }
188
189    #[test]
190    fn blocks_ipv6_link_local() {
191        assert!(is_private_ip("fe80::1"));
192        assert!(is_private_ip("fe80::abcd:1234"));
193    }
194
195    #[test]
196    fn blocks_bracketed_ipv6_with_port() {
197        assert!(is_private_ip("[::1]:8080"));
198        assert!(is_private_ip("[fc00::1]:443"));
199        assert!(is_private_ip("[fe80::1]:80"));
200    }
201
202    // --- Public IPs should be allowed ---
203
204    #[test]
205    fn allows_public_ips() {
206        assert!(!is_private_ip("8.8.8.8"));
207        assert!(!is_private_ip("1.1.1.1"));
208        assert!(!is_private_ip("203.0.113.1"));
209        assert!(!is_private_ip("93.184.216.34:443"));
210    }
211
212    #[test]
213    fn allows_public_ipv6() {
214        // 2001:db8::/32 is documentation range but not in our private blocklist.
215        assert!(!is_private_ip("2001:db8::1"));
216    }
217
218    #[test]
219    fn allows_public_hostnames() {
220        assert!(!is_private_ip("example.com"));
221        assert!(!is_private_ip("example.com:443"));
222        assert!(!is_private_ip("api.github.com"));
223    }
224}