Skip to main content

net/
ssrf.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! SSRF classifier.
3//!
4//! A *pure* address classifier plus a DNS-resolving host guard. The rule it
5//! enforces is blunt: the HTTP client refuses RFC-1918 and link-local
6//! destinations by default. This module is the mechanism; it is composed at
7//! every call site that introduces a model/agent/peer-supplied URL (A2A push
8//! targets, `http` workflow nodes), while the only operator-configured
9//! outbound (`intel/client.rs`) is exempt — its address comes from the
10//! operator, not from anything the model can steer.
11//!
12//! ## Resolve once, dial what you vetted
13//!
14//! A guard that resolves a name, likes the answer, and then lets the
15//! caller dial the *name* is decorative: the connect re-resolves, and an
16//! attacker who controls the authoritative DNS answers the guard with a
17//! public address and the connect with `169.254.169.254`. That is DNS
18//! rebinding, and it defeats an address check that does not carry its
19//! result forward.
20//!
21//! So the guard hands back the addresses it vetted
22//! ([`resolve_guarded`]) and the dial takes *addresses*, never a name
23//! ([`connect_vetted`] / [`connect_addrs`], which re-assert [`is_global`]
24//! on every address immediately before the syscall). TLS and the `Host`
25//! header stay on the original hostname — connect by IP, verify by name —
26//! so SNI and certificate validation are unaffected.
27//!
28//! [`guard_host`] serves the yes/no admission check at *registration*
29//! time, where there is no socket to dial yet; it is not sufficient on
30//! its own at delivery time.
31//!
32//! ## What "non-global" means here
33//!
34//! [`is_global`] returns `false` — i.e. the address is *blocked* — for
35//! any address an attacker could pivot to from inside the appliance's
36//! network namespace:
37//!
38//! * loopback (`127.0.0.0/8`, `::1`)
39//! * RFC-1918 private (`10/8`, `172.16/12`, `192.168/16`)
40//! * link-local (`169.254/16`, `fe80::/10`) — this is the cloud
41//!   metadata range (`169.254.169.254`)
42//! * IPv6 unique-local (`fc00::/7`)
43//! * unspecified (`0.0.0.0`, `::`)
44//! * multicast and the IPv4 limited broadcast (`255.255.255.255`)
45//! * "this network" `0.0.0.0/8` and the IETF/benchmark documentation
46//!   ranges, which never route on the public Internet
47//! * **any IPv4-mapped / IPv4-compatible IPv6** whose embedded v4
48//!   address is itself non-global — `::ffff:127.0.0.1` and friends are
49//!   a classic guard bypass, so we unwrap before classifying.
50//!
51//! We deliberately do NOT lean on `std`'s unstable `IpAddr::is_global`
52//! (feature `ip`, issue #27709) — it is not available on our MSRV and
53//! its semantics are still in flux. Every range below is spelled out by
54//! hand from primitives that are stable, in the same enumerate-the-bytes
55//! spirit as the rest of the crate.
56//!
57//! ## Logging posture
58//!
59//! Hosts and IPs are *operational* identifiers, not tool/instruction
60//! content, so the diagnostic carries the host and the offending class
61//! — never request bodies, headers, or secrets. The codebase is
62//! content-capture-off by default and this module keeps that contract.
63
64use std::io;
65use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream, ToSocketAddrs};
66use std::time::Duration;
67
68// ---------------------------------------------------------------------------
69// Errors
70// ---------------------------------------------------------------------------
71
72/// A host failed the SSRF guard, or could not be resolved at all.
73///
74/// `Clone`/`Eq` so callers can compare and surface it without owning a
75/// socket; `host` is the operator/tool-supplied authority (not secret),
76/// `reason` is a short human class string (e.g. `"loopback"`).
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct SsrfError {
79    /// The host authority that was guarded (no port).
80    pub host: String,
81    /// Short class of the failure, e.g. `"link-local 169.254.169.254"`.
82    pub reason: String,
83}
84
85impl std::fmt::Display for SsrfError {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        write!(
88            f,
89            "host `{}` rejected by SSRF guard: {}",
90            self.host, self.reason
91        )
92    }
93}
94
95impl std::error::Error for SsrfError {}
96
97fn reject(host: &str, reason: impl Into<String>) -> SsrfError {
98    SsrfError {
99        host: host.to_string(),
100        reason: reason.into(),
101    }
102}
103
104// ---------------------------------------------------------------------------
105// Pure classifier
106// ---------------------------------------------------------------------------
107
108/// `true` iff `ip` is a globally routable unicast address that is safe
109/// to dial from inside the appliance — i.e. *not* in any of the blocked
110/// ranges documented on this module.
111///
112/// Pure: no DNS, no I/O. This is the single source of truth; the host
113/// guard composes it over every resolved address.
114pub fn is_global(ip: IpAddr) -> bool {
115    match ip {
116        IpAddr::V4(v4) => is_global_v4(v4),
117        IpAddr::V6(v6) => is_global_v6(v6),
118    }
119}
120
121/// IPv4 classification. Blocked ranges are spelled out from RFC-3330 /
122/// RFC-1918 / RFC-3927 rather than via `std`'s unstable helpers.
123fn is_global_v4(ip: Ipv4Addr) -> bool {
124    let [a, b, _, _] = ip.octets();
125
126    // "This host on this network" — 0.0.0.0/8 (covers 0.0.0.0).
127    if a == 0 {
128        return false;
129    }
130    // Loopback 127.0.0.0/8, private 10/8 + 172.16/12 + 192.168/16,
131    // link-local 169.254/16, broadcast, multicast 224/4 + reserved
132    // 240/4, all unspecified — std covers these and they are stable.
133    if ip.is_loopback()
134        || ip.is_private()
135        || ip.is_link_local()
136        || ip.is_broadcast()
137        || ip.is_multicast()
138        || ip.is_unspecified()
139        || ip.is_documentation()
140    {
141        return false;
142    }
143    // Carrier-grade NAT (RFC-6598) 100.64.0.0/10 — shared address
144    // space, not globally routable; `is_shared` is unstable so unfold
145    // the prefix by hand.
146    if a == 100 && (64..=127).contains(&b) {
147        return false;
148    }
149    // Reserved 240.0.0.0/4 (minus the broadcast already caught) — never
150    // a routable destination.
151    if a >= 240 {
152        return false;
153    }
154    true
155}
156
157/// IPv6 classification. We first peel IPv4-mapped (`::ffff:0:0/96`) and
158/// IPv4-compatible (`::/96`) forms back to v4 and re-run the v4 rules —
159/// this is the bypass that bites naive guards.
160fn is_global_v6(ip: Ipv6Addr) -> bool {
161    // `::ffff:a.b.c.d` — classify the embedded v4 address.
162    if let Some(v4) = ip.to_ipv4_mapped() {
163        return is_global_v4(v4);
164    }
165    // `::a.b.c.d` (deprecated IPv4-compatible, plus ::1 / ::). `to_ipv4`
166    // also yields the mapped form, but the mapped case is handled
167    // above; here it catches the compatible range. ::1 and :: classify
168    // as loopback/unspecified v4-side too, but we also guard them
169    // directly below for clarity.
170    if let Some(v4) = ip.to_ipv4() {
171        return is_global_v4(v4);
172    }
173
174    if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
175        return false;
176    }
177
178    let segments = ip.segments();
179    // Link-local unicast fe80::/10 (top 10 bits == 1111 1110 10).
180    if (segments[0] & 0xffc0) == 0xfe80 {
181        return false;
182    }
183    // Unique-local fc00::/7 (top 7 bits == 1111 110x).
184    if (segments[0] & 0xfe00) == 0xfc00 {
185        return false;
186    }
187    // Documentation 2001:db8::/32 — never globally routable.
188    if segments[0] == 0x2001 && segments[1] == 0x0db8 {
189        return false;
190    }
191    true
192}
193
194// ---------------------------------------------------------------------------
195// Host guard (DNS-resolving)
196// ---------------------------------------------------------------------------
197
198/// Resolve `host` and reject if *any* resolved address is non-global.
199///
200/// This is the deny-all-the-aliases stance: a hostname that resolves to
201/// both a public and a private address is rejected, because an attacker
202/// who controls DNS could otherwise race the second connect (a DNS
203/// rebinding pivot).
204///
205/// **This answers a yes/no question and throws the addresses away**, so
206/// it is only sound where there is nothing to dial yet — admission of a
207/// push target at registration, config validation. Anything that goes on
208/// to open a socket MUST use [`resolve_guarded`] + [`connect_addrs`] (or
209/// [`connect_vetted`], which does both), or the connect re-resolves and
210/// the check it just passed means nothing.
211///
212/// `allow_private == true` is the operator escape hatch — it skips the
213/// check entirely without even resolving, so trusted localhost/private
214/// gateways (the configured intelligence endpoint) keep working. Callers
215/// that take a MODEL/AGENT-supplied URL MUST pass `false`.
216///
217/// Pure-ish: the only side effect is DNS resolution. No bytes are sent.
218pub fn guard_host(host: &str, allow_private: bool) -> Result<(), SsrfError> {
219    if allow_private {
220        return Ok(());
221    }
222    // Port 0 because we are only classifying: the resolver needs a port
223    // grammar and we discard the addresses anyway.
224    resolve_guarded(host, 0, false).map(|_| ())
225}
226
227// ---------------------------------------------------------------------------
228// Resolver seam
229// ---------------------------------------------------------------------------
230
231/// How a host is turned into addresses. A plain `fn` pointer, not a
232/// trait object: the only production implementation is [`std_resolve`],
233/// and the seam exists so a test can install a *hostile* resolver that
234/// answers the guard and the dial differently — the rebinding shape this
235/// module has to survive.
236pub type ResolveFn = fn(&str, u16) -> io::Result<Vec<SocketAddr>>;
237
238/// The production resolver: `std`'s `ToSocketAddrs`, with the bracketed
239/// IPv6 literal form (`[::1]`, as URLs write it) unwrapped first because
240/// `ToSocketAddrs` does not accept the brackets on a bare host.
241pub fn std_resolve(host: &str, port: u16) -> io::Result<Vec<SocketAddr>> {
242    let bare = host
243        .strip_prefix('[')
244        .and_then(|s| s.strip_suffix(']'))
245        .unwrap_or(host);
246    // An IP literal short-circuits DNS entirely — no syscall, and no
247    // opportunity for a resolver to answer with something else.
248    if let Ok(ip) = bare.parse::<IpAddr>() {
249        return Ok(vec![SocketAddr::new(ip, port)]);
250    }
251    (host, port).to_socket_addrs().map(|it| it.collect())
252}
253
254// ---------------------------------------------------------------------------
255// Resolve-once guard + dial-what-you-vetted
256// ---------------------------------------------------------------------------
257
258/// Resolve `host:port` **once** and return the addresses, having rejected
259/// the whole host if *any* of them is non-global.
260///
261/// The returned vector is the only thing a caller may dial: passing the
262/// name to a second resolution is precisely the rebinding hole this
263/// exists to close.
264///
265/// `allow_private` still resolves (there has to be something to connect
266/// to) but skips the classification, matching [`guard_host`]'s escape
267/// hatch.
268pub fn resolve_guarded(
269    host: &str,
270    port: u16,
271    allow_private: bool,
272) -> Result<Vec<SocketAddr>, SsrfError> {
273    resolve_guarded_with(host, port, allow_private, std_resolve)
274}
275
276/// [`resolve_guarded`] against an injected resolver. Public so the
277/// rebinding regression test can drive both halves — guard and dial —
278/// through a resolver that changes its mind between them.
279pub fn resolve_guarded_with(
280    host: &str,
281    port: u16,
282    allow_private: bool,
283    resolve: ResolveFn,
284) -> Result<Vec<SocketAddr>, SsrfError> {
285    if host.is_empty() {
286        return Err(reject(host, "empty host"));
287    }
288    let addrs = resolve(host, port).map_err(|e| reject(host, format!("resolve failed: {e}")))?;
289    if addrs.is_empty() {
290        return Err(reject(host, "no addresses resolved"));
291    }
292    if !allow_private {
293        for sa in &addrs {
294            check_addr(host, sa.ip())?;
295        }
296    }
297    Ok(addrs)
298}
299
300/// Dial one of `addrs`, re-asserting the classifier on every entry first.
301///
302/// The re-check is not redundant paranoia: this is the last instruction
303/// before the syscall, so it is the only place that can promise the bytes
304/// go somewhere global. A caller that hands over an address list built
305/// any other way (a cached answer, a redirect target) gets the same
306/// refusal, and one non-global entry refuses the *whole* dial rather than
307/// falling through to the next address — the same deny-all-the-aliases
308/// stance [`resolve_guarded`] takes, so a mixed answer cannot be raced.
309///
310/// `host` is carried for diagnostics only. TLS/SNI and the `Host` header
311/// remain the caller's business and must stay on the original hostname.
312pub fn connect_addrs(
313    host: &str,
314    addrs: &[SocketAddr],
315    timeout: Duration,
316    allow_private: bool,
317) -> io::Result<TcpStream> {
318    if addrs.is_empty() {
319        return Err(io::Error::new(
320            io::ErrorKind::NotFound,
321            format!("no vetted addresses for {host}"),
322        ));
323    }
324    if !allow_private {
325        for sa in addrs {
326            if let Err(e) = check_addr(host, sa.ip()) {
327                return Err(io::Error::new(
328                    io::ErrorKind::PermissionDenied,
329                    e.to_string(),
330                ));
331            }
332        }
333    }
334    // Every address was vetted above, so trying the next one on a
335    // connect failure cannot widen the target set — it is only
336    // dual-stack fallback.
337    let mut last: Option<io::Error> = None;
338    for sa in addrs {
339        match TcpStream::connect_timeout(sa, timeout) {
340            Ok(stream) => {
341                stream.set_read_timeout(Some(timeout))?;
342                stream.set_write_timeout(Some(timeout))?;
343                stream.set_nodelay(true).ok();
344                return Ok(stream);
345            }
346            Err(e) => last = Some(e),
347        }
348    }
349    Err(last.unwrap_or_else(|| {
350        io::Error::new(io::ErrorKind::NotFound, format!("cannot connect to {host}"))
351    }))
352}
353
354/// Guard and dial in one step: resolve once, vet, connect to a vetted
355/// address. This is what a model/peer-supplied URL must use instead of
356/// `http::connect_tcp`, which resolves the name a second time.
357pub fn connect_vetted(
358    host: &str,
359    port: u16,
360    timeout: Duration,
361    allow_private: bool,
362) -> io::Result<TcpStream> {
363    connect_vetted_with(host, port, timeout, allow_private, std_resolve)
364}
365
366/// [`connect_vetted`] against an injected resolver — the test seam.
367pub fn connect_vetted_with(
368    host: &str,
369    port: u16,
370    timeout: Duration,
371    allow_private: bool,
372    resolve: ResolveFn,
373) -> io::Result<TcpStream> {
374    let addrs = resolve_guarded_with(host, port, allow_private, resolve)
375        .map_err(|e| io::Error::new(io::ErrorKind::PermissionDenied, e.to_string()))?;
376    connect_addrs(host, &addrs, timeout, allow_private)
377}
378
379/// Classify one resolved address, turning a non-global result into a
380/// typed rejection with a short class string.
381fn check_addr(host: &str, ip: IpAddr) -> Result<(), SsrfError> {
382    if is_global(ip) {
383        Ok(())
384    } else {
385        Err(reject(host, format!("{} ({ip})", class_of(ip))))
386    }
387}
388
389/// Best-effort human label for *why* an address is non-global. Purely
390/// cosmetic — `is_global` remains the authority on the boolean.
391fn class_of(ip: IpAddr) -> &'static str {
392    match ip {
393        IpAddr::V4(v4) => {
394            if v4.is_unspecified() {
395                "unspecified"
396            } else if v4.is_loopback() {
397                "loopback"
398            } else if v4.is_private() {
399                "private (RFC-1918)"
400            } else if v4.is_link_local() {
401                "link-local"
402            } else if v4.is_broadcast() {
403                "broadcast"
404            } else if v4.is_multicast() {
405                "multicast"
406            } else {
407                "reserved"
408            }
409        }
410        IpAddr::V6(v6) => {
411            if let Some(v4) = v6.to_ipv4_mapped().or_else(|| v6.to_ipv4()) {
412                return class_of(IpAddr::V4(v4));
413            }
414            if v6.is_unspecified() {
415                "unspecified"
416            } else if v6.is_loopback() {
417                "loopback"
418            } else if v6.is_multicast() {
419                "multicast"
420            } else {
421                "link-local/unique-local"
422            }
423        }
424    }
425}
426
427// ---------------------------------------------------------------------------
428// Tests — LITERAL IPs only, never DNS.
429// ---------------------------------------------------------------------------
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr {
436        IpAddr::V4(Ipv4Addr::new(a, b, c, d))
437    }
438
439    fn v6(s: &str) -> IpAddr {
440        IpAddr::V6(s.parse::<Ipv6Addr>().expect("test ipv6 literal"))
441    }
442
443    #[test]
444    fn public_v4_is_global() {
445        assert!(is_global(v4(8, 8, 8, 8)));
446        assert!(is_global(v4(1, 1, 1, 1)));
447        assert!(is_global(v4(93, 184, 216, 34))); // example.com historic
448        assert!(is_global(v4(172, 15, 255, 255))); // just below 172.16/12
449        assert!(is_global(v4(172, 32, 0, 1))); // just above 172.31
450        assert!(is_global(v4(11, 0, 0, 1))); // just above 10/8
451        assert!(is_global(v4(192, 167, 255, 255))); // just below 192.168/16
452        assert!(is_global(v4(192, 169, 0, 1))); // just above 192.168/16
453        assert!(is_global(v4(100, 63, 255, 255))); // just below CGNAT 100.64/10
454        assert!(is_global(v4(100, 128, 0, 1))); // just above CGNAT
455    }
456
457    #[test]
458    fn loopback_blocked() {
459        assert!(!is_global(v4(127, 0, 0, 1)));
460        assert!(!is_global(v4(127, 255, 255, 255)));
461        assert!(!is_global(v6("::1")));
462    }
463
464    #[test]
465    fn rfc1918_blocked() {
466        // 10/8
467        assert!(!is_global(v4(10, 0, 0, 0)));
468        assert!(!is_global(v4(10, 255, 255, 255)));
469        // 172.16/12
470        assert!(!is_global(v4(172, 16, 0, 0)));
471        assert!(!is_global(v4(172, 16, 0, 1)));
472        assert!(!is_global(v4(172, 31, 255, 255)));
473        // 192.168/16
474        assert!(!is_global(v4(192, 168, 0, 1)));
475        assert!(!is_global(v4(192, 168, 255, 255)));
476    }
477
478    #[test]
479    fn link_local_and_metadata_blocked() {
480        assert!(!is_global(v4(169, 254, 0, 1)));
481        // The cloud metadata endpoint — the whole point of M6.
482        assert!(!is_global(v4(169, 254, 169, 254)));
483        assert!(!is_global(v4(169, 254, 255, 255)));
484        // IPv6 link-local fe80::/10 — both ends of the prefix.
485        assert!(!is_global(v6("fe80::1")));
486        assert!(!is_global(v6("febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff")));
487    }
488
489    #[test]
490    fn unique_local_blocked() {
491        // fc00::/7 covers fc00:: and fd00::.
492        assert!(!is_global(v6("fc00::1")));
493        assert!(!is_global(v6("fd12:3456:789a::1")));
494        assert!(!is_global(v6("fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")));
495    }
496
497    #[test]
498    fn unspecified_blocked() {
499        assert!(!is_global(v4(0, 0, 0, 0)));
500        assert!(!is_global(v4(0, 1, 2, 3))); // 0/8 "this network"
501        assert!(!is_global(v6("::")));
502    }
503
504    #[test]
505    fn multicast_and_broadcast_blocked() {
506        assert!(!is_global(v4(224, 0, 0, 1)));
507        assert!(!is_global(v4(239, 255, 255, 255)));
508        assert!(!is_global(v4(255, 255, 255, 255))); // limited broadcast
509        assert!(!is_global(v6("ff02::1")));
510    }
511
512    #[test]
513    fn reserved_v4_blocked() {
514        assert!(!is_global(v4(240, 0, 0, 1)));
515        assert!(!is_global(v4(255, 0, 0, 1)));
516    }
517
518    #[test]
519    fn ipv4_mapped_bypass_is_caught() {
520        // ::ffff:127.0.0.1 must classify as loopback, not as a global
521        // v6 address. This is the headline bypass.
522        assert!(!is_global(v6("::ffff:127.0.0.1")));
523        assert!(!is_global(v6("::ffff:10.0.0.1")));
524        assert!(!is_global(v6("::ffff:169.254.169.254")));
525        assert!(!is_global(v6("::ffff:192.168.1.1")));
526        // A mapped *public* v4 stays global.
527        assert!(is_global(v6("::ffff:8.8.8.8")));
528    }
529
530    #[test]
531    fn ipv4_compatible_bypass_is_caught() {
532        // ::a.b.c.d (deprecated) — embedded private v4 must be blocked.
533        assert!(!is_global(v6("::10.0.0.1")));
534        assert!(!is_global(v6("::169.254.169.254")));
535    }
536
537    #[test]
538    fn public_v6_is_global() {
539        assert!(is_global(v6("2606:4700:4700::1111"))); // 1.1.1.1 v6
540        assert!(is_global(v6("2001:4860:4860::8888"))); // google dns v6
541    }
542
543    #[test]
544    fn ipv6_documentation_blocked() {
545        assert!(!is_global(v6("2001:db8::1")));
546    }
547
548    // --- guard_host over literals (no DNS) ---
549
550    #[test]
551    fn guard_rejects_ip_literals() {
552        assert!(guard_host("127.0.0.1", false).is_err());
553        assert!(guard_host("10.0.0.5", false).is_err());
554        assert!(guard_host("169.254.169.254", false).is_err());
555        assert!(guard_host("::1", false).is_err());
556        assert!(guard_host("[::1]", false).is_err()); // bracketed
557        assert!(guard_host("[fe80::1]", false).is_err());
558        assert!(guard_host("::ffff:127.0.0.1", false).is_err());
559    }
560
561    #[test]
562    fn guard_allows_public_ip_literals() {
563        assert!(guard_host("8.8.8.8", false).is_ok());
564        assert!(guard_host("1.1.1.1", false).is_ok());
565        assert!(guard_host("[2606:4700:4700::1111]", false).is_ok());
566    }
567
568    #[test]
569    fn allow_private_skips_everything() {
570        // The operator escape hatch — must not even fail on a literal
571        // private address, since the intel endpoint is often localhost.
572        assert!(guard_host("127.0.0.1", true).is_ok());
573        assert!(guard_host("10.0.0.5", true).is_ok());
574        assert!(guard_host("", true).is_ok());
575        assert!(guard_host("anything.invalid", true).is_ok());
576    }
577
578    #[test]
579    fn empty_host_rejected_when_guarded() {
580        assert!(guard_host("", false).is_err());
581    }
582
583    #[test]
584    fn error_carries_host_and_class() {
585        let err = guard_host("169.254.169.254", false).unwrap_err();
586        assert_eq!(err.host, "169.254.169.254");
587        assert!(err.reason.contains("link-local"), "reason: {}", err.reason);
588        // Display must surface both without panicking.
589        let shown = err.to_string();
590        assert!(shown.contains("169.254.169.254"));
591        assert!(shown.contains("SSRF guard"));
592    }
593
594    #[test]
595    fn class_labels_are_specific() {
596        assert_eq!(class_of(v4(127, 0, 0, 1)), "loopback");
597        assert_eq!(class_of(v4(10, 0, 0, 1)), "private (RFC-1918)");
598        assert_eq!(class_of(v4(169, 254, 1, 1)), "link-local");
599        assert_eq!(class_of(v4(0, 0, 0, 0)), "unspecified");
600        assert_eq!(class_of(v4(224, 0, 0, 1)), "multicast");
601        assert_eq!(class_of(v4(255, 255, 255, 255)), "broadcast");
602        assert_eq!(class_of(v4(240, 0, 0, 1)), "reserved");
603        // mapped v6 borrows the v4 label.
604        assert_eq!(class_of(v6("::ffff:10.0.0.1")), "private (RFC-1918)");
605    }
606}