max / makenotwork
1 file changed,
+40 insertions,
-17 deletions
| @@ -9,7 +9,29 @@ const MAX_URLS: usize = 3; | |||
| 9 | 9 | /// Maximum response body size to read (1 MB). | |
| 10 | 10 | const MAX_BODY_SIZE: usize = 1_048_576; | |
| 11 | 11 | ||
| 12 | + | /// Check if an IP address is private/reserved (not safe for SSRF). | |
| 13 | + | fn is_private_ip(ip: std::net::IpAddr) -> bool { | |
| 14 | + | match ip { | |
| 15 | + | std::net::IpAddr::V4(v4) => { | |
| 16 | + | v4.is_loopback() | |
| 17 | + | || v4.is_private() | |
| 18 | + | || v4.is_link_local() | |
| 19 | + | || v4.is_broadcast() | |
| 20 | + | || v4.is_unspecified() | |
| 21 | + | || v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64 // 100.64.0.0/10 (CGNAT / Tailscale) | |
| 22 | + | } | |
| 23 | + | std::net::IpAddr::V6(v6) => { | |
| 24 | + | v6.is_loopback() | |
| 25 | + | || v6.is_unspecified() | |
| 26 | + | || (v6.segments()[0] & 0xfe00) == 0xfc00 // ULA fd00::/7 | |
| 27 | + | || (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local | |
| 28 | + | || matches!(v6.to_ipv4_mapped(), Some(v4) if is_private_ip(std::net::IpAddr::V4(v4))) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 12 | 33 | /// Validate that a URL is safe to fetch (no SSRF to internal networks). | |
| 34 | + | /// Resolves the hostname to catch alternative IP encodings (octal, hex, decimal, IPv6-mapped). | |
| 13 | 35 | fn validate_url(url: &str) -> bool { | |
| 14 | 36 | let lower = url.to_ascii_lowercase(); | |
| 15 | 37 | if !lower.starts_with("http://") && !lower.starts_with("https://") { | |
| @@ -30,26 +52,27 @@ fn validate_url(url: &str) -> bool { | |||
| 30 | 52 | host_and_port.split(':').next().unwrap_or("").to_string() | |
| 31 | 53 | }; | |
| 32 | 54 | let host = host.as_str(); | |
| 33 | - | if host == "localhost" | |
| 34 | - | || host == "127.0.0.1" | |
| 35 | - | || host == "[::1]" | |
| 36 | - | || host == "0.0.0.0" | |
| 37 | - | || host.starts_with("10.") | |
| 38 | - | || host.starts_with("192.168.") | |
| 39 | - | || host.starts_with("169.254.") | |
| 40 | - | || host.starts_with("[fd") | |
| 41 | - | || host.starts_with("[fe80:") | |
| 42 | - | { | |
| 55 | + | ||
| 56 | + | // Quick string-based check for common private patterns | |
| 57 | + | if host == "localhost" || host == "0.0.0.0" { | |
| 43 | 58 | return false; | |
| 44 | 59 | } | |
| 45 | - | // Block 172.16.0.0/12 | |
| 46 | - | if let Some(rest) = host.strip_prefix("172.") | |
| 47 | - | && let Some(second) = rest.split('.').next() | |
| 48 | - | && let Ok(n) = second.parse::<u8>() | |
| 49 | - | && (16..=31).contains(&n) | |
| 50 | - | { | |
| 51 | - | return false; | |
| 60 | + | ||
| 61 | + | // Try parsing as a raw IP address (catches octal, hex, decimal encodings) | |
| 62 | + | let bare_host = host.trim_start_matches('[').trim_end_matches(']'); | |
| 63 | + | if let Ok(ip) = bare_host.parse::<std::net::IpAddr>() { | |
| 64 | + | return !is_private_ip(ip); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // For hostnames, resolve and check all addresses | |
| 68 | + | if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&(bare_host, 80)) { | |
| 69 | + | for addr in addrs { | |
| 70 | + | if is_private_ip(addr.ip()) { | |
| 71 | + | return false; | |
| 72 | + | } | |
| 73 | + | } | |
| 52 | 74 | } | |
| 75 | + | ||
| 53 | 76 | true | |
| 54 | 77 | } | |
| 55 | 78 |