1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs};
10
11use ureq::config::Config;
12use ureq::http::Uri;
13use ureq::unversioned::resolver::{DefaultResolver, ResolvedSocketAddrs, Resolver};
14use ureq::unversioned::transport::NextTimeout;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum UrlError {
19 Empty,
20 BadScheme(String),
21 MissingHost,
22 Credentials,
23 Blocked(String),
24 Unresolvable(String),
25}
26
27impl std::fmt::Display for UrlError {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 match self {
30 Self::Empty => write!(f, "empty URL"),
31 Self::BadScheme(s) => {
32 write!(f, "unsupported scheme '{s}' (only http/https allowed)")
33 }
34 Self::MissingHost => write!(f, "URL has no host"),
35 Self::Credentials => write!(f, "URLs with embedded credentials are not allowed"),
36 Self::Blocked(h) => {
37 write!(
38 f,
39 "host '{h}' resolves to a blocked (private/loopback) address"
40 )
41 }
42 Self::Unresolvable(h) => write!(f, "host '{h}' could not be resolved"),
43 }
44 }
45}
46
47impl std::error::Error for UrlError {}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct SafeUrl {
52 pub scheme: String,
53 pub host: String,
54 pub port: u16,
55 pub authority: String,
56 pub normalized: String,
57}
58
59pub fn validate(raw: &str) -> Result<SafeUrl, UrlError> {
62 let trimmed = raw.trim();
63 if trimmed.is_empty() {
64 return Err(UrlError::Empty);
65 }
66 let Some((scheme_raw, rest)) = trimmed.split_once("://") else {
67 let head: String = trimmed.chars().take(12).collect();
68 return Err(UrlError::BadScheme(head));
69 };
70 let scheme = scheme_raw.to_ascii_lowercase();
71 if scheme != "http" && scheme != "https" {
72 return Err(UrlError::BadScheme(scheme));
73 }
74
75 let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
76 let authority = &rest[..auth_end];
77 let path = &rest[auth_end..];
78 if authority.is_empty() {
79 return Err(UrlError::MissingHost);
80 }
81 if authority.contains('@') {
82 return Err(UrlError::Credentials);
83 }
84
85 let (host, port) = split_host_port(authority, &scheme)?;
86 if host.is_empty() {
87 return Err(UrlError::MissingHost);
88 }
89
90 Ok(SafeUrl {
91 scheme: scheme.clone(),
92 host,
93 port,
94 authority: authority.to_string(),
95 normalized: format!("{scheme}://{authority}{path}"),
96 })
97}
98
99fn split_host_port(authority: &str, scheme: &str) -> Result<(String, u16), UrlError> {
100 let default_port = if scheme == "https" { 443 } else { 80 };
101
102 if let Some(stripped) = authority.strip_prefix('[') {
104 let Some(end) = stripped.find(']') else {
105 return Err(UrlError::MissingHost);
106 };
107 let host = stripped[..end].to_string();
108 let port = match stripped[end + 1..].strip_prefix(':') {
109 Some(p) => p.parse().map_err(|_| UrlError::MissingHost)?,
110 None => default_port,
111 };
112 return Ok((host, port));
113 }
114
115 match authority.rsplit_once(':') {
116 Some((host, port_str))
117 if !port_str.is_empty() && port_str.bytes().all(|b| b.is_ascii_digit()) =>
118 {
119 let port = port_str.parse().map_err(|_| UrlError::MissingHost)?;
120 Ok((host.to_string(), port))
121 }
122 _ => Ok((authority.to_string(), default_port)),
123 }
124}
125
126impl SafeUrl {
127 pub fn ensure_resolves_safely(&self) -> Result<(), UrlError> {
131 if let Ok(ip) = self.host.parse::<IpAddr>() {
132 return if ip_is_blocked(ip) {
133 Err(UrlError::Blocked(self.host.clone()))
134 } else {
135 Ok(())
136 };
137 }
138
139 let addrs = (self.host.as_str(), self.port)
140 .to_socket_addrs()
141 .map_err(|_| UrlError::Unresolvable(self.host.clone()))?;
142
143 let mut resolved_any = false;
144 for addr in addrs {
145 resolved_any = true;
146 if ip_is_blocked(addr.ip()) {
147 return Err(UrlError::Blocked(self.host.clone()));
148 }
149 }
150
151 if resolved_any {
152 Ok(())
153 } else {
154 Err(UrlError::Unresolvable(self.host.clone()))
155 }
156 }
157}
158
159pub fn ip_is_blocked(ip: IpAddr) -> bool {
161 match ip {
162 IpAddr::V4(v4) => v4_is_blocked(v4),
163 IpAddr::V6(v6) => {
164 if let Some(mapped) = v6.to_ipv4_mapped() {
166 return v4_is_blocked(mapped);
167 }
168 let v4 = |hi: u16, lo: u16| Ipv4Addr::from((u32::from(hi) << 16) | u32::from(lo));
170 let segments = v6.segments();
171 if segments[4] & !0x0300 == 0
176 && segments[5] == 0x5efe
177 && v4_is_blocked(v4(segments[6], segments[7]))
178 {
179 return true;
180 }
181 match segments {
182 [0x64, 0xff9b, 0, 0, 0, 0, hi, lo]
187 | [0x2002, hi, lo, ..]
188 | [0, 0, 0, 0, 0 | 0xffff, 0, hi, lo] => return v4_is_blocked(v4(hi, lo)),
189 [0x64, 0xff9b, 1, ..] => return true,
192 [0x2001, 0, shi, slo, _, _, chi, clo] => {
195 return v4_is_blocked(v4(shi, slo)) || v4_is_blocked(v4(!chi, !clo));
196 }
197 _ => {}
198 }
199 v6.is_loopback()
200 || v6.is_unspecified()
201 || v6.is_multicast()
202 || is_unique_local_v6(v6)
203 || is_link_local_v6(v6)
204 || is_site_local_v6(v6)
205 }
206 }
207}
208
209#[derive(Debug, Default)]
220pub struct SsrfSafeResolver {
221 inner: DefaultResolver,
222}
223
224impl Resolver for SsrfSafeResolver {
225 fn resolve(
226 &self,
227 uri: &Uri,
228 config: &Config,
229 timeout: NextTimeout,
230 ) -> Result<ResolvedSocketAddrs, ureq::Error> {
231 let addrs = self.inner.resolve(uri, config, timeout)?;
232 if addrs.iter().any(|addr| ip_is_blocked(addr.ip())) {
233 return Err(ureq::Error::HostNotFound);
234 }
235 Ok(addrs)
236 }
237}
238
239fn v4_is_blocked(v4: Ipv4Addr) -> bool {
240 let o = v4.octets();
241 v4.is_loopback()
242 || v4.is_private()
243 || v4.is_link_local()
244 || v4.is_broadcast()
245 || v4.is_unspecified()
246 || v4.is_documentation()
247 || o[0] == 0
248 || (o[0] == 100 && (o[1] & 0xc0) == 64)
250 || o[0] >= 224
252 || (o[0] == 198 && (o[1] & 0xfe) == 18)
254}
255
256fn is_unique_local_v6(v6: Ipv6Addr) -> bool {
257 (v6.segments()[0] & 0xfe00) == 0xfc00
258}
259
260fn is_site_local_v6(v6: Ipv6Addr) -> bool {
262 (v6.segments()[0] & 0xffc0) == 0xfec0
263}
264
265fn is_link_local_v6(v6: Ipv6Addr) -> bool {
266 (v6.segments()[0] & 0xffc0) == 0xfe80
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn validates_https_with_path() {
275 let u = validate("https://example.com/foo/bar?x=1").unwrap();
276 assert_eq!(u.scheme, "https");
277 assert_eq!(u.host, "example.com");
278 assert_eq!(u.port, 443);
279 assert_eq!(u.authority, "example.com");
280 assert_eq!(u.normalized, "https://example.com/foo/bar?x=1");
281 }
282
283 #[test]
284 fn validates_http_with_explicit_port() {
285 let u = validate("http://example.com:8080/p").unwrap();
286 assert_eq!(u.port, 8080);
287 assert_eq!(u.authority, "example.com:8080");
288 }
289
290 #[test]
291 fn validates_ipv6_literal_with_port() {
292 let u = validate("https://[2606:4700::1111]:8443/p").unwrap();
293 assert_eq!(u.host, "2606:4700::1111");
294 assert_eq!(u.port, 8443);
295 }
296
297 #[test]
298 fn rejects_non_http_scheme() {
299 assert!(matches!(
300 validate("ftp://example.com"),
301 Err(UrlError::BadScheme(_))
302 ));
303 assert!(matches!(
304 validate("file:///etc/passwd"),
305 Err(UrlError::BadScheme(_))
306 ));
307 }
308
309 #[test]
310 fn rejects_empty_and_credentials() {
311 assert_eq!(validate(" "), Err(UrlError::Empty));
312 assert_eq!(
313 validate("https://user:pass@example.com"),
314 Err(UrlError::Credentials)
315 );
316 }
317
318 #[test]
319 fn blocks_loopback_and_private_v4() {
320 for ip in ["127.0.0.1", "10.0.0.1", "192.168.1.1", "172.16.0.1"] {
321 assert!(ip_is_blocked(ip.parse().unwrap()), "{ip} must be blocked");
322 }
323 }
324
325 #[test]
326 fn blocks_metadata_and_cgnat() {
327 assert!(ip_is_blocked("169.254.169.254".parse().unwrap()));
328 assert!(ip_is_blocked("100.64.0.1".parse().unwrap()));
329 assert!(ip_is_blocked("0.0.0.0".parse().unwrap()));
330 }
331
332 #[test]
333 fn allows_public_v4_and_v6() {
334 assert!(!ip_is_blocked("8.8.8.8".parse().unwrap()));
335 assert!(!ip_is_blocked("1.1.1.1".parse().unwrap()));
336 assert!(!ip_is_blocked("2606:4700:4700::1111".parse().unwrap()));
337 }
338
339 #[test]
340 fn blocks_v6_internal_ranges() {
341 assert!(ip_is_blocked("::1".parse().unwrap()));
342 assert!(ip_is_blocked("fe80::1".parse().unwrap()));
343 assert!(ip_is_blocked("fc00::1".parse().unwrap()));
344 assert!(ip_is_blocked("::ffff:127.0.0.1".parse().unwrap()));
345 }
346
347 #[test]
348 fn blocks_v6_transition_addrs_embedding_internal_v4() {
349 for ip in [
352 "64:ff9b::a9fe:a9fe",
353 "64:ff9b::7f00:1",
354 "64:ff9b::a00:1",
355 "64:ff9b:1::a9fe:a9fe",
356 "2002:a9fe:a9fe::1",
357 "2002:7f00:1::1",
358 "2002:a00:1::1",
359 "2001:0:a9fe:a9fe::1",
360 "2001:0:808:808:0:0:5601:5601",
361 "2001:0:808:808:0:0:80ff:fffe",
362 "2001:0:808:808:0:0:f5ff:fffe",
363 "::a9fe:a9fe",
364 "::7f00:1",
365 "::a00:1",
366 "::",
367 ] {
368 assert!(ip_is_blocked(ip.parse().unwrap()), "{ip} must be blocked");
369 let u = validate(&format!("http://[{ip}]/")).unwrap();
370 assert!(
371 matches!(u.ensure_resolves_safely(), Err(UrlError::Blocked(_))),
372 "{ip} must be refused by ensure_resolves_safely"
373 );
374 }
375 }
376
377 #[test]
378 fn allows_v6_transition_addrs_embedding_public_v4() {
379 for ip in [
380 "64:ff9b::808:808",
381 "2002:808:808::1",
382 "2001:0:808:808:0:0:fefe:fefe",
384 "::808:808",
385 ] {
386 assert!(!ip_is_blocked(ip.parse().unwrap()), "{ip} must be allowed");
387 }
388 }
389
390 #[test]
393 fn blocks_siit_isatap_multicast_site_local_and_benchmarking() {
394 for ip in [
395 "::ffff:0:a9fe:a9fe",
397 "::ffff:0:7f00:1",
398 "::ffff:0:a00:1",
399 "2001:db8::5efe:a9fe:a9fe",
401 "2606:4700::200:5efe:7f00:1",
402 "2606:4700::5efe:a00:1",
403 "fe80::200:5efe:c0a8:101",
404 "ff02::1",
406 "ff05::2",
407 "ff0e::1",
408 "fec0::1",
409 "feff::1",
410 "224.0.0.1",
412 "239.255.255.250",
413 "240.0.0.1",
414 "198.18.0.1",
415 "198.19.255.255",
416 ] {
417 assert!(ip_is_blocked(ip.parse().unwrap()), "{ip} must be blocked");
418 let u = validate(&format!(
419 "http://{}/",
420 if ip.contains(':') {
421 format!("[{ip}]")
422 } else {
423 ip.to_string()
424 }
425 ))
426 .unwrap();
427 assert!(
428 matches!(u.ensure_resolves_safely(), Err(UrlError::Blocked(_))),
429 "{ip} must be refused by ensure_resolves_safely"
430 );
431 }
432 }
433
434 #[test]
435 fn allows_public_neighbours_of_the_new_blocked_ranges() {
436 for ip in [
437 "::ffff:0:808:808",
439 "2606:4700::5efe:808:808",
440 "2606:4700::200:5efe:101:101",
441 "2001:4860:4860::8888",
443 "fe00::1",
444 "198.17.255.255",
446 "198.20.0.1",
447 "223.255.255.254",
448 ] {
449 assert!(!ip_is_blocked(ip.parse().unwrap()), "{ip} must be allowed");
450 }
451 }
452
453 #[test]
454 fn ensure_resolves_safely_rejects_literal_loopback() {
455 let u = validate("http://127.0.0.1/").unwrap();
456 assert!(matches!(
457 u.ensure_resolves_safely(),
458 Err(UrlError::Blocked(_))
459 ));
460 }
461
462 #[test]
463 fn ensure_resolves_safely_allows_literal_public_ip() {
464 let u = validate("http://8.8.8.8/").unwrap();
465 assert!(u.ensure_resolves_safely().is_ok());
466 }
467
468 fn no_timeout() -> NextTimeout {
471 use ureq::unversioned::transport::time::Duration;
472 NextTimeout {
473 after: Duration::NotHappening,
474 reason: ureq::Timeout::Global,
475 }
476 }
477
478 #[test]
479 fn ssrf_safe_resolver_blocks_loopback_hostname() {
480 let resolver = SsrfSafeResolver::default();
486 let uri: Uri = "http://localhost:80/".parse().unwrap();
487 let config = Config::default();
488 assert!(resolver.resolve(&uri, &config, no_timeout()).is_err());
489 }
490
491 #[test]
492 fn ssrf_safe_resolver_allows_literal_public_ip() {
493 let resolver = SsrfSafeResolver::default();
494 let uri: Uri = "http://8.8.8.8:80/".parse().unwrap();
495 let config = Config::default();
496 assert!(resolver.resolve(&uri, &config, no_timeout()).is_ok());
497 }
498}