Skip to main content

lean_ctx/core/web/
url_guard.rs

1//! URL validation and SSRF protection for outbound fetches.
2//!
3//! `ctx_url_read` accepts arbitrary URLs supplied by an agent, so every request
4//! is gated here before any socket is opened: a scheme allow-list, rejection of
5//! embedded credentials, and rejection of hosts that resolve to loopback /
6//! private / link-local / metadata ranges. Redirect hops are re-validated by the
7//! caller using the same primitives.
8
9use 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/// Reasons a URL is refused before fetching.
17#[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/// A syntactically valid http(s) URL with its parsed authority.
50#[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
59/// Validate URL *syntax* only (no DNS lookup). Call
60/// [`SafeUrl::ensure_resolves_safely`] before opening a socket.
61pub 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    // IPv6 literal form: `[::1]` or `[::1]:8080`.
103    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    /// Resolve the host and reject if *any* resolved address falls in a blocked
128    /// range. Rejecting on a single blocked result is a conservative guard
129    /// against DNS-rebinding that mixes a public and an internal address.
130    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/// True for addresses an outbound fetch must never reach (SSRF guard).
160pub fn ip_is_blocked(ip: IpAddr) -> bool {
161    match ip {
162        IpAddr::V4(v4) => v4_is_blocked(v4),
163        IpAddr::V6(v6) => {
164            // Dual-stack hosts can expose internal v4 ranges via mapped addrs.
165            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/// A [`ureq::unversioned::resolver::Resolver`] that pins DNS resolution to the
177/// exact lookup used for the SSRF check.
178///
179/// [`SafeUrl::ensure_resolves_safely`] and ureq's own resolver each perform an
180/// independent DNS lookup, which opens a rebinding window: a hostile resolver
181/// with a short TTL can answer the first (validation) query with a public
182/// address and the second (connect) query with a blocked one (e.g. the cloud
183/// metadata IP or loopback). Passing this resolver to the `ureq::Agent` makes
184/// resolution and validation the same lookup, so there is no second query left
185/// to rebind.
186#[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        // 100.64.0.0/10 carrier-grade NAT.
216        || (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    // --- SSRF-rebinding: resolver pinning ---
321
322    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        // `localhost` resolves via the OS hosts file to 127.0.0.1 with no
333        // network access, so this exercises the real DefaultResolver lookup
334        // path — a stand-in for a DNS-rebinding response that only appears
335        // blocked at the moment of connection, not at the earlier
336        // `ensure_resolves_safely` check.
337        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}