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
159#[cfg(feature = "test-support")]
163fn loopback_allowed(ip: IpAddr) -> bool {
164 ip == IpAddr::V4(Ipv4Addr::LOCALHOST)
165 && std::env::var("LEAN_CTX_URL_GUARD_ALLOW_LOOPBACK").is_ok_and(|v| v == "1")
166}
167
168#[cfg(not(feature = "test-support"))]
169fn loopback_allowed(_ip: IpAddr) -> bool {
170 false
171}
172
173pub fn ip_is_blocked(ip: IpAddr) -> bool {
175 if loopback_allowed(ip) {
176 return false;
177 }
178 match ip {
179 IpAddr::V4(v4) => v4_is_blocked(v4),
180 IpAddr::V6(v6) => {
181 if let Some(mapped) = v6.to_ipv4_mapped() {
183 return v4_is_blocked(mapped);
184 }
185 let v4 = |hi: u16, lo: u16| Ipv4Addr::from((u32::from(hi) << 16) | u32::from(lo));
187 let segments = v6.segments();
188 if segments[4] & !0x0300 == 0
193 && segments[5] == 0x5efe
194 && v4_is_blocked(v4(segments[6], segments[7]))
195 {
196 return true;
197 }
198 match segments {
199 [0x64, 0xff9b, 0, 0, 0, 0, hi, lo]
204 | [0x2002, hi, lo, ..]
205 | [0, 0, 0, 0, 0 | 0xffff, 0, hi, lo] => return v4_is_blocked(v4(hi, lo)),
206 [0x64, 0xff9b, 1, ..] => return true,
209 [0x2001, 0, shi, slo, _, _, chi, clo] => {
212 return v4_is_blocked(v4(shi, slo)) || v4_is_blocked(v4(!chi, !clo));
213 }
214 _ => {}
215 }
216 v6.is_loopback()
217 || v6.is_unspecified()
218 || v6.is_multicast()
219 || is_unique_local_v6(v6)
220 || is_link_local_v6(v6)
221 || is_site_local_v6(v6)
222 }
223 }
224}
225
226#[derive(Debug, Default)]
237pub struct SsrfSafeResolver {
238 inner: DefaultResolver,
239}
240
241impl Resolver for SsrfSafeResolver {
242 fn resolve(
243 &self,
244 uri: &Uri,
245 config: &Config,
246 timeout: NextTimeout,
247 ) -> Result<ResolvedSocketAddrs, ureq::Error> {
248 let addrs = self.inner.resolve(uri, config, timeout)?;
249 if addrs.iter().any(|addr| ip_is_blocked(addr.ip())) {
250 return Err(ureq::Error::HostNotFound);
251 }
252 Ok(addrs)
253 }
254}
255
256fn v4_is_blocked(v4: Ipv4Addr) -> bool {
257 let o = v4.octets();
258 v4.is_loopback()
259 || v4.is_private()
260 || v4.is_link_local()
261 || v4.is_broadcast()
262 || v4.is_unspecified()
263 || v4.is_documentation()
264 || o[0] == 0
265 || (o[0] == 100 && (o[1] & 0xc0) == 64)
267 || o[0] >= 224
269 || (o[0] == 198 && (o[1] & 0xfe) == 18)
271}
272
273fn is_unique_local_v6(v6: Ipv6Addr) -> bool {
274 (v6.segments()[0] & 0xfe00) == 0xfc00
275}
276
277fn is_site_local_v6(v6: Ipv6Addr) -> bool {
279 (v6.segments()[0] & 0xffc0) == 0xfec0
280}
281
282fn is_link_local_v6(v6: Ipv6Addr) -> bool {
283 (v6.segments()[0] & 0xffc0) == 0xfe80
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn validates_https_with_path() {
292 let u = validate("https://example.com/foo/bar?x=1").unwrap();
293 assert_eq!(u.scheme, "https");
294 assert_eq!(u.host, "example.com");
295 assert_eq!(u.port, 443);
296 assert_eq!(u.authority, "example.com");
297 assert_eq!(u.normalized, "https://example.com/foo/bar?x=1");
298 }
299
300 #[test]
301 fn validates_http_with_explicit_port() {
302 let u = validate("http://example.com:8080/p").unwrap();
303 assert_eq!(u.port, 8080);
304 assert_eq!(u.authority, "example.com:8080");
305 }
306
307 #[test]
308 fn validates_ipv6_literal_with_port() {
309 let u = validate("https://[2606:4700::1111]:8443/p").unwrap();
310 assert_eq!(u.host, "2606:4700::1111");
311 assert_eq!(u.port, 8443);
312 }
313
314 #[test]
315 fn rejects_non_http_scheme() {
316 assert!(matches!(
317 validate("ftp://example.com"),
318 Err(UrlError::BadScheme(_))
319 ));
320 assert!(matches!(
321 validate("file:///etc/passwd"),
322 Err(UrlError::BadScheme(_))
323 ));
324 }
325
326 #[test]
327 fn rejects_empty_and_credentials() {
328 assert_eq!(validate(" "), Err(UrlError::Empty));
329 assert_eq!(
330 validate("https://user:pass@example.com"),
331 Err(UrlError::Credentials)
332 );
333 }
334
335 #[test]
336 fn blocks_loopback_and_private_v4() {
337 for ip in ["127.0.0.1", "10.0.0.1", "192.168.1.1", "172.16.0.1"] {
338 assert!(ip_is_blocked(ip.parse().unwrap()), "{ip} must be blocked");
339 }
340 }
341
342 #[test]
343 fn blocks_metadata_and_cgnat() {
344 assert!(ip_is_blocked("169.254.169.254".parse().unwrap()));
345 assert!(ip_is_blocked("100.64.0.1".parse().unwrap()));
346 assert!(ip_is_blocked("0.0.0.0".parse().unwrap()));
347 }
348
349 #[test]
350 fn allows_public_v4_and_v6() {
351 assert!(!ip_is_blocked("8.8.8.8".parse().unwrap()));
352 assert!(!ip_is_blocked("1.1.1.1".parse().unwrap()));
353 assert!(!ip_is_blocked("2606:4700:4700::1111".parse().unwrap()));
354 }
355
356 #[test]
357 fn blocks_v6_internal_ranges() {
358 assert!(ip_is_blocked("::1".parse().unwrap()));
359 assert!(ip_is_blocked("fe80::1".parse().unwrap()));
360 assert!(ip_is_blocked("fc00::1".parse().unwrap()));
361 assert!(ip_is_blocked("::ffff:127.0.0.1".parse().unwrap()));
362 }
363
364 #[test]
365 fn blocks_v6_transition_addrs_embedding_internal_v4() {
366 for ip in [
369 "64:ff9b::a9fe:a9fe",
370 "64:ff9b::7f00:1",
371 "64:ff9b::a00:1",
372 "64:ff9b:1::a9fe:a9fe",
373 "2002:a9fe:a9fe::1",
374 "2002:7f00:1::1",
375 "2002:a00:1::1",
376 "2001:0:a9fe:a9fe::1",
377 "2001:0:808:808:0:0:5601:5601",
378 "2001:0:808:808:0:0:80ff:fffe",
379 "2001:0:808:808:0:0:f5ff:fffe",
380 "::a9fe:a9fe",
381 "::7f00:1",
382 "::a00:1",
383 "::",
384 ] {
385 assert!(ip_is_blocked(ip.parse().unwrap()), "{ip} must be blocked");
386 let u = validate(&format!("http://[{ip}]/")).unwrap();
387 assert!(
388 matches!(u.ensure_resolves_safely(), Err(UrlError::Blocked(_))),
389 "{ip} must be refused by ensure_resolves_safely"
390 );
391 }
392 }
393
394 #[test]
395 fn allows_v6_transition_addrs_embedding_public_v4() {
396 for ip in [
397 "64:ff9b::808:808",
398 "2002:808:808::1",
399 "2001:0:808:808:0:0:fefe:fefe",
401 "::808:808",
402 ] {
403 assert!(!ip_is_blocked(ip.parse().unwrap()), "{ip} must be allowed");
404 }
405 }
406
407 #[test]
410 fn blocks_siit_isatap_multicast_site_local_and_benchmarking() {
411 for ip in [
412 "::ffff:0:a9fe:a9fe",
414 "::ffff:0:7f00:1",
415 "::ffff:0:a00:1",
416 "2001:db8::5efe:a9fe:a9fe",
418 "2606:4700::200:5efe:7f00:1",
419 "2606:4700::5efe:a00:1",
420 "fe80::200:5efe:c0a8:101",
421 "ff02::1",
423 "ff05::2",
424 "ff0e::1",
425 "fec0::1",
426 "feff::1",
427 "224.0.0.1",
429 "239.255.255.250",
430 "240.0.0.1",
431 "198.18.0.1",
432 "198.19.255.255",
433 ] {
434 assert!(ip_is_blocked(ip.parse().unwrap()), "{ip} must be blocked");
435 let u = validate(&format!(
436 "http://{}/",
437 if ip.contains(':') {
438 format!("[{ip}]")
439 } else {
440 ip.to_string()
441 }
442 ))
443 .unwrap();
444 assert!(
445 matches!(u.ensure_resolves_safely(), Err(UrlError::Blocked(_))),
446 "{ip} must be refused by ensure_resolves_safely"
447 );
448 }
449 }
450
451 #[test]
452 fn allows_public_neighbours_of_the_new_blocked_ranges() {
453 for ip in [
454 "::ffff:0:808:808",
456 "2606:4700::5efe:808:808",
457 "2606:4700::200:5efe:101:101",
458 "2001:4860:4860::8888",
460 "fe00::1",
461 "198.17.255.255",
463 "198.20.0.1",
464 "223.255.255.254",
465 ] {
466 assert!(!ip_is_blocked(ip.parse().unwrap()), "{ip} must be allowed");
467 }
468 }
469
470 #[test]
471 fn ensure_resolves_safely_rejects_literal_loopback() {
472 let u = validate("http://127.0.0.1/").unwrap();
473 assert!(matches!(
474 u.ensure_resolves_safely(),
475 Err(UrlError::Blocked(_))
476 ));
477 }
478
479 #[test]
480 fn ensure_resolves_safely_allows_literal_public_ip() {
481 let u = validate("http://8.8.8.8/").unwrap();
482 assert!(u.ensure_resolves_safely().is_ok());
483 }
484
485 fn no_timeout() -> NextTimeout {
488 use ureq::unversioned::transport::time::Duration;
489 NextTimeout {
490 after: Duration::NotHappening,
491 reason: ureq::Timeout::Global,
492 }
493 }
494
495 #[test]
496 fn ssrf_safe_resolver_blocks_loopback_hostname() {
497 let resolver = SsrfSafeResolver::default();
503 let uri: Uri = "http://localhost:80/".parse().unwrap();
504 let config = Config::default();
505 assert!(resolver.resolve(&uri, &config, no_timeout()).is_err());
506 }
507
508 #[test]
509 fn ssrf_safe_resolver_allows_literal_public_ip() {
510 let resolver = SsrfSafeResolver::default();
511 let uri: Uri = "http://8.8.8.8:80/".parse().unwrap();
512 let config = Config::default();
513 assert!(resolver.resolve(&uri, &config, no_timeout()).is_ok());
514 }
515}