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