Skip to main content

idprova_core/
http.rs

1//! SSRF-safe URL validation and secure HTTP client builder.
2//!
3//! Use [`validate_registry_url`] to sanitize any registry URL before making
4//! outbound requests. Rejects private/loopback IPs, dangerous schemes, and
5//! cloud metadata endpoints.
6
7use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
8
9use ipnet::{Ipv4Net, Ipv6Net};
10use url::Url;
11
12use crate::{IdprovaError, Result};
13
14// ── Blocked CIDR ranges ──────────────────────────────────────────────────────
15
16/// IPv4 ranges that must never be contacted (SSRF prevention).
17fn blocked_v4() -> Vec<Ipv4Net> {
18    [
19        "127.0.0.0/8",    // loopback
20        "10.0.0.0/8",     // private class A
21        "172.16.0.0/12",  // private class B
22        "192.168.0.0/16", // private class C
23        "169.254.0.0/16", // link-local (cloud metadata)
24        "0.0.0.0/8",      // unspecified
25        "100.64.0.0/10",  // shared address space
26        "192.0.0.0/24",   // IETF protocol assignments
27        "198.18.0.0/15",  // benchmark testing
28        "240.0.0.0/4",    // reserved
29    ]
30    .iter()
31    .map(|s| s.parse().expect("static CIDR is valid"))
32    .collect()
33}
34
35/// IPv6 ranges that must never be contacted.
36fn blocked_v6() -> Vec<Ipv6Net> {
37    [
38        "::1/128",       // loopback
39        "fc00::/7",      // unique local
40        "fe80::/10",     // link-local
41        "::ffff:0:0/96", // IPv4-mapped
42        "::/128",        // unspecified
43    ]
44    .iter()
45    .map(|s| s.parse().expect("static CIDR is valid"))
46    .collect()
47}
48
49// ── Allowed schemes ───────────────────────────────────────────────────────────
50
51/// Only HTTPS (and HTTP for localhost in tests/dev) are permitted.
52/// All other schemes — file://, gopher://, ldap://, ftp://, data:, etc. — are rejected.
53const ALLOWED_SCHEMES: &[&str] = &["https", "http"];
54
55// ── Public API ────────────────────────────────────────────────────────────────
56
57/// Validate a registry URL for SSRF safety.
58///
59/// Rejects:
60/// - Non-HTTP/HTTPS schemes (`file://`, `gopher://`, `ldap://`, `ftp://`, etc.)
61/// - Private/loopback IPv4: `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`
62/// - Link-local / cloud metadata: `169.254.0.0/16`
63/// - IPv6 loopback `::1` and ULA `fc00::/7`
64/// - Hostnames that resolve to any of the above
65///
66/// Returns the parsed [`Url`] on success.
67pub fn validate_registry_url(raw_url: &str) -> Result<Url> {
68    let url = Url::parse(raw_url)
69        .map_err(|e| IdprovaError::Other(format!("invalid URL '{raw_url}': {e}")))?;
70
71    // Check scheme
72    if !ALLOWED_SCHEMES.contains(&url.scheme()) {
73        return Err(IdprovaError::Other(format!(
74            "URL scheme '{}' is not permitted; only https/http are allowed",
75            url.scheme()
76        )));
77    }
78
79    // Extract host — a URL without a host is invalid for registry use
80    let host = url
81        .host_str()
82        .ok_or_else(|| IdprovaError::Other(format!("URL has no host: {raw_url}")))?;
83
84    // If the host is an IP literal, check it directly
85    if let Ok(ip) = host.parse::<IpAddr>() {
86        check_ip_blocked(ip)?;
87        return Ok(url);
88    }
89
90    // Otherwise resolve hostname → check each resolved IP
91    let port = url
92        .port()
93        .unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
94    let addrs_str = format!("{host}:{port}");
95    let resolved: Vec<SocketAddr> = addrs_str
96        .to_socket_addrs()
97        .map_err(|e| IdprovaError::Other(format!("cannot resolve host '{host}': {e}")))?
98        .collect();
99
100    if resolved.is_empty() {
101        return Err(IdprovaError::Other(format!(
102            "host '{host}' resolved to no addresses"
103        )));
104    }
105
106    for addr in resolved {
107        check_ip_blocked(addr.ip())?;
108    }
109
110    Ok(url)
111}
112
113/// Check whether a single IP address falls in a blocked range.
114fn check_ip_blocked(ip: IpAddr) -> Result<()> {
115    match ip {
116        IpAddr::V4(v4) => {
117            for net in blocked_v4() {
118                if net.contains(&v4) {
119                    return Err(IdprovaError::Other(format!(
120                        "IP address {ip} is in blocked range {net} (SSRF prevention)"
121                    )));
122                }
123            }
124        }
125        IpAddr::V6(v6) => {
126            for net in blocked_v6() {
127                if net.contains(&v6) {
128                    return Err(IdprovaError::Other(format!(
129                        "IP address {ip} is in blocked range {net} (SSRF prevention)"
130                    )));
131                }
132            }
133        }
134    }
135    Ok(())
136}
137
138/// Build a secure, SSRF-safe [`reqwest::Client`] for registry communication.
139///
140/// Configuration:
141/// - `timeout`: 10 seconds total
142/// - `connect_timeout`: 5 seconds
143/// - `redirect` limit: 5
144/// - `https_only`: true (no plain-HTTP redirects)
145/// - `user_agent`: `idprova-client/{version}`
146#[cfg(feature = "http")]
147pub fn build_registry_client() -> std::result::Result<reqwest::Client, reqwest::Error> {
148    reqwest::Client::builder()
149        .timeout(std::time::Duration::from_secs(10))
150        .connect_timeout(std::time::Duration::from_secs(5))
151        .redirect(reqwest::redirect::Policy::limited(5))
152        .https_only(true)
153        .user_agent(format!("idprova-client/{}", env!("CARGO_PKG_VERSION")))
154        .build()
155}
156
157// ── Tests ─────────────────────────────────────────────────────────────────────
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    // ── Scheme rejection ──────────────────────────────────────────────────────
164
165    #[test]
166    fn test_reject_file_scheme() {
167        let result = validate_registry_url("file:///etc/passwd");
168        assert!(result.is_err(), "file:// must be rejected");
169        let msg = result.unwrap_err().to_string();
170        assert!(msg.contains("scheme"), "expected scheme error, got: {msg}");
171    }
172
173    #[test]
174    fn test_reject_gopher_scheme() {
175        assert!(validate_registry_url("gopher://evil.example.com/").is_err());
176    }
177
178    #[test]
179    fn test_reject_ldap_scheme() {
180        assert!(validate_registry_url("ldap://10.0.0.1/dc=example").is_err());
181    }
182
183    #[test]
184    fn test_reject_ftp_scheme() {
185        assert!(validate_registry_url("ftp://ftp.example.com/file").is_err());
186    }
187
188    #[test]
189    fn test_reject_data_uri() {
190        assert!(validate_registry_url("data:text/html,<script>alert(1)</script>").is_err());
191    }
192
193    // ── Private IP rejection ──────────────────────────────────────────────────
194
195    #[test]
196    fn test_reject_loopback_ipv4() {
197        assert!(validate_registry_url("http://127.0.0.1/api").is_err());
198        assert!(validate_registry_url("http://127.1.2.3/api").is_err());
199    }
200
201    #[test]
202    fn test_reject_private_class_a() {
203        assert!(validate_registry_url("https://10.0.0.1/api").is_err());
204        assert!(validate_registry_url("https://10.255.255.255/api").is_err());
205    }
206
207    #[test]
208    fn test_reject_private_class_b() {
209        assert!(validate_registry_url("https://172.16.0.1/api").is_err());
210        assert!(validate_registry_url("https://172.31.255.255/api").is_err());
211    }
212
213    #[test]
214    fn test_reject_private_class_c() {
215        assert!(validate_registry_url("https://192.168.1.1/api").is_err());
216    }
217
218    #[test]
219    fn test_reject_cloud_metadata() {
220        assert!(validate_registry_url("http://169.254.169.254/latest/meta-data/").is_err());
221    }
222
223    #[test]
224    fn test_reject_ipv6_loopback() {
225        assert!(validate_registry_url("https://[::1]/api").is_err());
226    }
227
228    #[test]
229    fn test_reject_ipv6_ula() {
230        assert!(validate_registry_url("https://[fc00::1]/api").is_err());
231        assert!(validate_registry_url("https://[fd00::1]/api").is_err());
232    }
233
234    // ── Valid URLs accepted ───────────────────────────────────────────────────
235
236    #[test]
237    #[ignore = "requires DNS + network access"]
238    fn test_accept_public_https_url() {
239        let result = validate_registry_url("https://registry.idprova.dev");
240        assert!(
241            result.is_ok(),
242            "public HTTPS URL must be accepted: {:?}",
243            result.err()
244        );
245    }
246
247    #[test]
248    #[ignore = "requires DNS + network access"]
249    fn test_accept_public_https_with_path() {
250        assert!(validate_registry_url("https://registry.idprova.dev/v1/aid/test").is_ok());
251    }
252
253    #[test]
254    fn test_accept_public_ip() {
255        // Public IPs (not in any private/reserved range) must be accepted
256        assert!(validate_registry_url("https://1.1.1.1/api").is_ok());
257        assert!(validate_registry_url("https://8.8.8.8/api").is_ok());
258    }
259
260    // ── Edge cases ────────────────────────────────────────────────────────────
261
262    #[test]
263    fn test_reject_malformed_url() {
264        assert!(validate_registry_url("not a url at all").is_err());
265        assert!(validate_registry_url("").is_err());
266    }
267}