Skip to main content

webfetch/
guard.rs

1//! SSRF guard for the fetch path.
2//!
3//! `fetch` is reachable from the CLI and the MCP server, so a crafted URL or a
4//! prompt-injected link could otherwise be used to reach the cloud metadata
5//! endpoint (`169.254.169.254`), `localhost`, or services on the private
6//! network. This module rejects non-`http(s)` schemes and any URL whose host
7//! resolves to a non-public IP address, on both the initial request and every
8//! redirect hop.
9//!
10//! Set `WEBFETCH_ALLOW_PRIVATE=1` to disable the guard (for trusted internal
11//! use or tests).
12
13use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
14use std::sync::Once;
15
16use url::{Host, Url};
17
18/// Env var that, when set to `1`/`true`, disables the SSRF guard.
19const ALLOW_PRIVATE_ENV: &str = "WEBFETCH_ALLOW_PRIVATE";
20
21static ALLOW_PRIVATE_WARNING: Once = Once::new();
22
23/// Whether the guard is disabled via environment opt-out.
24///
25/// When active, emits a one-line warning to stderr (once per process) so an
26/// operator can see the SSRF guard has been turned off and private, loopback,
27/// and cloud-metadata addresses are reachable.
28pub fn allow_private() -> bool {
29    let enabled = matches!(
30        std::env::var(ALLOW_PRIVATE_ENV).ok().as_deref(),
31        Some("1") | Some("true") | Some("TRUE")
32    );
33    if enabled {
34        ALLOW_PRIVATE_WARNING.call_once(|| {
35            eprintln!(
36                "warning: {ALLOW_PRIVATE_ENV} is set — SSRF guard disabled; \
37                 private, loopback, and metadata IPs are reachable"
38            );
39        });
40    }
41    enabled
42}
43
44/// Returns true if `ip` is not safe to fetch from a public-web client:
45/// loopback, private, link-local (incl. cloud metadata), CGNAT, unspecified,
46/// multicast, broadcast, documentation/benchmark ranges, and the IPv6
47/// equivalents (ULA, link-local, IPv4-mapped).
48pub fn is_blocked_ip(ip: IpAddr) -> bool {
49    match ip {
50        IpAddr::V4(v4) => is_blocked_ipv4(v4),
51        IpAddr::V6(v6) => is_blocked_ipv6(v6),
52    }
53}
54
55fn is_blocked_ipv4(ip: Ipv4Addr) -> bool {
56    let o = ip.octets();
57    ip.is_loopback()           // 127.0.0.0/8
58        || ip.is_private()         // 10/8, 172.16/12, 192.168/16
59        || ip.is_link_local()     // 169.254.0.0/16 (cloud metadata)
60        || ip.is_broadcast()      // 255.255.255.255
61        || ip.is_unspecified()    // 0.0.0.0
62        || ip.is_multicast()      // 224.0.0.0/4
63        || ip.is_documentation()  // 192.0.2/24, 198.51.100/24, 203.0.113/24
64        || o[0] == 0              // 0.0.0.0/8 "this network"
65        || (o[0] == 100 && (o[1] & 0xc0) == 64) // 100.64.0.0/10 CGNAT
66        || (o[0] == 192 && o[1] == 0 && o[2] == 0) // 192.0.0.0/24 IETF protocol
67        || (o[0] == 198 && (o[1] & 0xfe) == 18) // 198.18.0.0/15 benchmarking
68        || o[0] >= 240 // 240.0.0.0/4 reserved (excludes broadcast already)
69}
70
71fn is_blocked_ipv6(ip: Ipv6Addr) -> bool {
72    // IPv4-mapped / -compatible: classify by the embedded IPv4 address.
73    if let Some(v4) = ip.to_ipv4_mapped() {
74        return is_blocked_ipv4(v4);
75    }
76    if let Some(v4) = ip.to_ipv4() {
77        // Covers ::a.b.c.d (incl. ::1 loopback and :: unspecified).
78        return is_blocked_ipv4(v4);
79    }
80    let seg = ip.segments();
81
82    // Transition mechanisms embed an IPv4 address inside an IPv6 one, which is
83    // a way to name 169.254.169.254 without writing it down. Classify by the
84    // address they carry.
85    if let Some(v4) = embedded_ipv4(ip) {
86        if is_blocked_ipv4(v4) {
87            return true;
88        }
89    }
90
91    ip.is_loopback()
92        || ip.is_unspecified()
93        || ip.is_multicast()
94        || (seg[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
95        || (seg[0] & 0xfe00) == 0xfc00 // fc00::/7 unique local (ULA)
96        || (seg[0] == 0x2001 && seg[1] == 0x0db8) // 2001:db8::/32 documentation
97}
98
99/// Pull the IPv4 address out of a transition-mechanism IPv6 address.
100///
101/// - `64:ff9b::/96` and `64:ff9b:1::/48` — NAT64, which forwards to the
102///   embedded IPv4 address.
103/// - `2002::/16` — 6to4, whose next two groups are the IPv4 address.
104fn embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
105    let seg = ip.segments();
106    let from = |hi: u16, lo: u16| Some(Ipv4Addr::from(((hi as u32) << 16) | lo as u32));
107
108    // NAT64 well-known prefix: 64:ff9b:: with the IPv4 in the last 32 bits.
109    if seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2] == 0 && seg[3] == 0 && seg[4] == 0 {
110        return from(seg[6], seg[7]);
111    }
112    // 6to4: 2002:V4ADDR::/48.
113    if seg[0] == 0x2002 {
114        return from(seg[1], seg[2]);
115    }
116    None
117}
118
119/// Ports that are never an HTTP service, and that an SSRF probe would love to
120/// reach: mail, shells, and databases on an otherwise public host.
121///
122/// This mirrors what browsers refuse. It is a blocklist rather than an
123/// allowlist because HTTP legitimately runs on all sorts of ports (8080, 3000,
124/// 8443) and refusing those would break ordinary use.
125const BLOCKED_PORTS: [u16; 21] = [
126    22,    // ssh
127    23,    // telnet
128    25,    // smtp
129    53,    // dns
130    69,    // tftp
131    110,   // pop3
132    119,   // nntp
133    135,   // msrpc
134    137,   // netbios
135    139,   // netbios
136    143,   // imap
137    445,   // smb
138    465,   // smtps
139    587,   // smtp submission
140    993,   // imaps
141    995,   // pop3s
142    1433,  // mssql
143    3306,  // mysql
144    5432,  // postgres
145    6379,  // redis
146    11211, // memcached
147];
148
149/// An error describing why a URL was rejected by the guard.
150#[derive(Debug)]
151pub struct BlockedUrl(pub String);
152
153impl std::fmt::Display for BlockedUrl {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        write!(f, "blocked URL: {}", self.0)
156    }
157}
158
159impl std::error::Error for BlockedUrl {}
160
161/// Only `http(s)` is fetchable. Checked before the env opt-out is consulted:
162/// `WEBFETCH_ALLOW_PRIVATE` exists to reach internal *hosts*, not to turn an
163/// HTTP client into a file reader.
164fn check_scheme(url: &Url) -> Result<(), BlockedUrl> {
165    match url.scheme() {
166        "http" | "https" => Ok(()),
167        other => Err(BlockedUrl(format!("scheme `{other}` not allowed"))),
168    }
169}
170
171/// Refuse ports that never speak HTTP. See [`BLOCKED_PORTS`].
172fn check_port(url: &Url) -> Result<(), BlockedUrl> {
173    match url.port() {
174        Some(port) if BLOCKED_PORTS.contains(&port) => Err(BlockedUrl(format!(
175            "port {port} is not an HTTP service and is not fetchable"
176        ))),
177        _ => Ok(()),
178    }
179}
180
181/// Validate a URL's scheme, port and host, resolving and classifying the host.
182/// On success returns the validated socket addresses (host resolved to public
183/// IPs) so the caller can pin the connection and avoid a DNS-rebinding TOCTOU
184/// window.
185///
186/// Host classification is skipped when the guard is disabled via env; the
187/// scheme and port checks always run.
188///
189/// Async because domain validation resolves DNS via [`tokio::net::lookup_host`]
190/// rather than the blocking `std` resolver — important on the async fetch path
191/// (and the concurrent MCP server) so a slow lookup never blocks a tokio worker.
192pub async fn validate_url(url: &Url) -> Result<Vec<std::net::SocketAddr>, BlockedUrl> {
193    check_scheme(url)?;
194    check_port(url)?;
195
196    if allow_private() {
197        return Ok(Vec::new());
198    }
199
200    let host = url
201        .host()
202        .ok_or_else(|| BlockedUrl(format!("no host in {url}")))?;
203
204    match host {
205        Host::Ipv4(ip) => {
206            if is_blocked_ip(IpAddr::V4(ip)) {
207                return Err(BlockedUrl(format!("host IP {ip} is not public")));
208            }
209            Ok(Vec::new())
210        }
211        Host::Ipv6(ip) => {
212            if is_blocked_ip(IpAddr::V6(ip)) {
213                return Err(BlockedUrl(format!("host IP {ip} is not public")));
214            }
215            Ok(Vec::new())
216        }
217        Host::Domain(domain) => validate_domain(url, domain).await,
218    }
219}
220
221async fn validate_domain(url: &Url, domain: &str) -> Result<Vec<std::net::SocketAddr>, BlockedUrl> {
222    // Block obvious local names early; DNS may also resolve these.
223    let lower = domain.to_ascii_lowercase();
224    if lower == "localhost" || lower.ends_with(".localhost") {
225        return Err(BlockedUrl(format!("host `{domain}` is local")));
226    }
227
228    let port = url
229        .port_or_known_default()
230        .ok_or_else(|| BlockedUrl(format!("no port for {url}")))?;
231
232    // Resolve (non-blocking) and require that EVERY resolved address is public,
233    // then return them so the connection can be pinned to the validated set.
234    let addrs: Vec<_> = tokio::net::lookup_host((domain, port))
235        .await
236        .map_err(|e| BlockedUrl(format!("cannot resolve `{domain}`: {e}")))?
237        .collect();
238
239    if addrs.is_empty() {
240        return Err(BlockedUrl(format!("`{domain}` resolved to no addresses")));
241    }
242    for addr in &addrs {
243        if is_blocked_ip(addr.ip()) {
244            return Err(BlockedUrl(format!(
245                "`{domain}` resolves to non-public IP {}",
246                addr.ip()
247            )));
248        }
249    }
250    Ok(addrs)
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn blocked(s: &str) -> bool {
258        is_blocked_ip(s.parse::<IpAddr>().unwrap())
259    }
260
261    #[test]
262    fn blocks_loopback_and_private_and_metadata() {
263        assert!(blocked("127.0.0.1"));
264        assert!(blocked("10.0.0.1"));
265        assert!(blocked("172.16.5.4"));
266        assert!(blocked("192.168.1.1"));
267        assert!(blocked("169.254.169.254")); // cloud metadata
268        assert!(blocked("100.64.0.1")); // CGNAT
269        assert!(blocked("0.0.0.0"));
270        assert!(blocked("255.255.255.255"));
271        assert!(blocked("224.0.0.1")); // multicast
272        assert!(blocked("240.0.0.1")); // reserved
273    }
274
275    #[test]
276    fn blocks_ipv6_local_and_mapped() {
277        assert!(blocked("::1")); // loopback
278        assert!(blocked("::")); // unspecified
279        assert!(blocked("fe80::1")); // link-local
280        assert!(blocked("fc00::1")); // ULA
281        assert!(blocked("::ffff:127.0.0.1")); // v4-mapped loopback
282        assert!(blocked("::ffff:169.254.169.254")); // v4-mapped metadata
283    }
284
285    #[test]
286    fn allows_public() {
287        assert!(!blocked("1.1.1.1"));
288        assert!(!blocked("8.8.8.8"));
289        assert!(!blocked("93.184.216.34")); // example.com
290        assert!(!blocked("2606:4700:4700::1111")); // cloudflare v6
291    }
292
293    /// Transition mechanisms are another way to spell an IPv4 address, so they
294    /// are another way to spell the metadata endpoint.
295    #[test]
296    fn blocks_ipv4_embedded_in_transition_addresses() {
297        assert!(blocked("64:ff9b::169.254.169.254")); // NAT64
298        assert!(blocked("64:ff9b::a00:1")); // NAT64 → 10.0.0.1
299        assert!(blocked("2002:a9fe:a9fe::1")); // 6to4 → 169.254.169.254
300        assert!(blocked("2002:7f00:1::1")); // 6to4 → 127.0.0.1
301                                            // The same mechanisms pointing at public space stay allowed.
302        assert!(!blocked("64:ff9b::8.8.8.8"));
303        assert!(!blocked("2002:0808:0808::1")); // 6to4 → 8.8.8.8
304    }
305
306    #[tokio::test]
307    async fn rejects_non_http_ports() {
308        for target in [
309            "http://example.com:22/",
310            "http://example.com:6379/",
311            "https://example.com:3306/",
312            "http://example.com:25/",
313        ] {
314            let url = Url::parse(target).unwrap();
315            assert!(
316                validate_url(&url).await.is_err(),
317                "{target} should be blocked"
318            );
319        }
320    }
321
322    #[test]
323    fn allows_ordinary_http_ports() {
324        // HTTP runs on all sorts of ports; only the never-HTTP ones are refused.
325        for target in [
326            "http://example.com/",
327            "https://example.com/",
328            "http://example.com:8080/",
329            "http://example.com:3000/",
330            "https://example.com:8443/",
331        ] {
332            assert!(check_port(&Url::parse(target).unwrap()).is_ok(), "{target}");
333        }
334    }
335
336    #[tokio::test]
337    async fn rejects_non_http_scheme() {
338        let url = Url::parse("file:///etc/passwd").unwrap();
339        assert!(validate_url(&url).await.is_err());
340        let url = Url::parse("ftp://example.com/x").unwrap();
341        assert!(validate_url(&url).await.is_err());
342    }
343
344    #[tokio::test]
345    async fn rejects_literal_metadata_ip_url() {
346        let url = Url::parse("http://169.254.169.254/latest/meta-data/").unwrap();
347        assert!(validate_url(&url).await.is_err());
348    }
349
350    #[tokio::test]
351    async fn rejects_localhost_name() {
352        let url = Url::parse("http://localhost:8080/admin").unwrap();
353        assert!(validate_url(&url).await.is_err());
354    }
355
356    /// The env opt-out widens which hosts are reachable; it must not widen
357    /// which schemes are. Tested against the scheme check directly rather than
358    /// by setting the variable, which is process-global and would race every
359    /// other test in this binary.
360    #[test]
361    fn scheme_check_is_independent_of_the_env_opt_out() {
362        for bad in ["file:///etc/passwd", "ftp://example.com/x", "gopher://x/"] {
363            assert!(check_scheme(&Url::parse(bad).unwrap()).is_err(), "{bad}");
364        }
365        assert!(check_scheme(&Url::parse("https://example.com").unwrap()).is_ok());
366    }
367
368    // A redirect target is validated by the exact same `validate_url` the fetch
369    // loop runs (and pins) on every hop, so a redirect to a private/loopback IP
370    // is rejected before any connection is made.
371    #[tokio::test]
372    async fn rejects_redirect_target_to_private_ip() {
373        for target in [
374            "http://127.0.0.1/internal",
375            "http://10.0.0.1/admin",
376            "http://192.168.1.1/",
377            "http://169.254.169.254/latest/meta-data/",
378        ] {
379            let url = Url::parse(target).unwrap();
380            assert!(
381                validate_url(&url).await.is_err(),
382                "redirect target {target} should be blocked"
383            );
384        }
385    }
386}