Skip to main content

max / makenotwork

Fix SSRF bypass in link preview via IP parsing and DNS resolution Replace string-based private IP checks with std::net::IpAddr parsing to catch octal, hex, decimal, and IPv6-mapped IPv4 encodings. Resolve hostnames and check all addresses. Block CGNAT/Tailscale range (100.64.0.0/10). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-26 20:06 UTC
Commit: 8ae68e6aa2ba929b6835228bd6f293bf2435054e
Parent: ab894e4
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