1use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
8
9use ipnet::{Ipv4Net, Ipv6Net};
10use url::Url;
11
12use crate::{IdprovaError, Result};
13
14fn blocked_v4() -> Vec<Ipv4Net> {
18 [
19 "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "198.18.0.0/15", "240.0.0.0/4", ]
30 .iter()
31 .map(|s| s.parse().expect("static CIDR is valid"))
32 .collect()
33}
34
35fn blocked_v6() -> Vec<Ipv6Net> {
37 [
38 "::1/128", "fc00::/7", "fe80::/10", "::ffff:0:0/96", "::/128", ]
44 .iter()
45 .map(|s| s.parse().expect("static CIDR is valid"))
46 .collect()
47}
48
49const ALLOWED_SCHEMES: &[&str] = &["https", "http"];
54
55pub 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 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 let host = url
81 .host_str()
82 .ok_or_else(|| IdprovaError::Other(format!("URL has no host: {raw_url}")))?;
83
84 if let Ok(ip) = host.parse::<IpAddr>() {
86 check_ip_blocked(ip)?;
87 return Ok(url);
88 }
89
90 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
113fn 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#[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#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[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 #[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 #[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 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 #[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}