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 v6.is_loopback()
169 || v6.is_unspecified()
170 || is_unique_local_v6(v6)
171 || is_link_local_v6(v6)
172 }
173 }
174}
175
176#[derive(Debug, Default)]
187pub struct SsrfSafeResolver {
188 inner: DefaultResolver,
189}
190
191impl Resolver for SsrfSafeResolver {
192 fn resolve(
193 &self,
194 uri: &Uri,
195 config: &Config,
196 timeout: NextTimeout,
197 ) -> Result<ResolvedSocketAddrs, ureq::Error> {
198 let addrs = self.inner.resolve(uri, config, timeout)?;
199 if addrs.iter().any(|addr| ip_is_blocked(addr.ip())) {
200 return Err(ureq::Error::HostNotFound);
201 }
202 Ok(addrs)
203 }
204}
205
206fn v4_is_blocked(v4: Ipv4Addr) -> bool {
207 let o = v4.octets();
208 v4.is_loopback()
209 || v4.is_private()
210 || v4.is_link_local()
211 || v4.is_broadcast()
212 || v4.is_unspecified()
213 || v4.is_documentation()
214 || o[0] == 0
215 || (o[0] == 100 && (o[1] & 0xc0) == 64)
217}
218
219fn is_unique_local_v6(v6: Ipv6Addr) -> bool {
220 (v6.segments()[0] & 0xfe00) == 0xfc00
221}
222
223fn is_link_local_v6(v6: Ipv6Addr) -> bool {
224 (v6.segments()[0] & 0xffc0) == 0xfe80
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn validates_https_with_path() {
233 let u = validate("https://example.com/foo/bar?x=1").unwrap();
234 assert_eq!(u.scheme, "https");
235 assert_eq!(u.host, "example.com");
236 assert_eq!(u.port, 443);
237 assert_eq!(u.authority, "example.com");
238 assert_eq!(u.normalized, "https://example.com/foo/bar?x=1");
239 }
240
241 #[test]
242 fn validates_http_with_explicit_port() {
243 let u = validate("http://example.com:8080/p").unwrap();
244 assert_eq!(u.port, 8080);
245 assert_eq!(u.authority, "example.com:8080");
246 }
247
248 #[test]
249 fn validates_ipv6_literal_with_port() {
250 let u = validate("https://[2606:4700::1111]:8443/p").unwrap();
251 assert_eq!(u.host, "2606:4700::1111");
252 assert_eq!(u.port, 8443);
253 }
254
255 #[test]
256 fn rejects_non_http_scheme() {
257 assert!(matches!(
258 validate("ftp://example.com"),
259 Err(UrlError::BadScheme(_))
260 ));
261 assert!(matches!(
262 validate("file:///etc/passwd"),
263 Err(UrlError::BadScheme(_))
264 ));
265 }
266
267 #[test]
268 fn rejects_empty_and_credentials() {
269 assert_eq!(validate(" "), Err(UrlError::Empty));
270 assert_eq!(
271 validate("https://user:pass@example.com"),
272 Err(UrlError::Credentials)
273 );
274 }
275
276 #[test]
277 fn blocks_loopback_and_private_v4() {
278 for ip in ["127.0.0.1", "10.0.0.1", "192.168.1.1", "172.16.0.1"] {
279 assert!(ip_is_blocked(ip.parse().unwrap()), "{ip} must be blocked");
280 }
281 }
282
283 #[test]
284 fn blocks_metadata_and_cgnat() {
285 assert!(ip_is_blocked("169.254.169.254".parse().unwrap()));
286 assert!(ip_is_blocked("100.64.0.1".parse().unwrap()));
287 assert!(ip_is_blocked("0.0.0.0".parse().unwrap()));
288 }
289
290 #[test]
291 fn allows_public_v4_and_v6() {
292 assert!(!ip_is_blocked("8.8.8.8".parse().unwrap()));
293 assert!(!ip_is_blocked("1.1.1.1".parse().unwrap()));
294 assert!(!ip_is_blocked("2606:4700:4700::1111".parse().unwrap()));
295 }
296
297 #[test]
298 fn blocks_v6_internal_ranges() {
299 assert!(ip_is_blocked("::1".parse().unwrap()));
300 assert!(ip_is_blocked("fe80::1".parse().unwrap()));
301 assert!(ip_is_blocked("fc00::1".parse().unwrap()));
302 assert!(ip_is_blocked("::ffff:127.0.0.1".parse().unwrap()));
303 }
304
305 #[test]
306 fn ensure_resolves_safely_rejects_literal_loopback() {
307 let u = validate("http://127.0.0.1/").unwrap();
308 assert!(matches!(
309 u.ensure_resolves_safely(),
310 Err(UrlError::Blocked(_))
311 ));
312 }
313
314 #[test]
315 fn ensure_resolves_safely_allows_literal_public_ip() {
316 let u = validate("http://8.8.8.8/").unwrap();
317 assert!(u.ensure_resolves_safely().is_ok());
318 }
319
320 fn no_timeout() -> NextTimeout {
323 use ureq::unversioned::transport::time::Duration;
324 NextTimeout {
325 after: Duration::NotHappening,
326 reason: ureq::Timeout::Global,
327 }
328 }
329
330 #[test]
331 fn ssrf_safe_resolver_blocks_loopback_hostname() {
332 let resolver = SsrfSafeResolver::default();
338 let uri: Uri = "http://localhost:80/".parse().unwrap();
339 let config = Config::default();
340 assert!(resolver.resolve(&uri, &config, no_timeout()).is_err());
341 }
342
343 #[test]
344 fn ssrf_safe_resolver_allows_literal_public_ip() {
345 let resolver = SsrfSafeResolver::default();
346 let uri: Uri = "http://8.8.8.8:80/".parse().unwrap();
347 let config = Config::default();
348 assert!(resolver.resolve(&uri, &config, no_timeout()).is_ok());
349 }
350}