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            // Transition prefixes that embed an IPv4 destination (GHSA-h693-v7jv-vmv9).
169            let v4 = |hi: u16, lo: u16| Ipv4Addr::from((u32::from(hi) << 16) | u32::from(lo));
170            let segments = v6.segments();
171            // ISATAP (RFC 5214): any /64 prefix with interface ID
172            // `0:5efe:a.b.c.d`, or `200:5efe:…` with the universal/local bit
173            // (the group bit is masked too). Checked independently of the
174            // prefix forms below, which an ISATAP interface ID can share.
175            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                // NAT64 well-known prefix 64:ff9b::/96 (RFC 6052), 6to4 2002::/16
183                // (RFC 3056, v4 in bits 16..48), deprecated IPv4-compatible
184                // ::a.b.c.d (RFC 4291; `::` and `::1` land in 0.0.0.0/8, blocked)
185                // and SIIT IPv4-translated ::ffff:0:a.b.c.d (RFC 2765).
186                [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                // NAT64 local-use 64:ff9b:1::/48 (RFC 8215): the operator picks the
190                // prefix length, so the embedded v4 cannot be located; never public.
191                [0x64, 0xff9b, 1, ..] => return true,
192                // Teredo 2001::/32 (RFC 4380): server v4 in bits 32..64, client v4
193                // bit-inverted in the last 32 bits.
194                [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/// A [`ureq::unversioned::resolver::Resolver`] that pins DNS resolution to the
210/// exact lookup used for the SSRF check.
211///
212/// [`SafeUrl::ensure_resolves_safely`] and ureq's own resolver each perform an
213/// independent DNS lookup, which opens a rebinding window: a hostile resolver
214/// with a short TTL can answer the first (validation) query with a public
215/// address and the second (connect) query with a blocked one (e.g. the cloud
216/// metadata IP or loopback). Passing this resolver to the `ureq::Agent` makes
217/// resolution and validation the same lookup, so there is no second query left
218/// to rebind.
219#[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        // 100.64.0.0/10 carrier-grade NAT.
249        || (o[0] == 100 && (o[1] & 0xc0) == 64)
250        // 224.0.0.0/4 multicast and 240.0.0.0/4 reserved (incl. broadcast).
251        || o[0] >= 224
252        // 198.18.0.0/15 benchmarking (RFC 2544), routed to internal test nets.
253        || (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
260/// fec0::/10, deprecated site-local (RFC 3879) — still routed internally.
261fn 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        // GHSA-h693-v7jv-vmv9: 169.254.169.254, 127.0.0.1 and 10.0.0.1 via
350        // NAT64, 6to4, Teredo (server and client) and IPv4-compatible forms.
351        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            // Teredo: server 8.8.8.8, client 1.1.1.1 (inverted fefe:fefe).
383            "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    /// GHSA-h693-v7jv-vmv9 residuals: SIIT, ISATAP, IPv6 multicast and
391    /// site-local, IPv4 multicast/reserved and benchmarking ranges.
392    #[test]
393    fn blocks_siit_isatap_multicast_site_local_and_benchmarking() {
394        for ip in [
395            // SIIT IPv4-translated ::ffff:0:a.b.c.d.
396            "::ffff:0:a9fe:a9fe",
397            "::ffff:0:7f00:1",
398            "::ffff:0:a00:1",
399            // ISATAP interface IDs (local and universal) embedding internal v4.
400            "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            // IPv6 multicast ff00::/8 and site-local fec0::/10.
405            "ff02::1",
406            "ff05::2",
407            "ff0e::1",
408            "fec0::1",
409            "feff::1",
410            // IPv4 multicast, reserved and benchmarking.
411            "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            // SIIT and ISATAP embedding public v4 stay allowed.
438            "::ffff:0:808:808",
439            "2606:4700::5efe:808:808",
440            "2606:4700::200:5efe:101:101",
441            // Global unicast neighbours of fec0::/10 and ff00::/8.
442            "2001:4860:4860::8888",
443            "fe00::1",
444            // Public v4 next to the benchmarking and multicast ranges.
445            "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    // --- SSRF-rebinding: resolver pinning ---
469
470    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        // `localhost` resolves via the OS hosts file to 127.0.0.1 with no
481        // network access, so this exercises the real DefaultResolver lookup
482        // path — a stand-in for a DNS-rebinding response that only appears
483        // blocked at the moment of connection, not at the earlier
484        // `ensure_resolves_safely` check.
485        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}