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}