Skip to main content

lc_core/
ssrf.rs

1// lc-core/src/ssrf.rs
2//! SSRF protection — a single shared implementation, no copies allowed.
3//!
4//! `is_private_ip` / `url_points_to_private_ip` / `guarded_get` are security-critical;
5//! the whole workspace must have exactly one implementation. Originally authored for
6//! `lc-tools` (review Q1) and lifted into `lc-core` (0.20.0 S4 P1) so provider crates
7//! that cannot depend on `lc-tools` (e.g. `lc-providers`) share the same rules. Any
8//! rule evolution (adding CGNAT 100.64.0.0/10, new IPv6 special ranges, etc.) must
9//! only change here, otherwise the entry points would diverge: "URLFetch blocks
10//! intranet, Whisper allows intranet".
11
12use std::net::{IpAddr, SocketAddr};
13use std::time::Duration;
14
15use crate::tools::ToolError;
16
17/// Check if an IP address is private/internal or otherwise non-routable for SSRF
18/// purposes.
19///
20/// A3: the set now covers the full RFC 6890 / 5735 / 7913 special-purpose ranges that
21/// a server would never legitimately need to fetch (CGNAT `100.64/10`, benchmarking
22/// `198.18/15`, TEST-NET documentation blocks, multicast, reserved) in addition to the
23/// classic private/link-local/loopback ranges. Blocking these closes SSRF paths that
24/// probe cloud-metadata, loopback services, or internal benchmarking hosts.
25pub fn is_private_ip(ip: &IpAddr) -> bool {
26    match ip {
27        IpAddr::V4(v4) => {
28            // 0.0.0.0/8  "this network"
29            if v4.octets()[0] == 0 {
30                return true;
31            }
32            is_private_ipv4(*v4)
33        }
34        IpAddr::V6(v6) => {
35            // IPv4-mapped IPv6 (::ffff:a.b.c.d) targets an IPv4 endpoint directly, so it must
36            // be converted back to a V4 check; otherwise addresses like ::ffff:127.0.0.1 /
37            // ::ffff:169.254.169.254 would bypass the protection
38            if let Some(v4) = v6.to_ipv4_mapped() {
39                return is_private_ip(&IpAddr::V4(v4));
40            }
41            is_private_ipv6(*v6)
42        }
43    }
44}
45
46/// IPv4 private / special-purpose ranges (RFC 6890 / 5735).
47fn is_private_ipv4(v4: std::net::Ipv4Addr) -> bool {
48    let [a, b, _, _] = v4.octets();
49    match a {
50        // 0.0.0.0/8       "this network"
51        0 => true,
52        // 10.0.0.0/8      private
53        10 => true,
54        // 100.64.0.0/10   shared address space (CGNAT)
55        100 => b & 0b1100_0000 == 0b0100_0000,
56        // 127.0.0.0/8     loopback
57        127 => true,
58        // 169.254.0.0/16  link-local
59        169 => b == 254,
60        // 172.16.0.0/12   private
61        172 => (16..=31).contains(&b),
62        // 192.0.0.0/24 IETF protocol assignments + 192.0.2.0/24 TEST-NET-1 (doc)
63        192 if b == 0 => true,
64        // 192.88.99.0/24  6to4 relay anycast (deprecated)
65        192 if b == 88 => true,
66        // 192.168.0.0/16  private
67        192 if b == 168 => true,
68        // 198.18.0.0/15 benchmarking + 198.51.100.0/24 TEST-NET-2 (doc)
69        198 => (b & 0xfe) == 0x12 || b == 51,
70        // 203.0.113.0/24   TEST-NET-3 (doc)
71        203 if b == 0 => v4.octets()[2] == 113,
72        // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved
73        224..=255 => true,
74        _ => false,
75    }
76}
77
78/// IPv6 private / special-purpose ranges.
79fn is_private_ipv6(v6: std::net::Ipv6Addr) -> bool {
80    let seg = v6.segments();
81    // ::1 loopback
82    if v6.is_loopback() {
83        return true;
84    }
85    // fc00::/7 unique-local
86    if (seg[0] & 0xfe00) == 0xfc00 {
87        return true;
88    }
89    // fe80::/10 link-local
90    if matches!(seg, [0xfe80, ..]) {
91        return true;
92    }
93    // :: unspecified
94    if v6 == std::net::Ipv6Addr::UNSPECIFIED {
95        return true;
96    }
97    // fec0::/10          site-local (deprecated, RFC 3879)
98    if (seg[0] & 0xffc0) == 0xfec0 {
99        return true;
100    }
101    // ff00::/8            multicast
102    if (seg[0] & 0xff00) == 0xff00 {
103        return true;
104    }
105    // 2001:db8::/32        documentation
106    if seg[0] == 0x2001 && seg[1] == 0x0db8 {
107        return true;
108    }
109    // 2002::/16            6to4 (RFC 3056)
110    seg[0] == 0x2002
111}
112
113/// Default timeout for guarded requests when the caller does not supply one.
114pub const DEFAULT_GUARDED_TIMEOUT: Duration = Duration::from_secs(30);
115
116/// Resolve a URL hostname and check if it points to a private IP (async).
117///
118/// If DNS returns several addresses (A + AAAA, round-robin) the result is
119/// `true` when **any** of them is private: a checker that validated only the
120/// first answer could be bypassed by an answer list whose first entry is
121/// public and whose later entries are internal.
122pub async fn url_points_to_private_ip(url: &str) -> Result<bool, ToolError> {
123    let parsed = parse_http_url(url)?;
124    let addrs = resolve_url_addrs(&parsed).await?;
125    Ok(addrs.iter().any(|sa| is_private_ip(&sa.ip())))
126}
127
128/// Parse an http(s) URL, rejecting missing hosts and non-http(s) schemes.
129fn parse_http_url(url: &str) -> Result<url::Url, ToolError> {
130    let parsed =
131        url::Url::parse(url).map_err(|e| ToolError::InvalidInput(format!("Invalid URL: {}", e)))?;
132    if !matches!(parsed.scheme(), "http" | "https") {
133        return Err(ToolError::InvalidInput(format!(
134            "URL scheme not supported: {}",
135            parsed.scheme()
136        )));
137    }
138    if parsed.host_str().is_none() {
139        return Err(ToolError::InvalidInput("URL has no host".to_string()));
140    }
141    Ok(parsed)
142}
143
144/// Resolve a parsed URL's host to the concrete socket addresses the connection
145/// will be pinned to. IP-literal hosts short-circuit without DNS.
146///
147/// A3: this is the *only* resolution in the guarded path. The addresses
148/// returned here are validated and then handed to reqwest via
149/// `resolve_to_addrs`, so the checker and the actual TCP connection can never
150/// disagree (see [`pinned_client`]) — the previous check-then-re-resolve
151/// implementation left a DNS-rebinding (TOCTOU) window.
152async fn resolve_url_addrs(parsed: &url::Url) -> Result<Vec<SocketAddr>, ToolError> {
153    let host = parsed
154        .host_str()
155        .ok_or_else(|| ToolError::InvalidInput("URL has no host".to_string()))?;
156    let port = parsed.port_or_known_default().unwrap_or(80);
157
158    // IP literal: no DNS lookup at all, just attach the URL port.
159    if let Ok(ip) = host.parse::<IpAddr>() {
160        return Ok(vec![SocketAddr::new(ip, port)]);
161    }
162
163    let addrs: Vec<SocketAddr> = tokio::net::lookup_host((host, port))
164        .await
165        .map_err(|e| {
166            ToolError::ExecutionFailed(format!("DNS resolution failed for {}: {}", host, e))
167        })?
168        .collect();
169
170    if addrs.is_empty() {
171        return Err(ToolError::ExecutionFailed(format!(
172            "DNS resolution returned no addresses for {}",
173            host
174        )));
175    }
176    Ok(addrs)
177}
178
179/// Reject the address set when **any** resolved address is private.
180fn ensure_all_public(addrs: &[SocketAddr]) -> Result<(), ToolError> {
181    if let Some(bad) = addrs.iter().map(SocketAddr::ip).find(is_private_ip) {
182        return Err(ToolError::ExecutionFailed(format!(
183            "Request to private/internal IP address ({bad}) is blocked by SSRF protection. \
184             Call .with_allow_private_ips(true) to allow."
185        )));
186    }
187    Ok(())
188}
189
190/// Build a one-shot client whose DNS for `host` is pinned to the already
191/// validated `addrs`. reqwest resolves the override key by hostname (the URL's
192/// port in the SocketAddr list is ignored for non-literal hosts), while the
193/// `Host` header and TLS SNI keep using the URL hostname, so virtual hosting
194/// and certificate validation are unaffected. IP-literal hosts need no pin:
195/// reqwest connects to the literal directly, which is the validated address.
196fn pinned_client(
197    host: &str,
198    addrs: &[SocketAddr],
199    timeout: Option<Duration>,
200) -> Result<reqwest::Client, ToolError> {
201    let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none());
202    builder = builder.timeout(timeout.unwrap_or(DEFAULT_GUARDED_TIMEOUT));
203    if host.parse::<IpAddr>().is_err() {
204        builder = builder.resolve_to_addrs(host, addrs);
205    }
206    builder
207        .build()
208        .map_err(|e| ToolError::ExecutionFailed(format!("failed to build HTTP client: {}", e)))
209}
210
211/// Maximum number of hops for manual redirect following (matches the reqwest default).
212const MAX_REDIRECTS: usize = 10;
213
214/// GET request with per-hop SSRF checks and IP pinning, following redirects manually.
215///
216/// reqwest follows 30x by default but does not re-check the redirect target, which is the
217/// root of the "first hop checked, redirect into the intranet" SSRF bypass. Here every hop
218/// is resolved once, **all** resolved addresses are validated, and the same address set is
219/// pinned onto a one-shot client (`ClientBuilder::resolve_to_addrs`) before sending — the
220/// `Host` header and TLS SNI still carry the hostname. A hostile DNS answer therefore
221/// cannot pass the check with a public IP and re-resolve to an internal one at connect
222/// time (DNS-rebinding / TOCTOU closed, A3). The redirect target is taken from the
223/// Location header (relative URLs supported) and non-http(s) protocols are rejected.
224///
225/// A fresh short-lived client is built per hop because reqwest only accepts DNS overrides
226/// on the client builder. `timeout` bounds the whole request; `None` falls back to
227/// [`DEFAULT_GUARDED_TIMEOUT`] (30s).
228///
229/// When `check_ssrf = false`, the SSRF check is skipped (corresponding to
230/// `with_allow_private_ips(true)`), but address pinning and manual redirect following are
231/// preserved.
232pub async fn guarded_get(
233    url: &str,
234    check_ssrf: bool,
235    timeout: Option<Duration>,
236) -> Result<reqwest::Response, ToolError> {
237    let mut current = url.to_string();
238    for _ in 0..=MAX_REDIRECTS {
239        let parsed = parse_http_url(&current)?;
240        let host = parsed.host_str().expect("host checked above").to_string();
241        let addrs = resolve_url_addrs(&parsed).await?;
242        if check_ssrf {
243            ensure_all_public(&addrs)?;
244        }
245        let client = pinned_client(&host, &addrs, timeout)?;
246
247        let resp = client
248            .get(&current)
249            .send()
250            .await
251            .map_err(|e| ToolError::ExecutionFailed(format!("HTTP request failed: {}", e)))?;
252
253        if !resp.status().is_redirection() {
254            return Ok(resp);
255        }
256
257        // Follow only when a Location header is present; otherwise hand the 3xx response back as-is
258        let Some(location) = resp
259            .headers()
260            .get(reqwest::header::LOCATION)
261            .and_then(|v| v.to_str().ok())
262        else {
263            return Ok(resp);
264        };
265        current = resolve_redirect(&current, location)?;
266    }
267    Err(ToolError::ExecutionFailed(format!(
268        "request redirect count exceeded the limit of {} times",
269        MAX_REDIRECTS
270    )))
271}
272
273/// POST request with a JSON body, behind the same resolve-once / validate-all / pin-IP
274/// protection as [`guarded_get`]. POST intentionally does not follow redirects (the 3xx
275/// response is handed back as-is), so only the first hop is checked. (A3)
276pub async fn guarded_post_json(
277    url: &str,
278    body: &serde_json::Value,
279    check_ssrf: bool,
280    timeout: Option<Duration>,
281) -> Result<reqwest::Response, ToolError> {
282    let parsed = parse_http_url(url)?;
283    let host = parsed.host_str().expect("host checked above").to_string();
284    let addrs = resolve_url_addrs(&parsed).await?;
285    if check_ssrf {
286        ensure_all_public(&addrs)?;
287    }
288    let client = pinned_client(&host, &addrs, timeout)?;
289
290    client
291        .post(url)
292        .json(body)
293        .send()
294        .await
295        .map_err(|e| ToolError::ExecutionFailed(format!("HTTP request failed: {}", e)))
296}
297
298/// Resolves the Location header (possibly relative) into an absolute URL, rejecting non-http(s) protocols.
299fn resolve_redirect(base: &str, location: &str) -> Result<String, ToolError> {
300    let joined = url::Url::parse(base)
301        .and_then(|base_url| base_url.join(location))
302        .map_err(|e| ToolError::InvalidInput(format!("invalid redirect target: {}", e)))?;
303    if joined.scheme() != "http" && joined.scheme() != "https" {
304        return Err(ToolError::InvalidInput(format!(
305            "redirect target protocol not supported: {}",
306            joined.scheme()
307        )));
308    }
309    Ok(joined.to_string())
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn ipv4_mapped_ipv6_private_is_blocked() {
318        assert!(is_private_ip(
319            &"::ffff:127.0.0.1".parse::<IpAddr>().unwrap()
320        ));
321        assert!(is_private_ip(&"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()));
322        assert!(is_private_ip(
323            &"::ffff:169.254.169.254".parse::<IpAddr>().unwrap()
324        ));
325        assert!(is_private_ip(
326            &"::ffff:192.168.1.1".parse::<IpAddr>().unwrap()
327        ));
328        assert!(is_private_ip(
329            &"::ffff:172.16.0.1".parse::<IpAddr>().unwrap()
330        ));
331    }
332
333    #[test]
334    fn ipv4_mapped_ipv6_public_allowed() {
335        assert!(!is_private_ip(&"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()));
336        assert!(!is_private_ip(&"::ffff:1.1.1.1".parse::<IpAddr>().unwrap()));
337    }
338
339    #[test]
340    fn regular_ipv6_unchanged() {
341        assert!(is_private_ip(&"::1".parse::<IpAddr>().unwrap()));
342        assert!(is_private_ip(&"fc00::1".parse::<IpAddr>().unwrap()));
343        assert!(is_private_ip(&"fe80::1".parse::<IpAddr>().unwrap()));
344        // 2001:db8::/32 is the documentation range (RFC 6890) — now flagged special.
345        assert!(is_private_ip(&"2001:db8::1".parse::<IpAddr>().unwrap()));
346        // A genuine public IPv6 must still be allowed.
347        assert!(!is_private_ip(
348            &"2606:4700:4700::1111".parse::<IpAddr>().unwrap()
349        ));
350    }
351
352    #[test]
353    fn ipv4_special_ranges_are_blocked() {
354        // A3 additions per RFC 6890/5735.
355        let blocked: &[&str] = &[
356            "100.64.0.1",    // CGNAT
357            "100.127.255.1", // CGNAT upper bound
358            "198.18.0.1",    // benchmarking
359            "198.19.255.1",  // benchmarking upper bound
360            "192.0.0.1",     // IETF protocol assignments
361            "192.0.2.1",     // TEST-NET-1
362            "198.51.100.1",  // TEST-NET-2
363            "203.0.113.1",   // TEST-NET-3
364            "224.0.0.1",     // multicast
365            "240.0.0.1",     // reserved
366            "0.1.2.3",       // "this network"
367        ];
368        for s in blocked {
369            assert!(
370                is_private_ip(&s.parse::<IpAddr>().unwrap()),
371                "expected {s} to be flagged"
372            );
373        }
374
375        // Public + CGNAT-adjacent-but-public addresses must still pass.
376        for s in &["100.128.0.1", "198.20.0.1", "8.8.8.8", "1.1.1.1"] {
377            assert!(
378                !is_private_ip(&s.parse::<IpAddr>().unwrap()),
379                "expected {s} to be allowed"
380            );
381        }
382    }
383
384    #[test]
385    fn resolve_redirect_relative_and_absolute() {
386        assert_eq!(
387            resolve_redirect("https://a.com/x", "/internal").unwrap(),
388            "https://a.com/internal"
389        );
390        assert_eq!(
391            resolve_redirect("https://a.com/x", "https://b.com/y").unwrap(),
392            "https://b.com/y"
393        );
394    }
395
396    #[test]
397    fn resolve_redirect_rejects_non_http() {
398        assert!(resolve_redirect("https://a.com/x", "file:///etc/passwd").is_err());
399        assert!(resolve_redirect("https://a.com/x", "ftp://b.com").is_err());
400    }
401
402    // ---- A3: resolve-once / validate-all / IP pinning -----------------------
403
404    #[test]
405    fn parse_http_url_rejects_scheme_and_missing_host() {
406        assert!(parse_http_url("file:///etc/passwd").is_err());
407        assert!(parse_http_url("ftp://b.com/x").is_err());
408        assert!(parse_http_url("not a url").is_err());
409        // url::Url accepts http:/etc but records no host — still rejected.
410        assert!(parse_http_url("http://").is_err());
411        assert!(parse_http_url("https://example.com/x").is_ok());
412    }
413
414    #[test]
415    fn ensure_all_public_blocks_when_any_answer_is_private() {
416        // DNS round-robin with one internal answer must reject the whole set.
417        let mixed: Vec<SocketAddr> = vec![
418            "8.8.8.8:443".parse().unwrap(),
419            "10.0.0.5:443".parse().unwrap(),
420            "1.1.1.1:443".parse().unwrap(),
421        ];
422        let err = ensure_all_public(&mixed).unwrap_err();
423        assert!(err.to_string().contains("SSRF"), "got: {err}");
424
425        let public: Vec<SocketAddr> = vec![
426            "8.8.8.8:443".parse().unwrap(),
427            "[2606:4700:4700::1111]:443".parse().unwrap(),
428        ];
429        ensure_all_public(&public).expect("all-public set passes");
430
431        // IPv4-mapped IPv6 in the answer set is unmasked by is_private_ip.
432        let mapped: Vec<SocketAddr> = vec!["[::ffff:169.254.169.254]:80".parse().unwrap()];
433        assert!(ensure_all_public(&mapped).is_err());
434    }
435
436    #[tokio::test]
437    async fn resolve_url_addrs_ip_literals_bypass_dns() {
438        // Numeric hosts must resolve locally (no DNS query, works offline) and
439        // carry the URL's effective port.
440        let url = parse_http_url("http://127.0.0.1:8080/").unwrap();
441        let addrs = resolve_url_addrs(&url).await.unwrap();
442        assert_eq!(addrs, vec!["127.0.0.1:8080".parse::<SocketAddr>().unwrap()]);
443
444        let url = parse_http_url("https://8.8.8.8/").unwrap();
445        let addrs = resolve_url_addrs(&url).await.unwrap();
446        assert_eq!(addrs, vec!["8.8.8.8:443".parse::<SocketAddr>().unwrap()]);
447    }
448
449    #[tokio::test]
450    async fn guarded_get_blocks_loopback_before_connecting() {
451        // The rejection happens after resolution but before the pinned client is
452        // built/sent, so no network I/O occurs — no listener needed (and on
453        // Windows every loopback port would otherwise appear open).
454        let err = guarded_get("http://127.0.0.1:1/", true, None)
455            .await
456            .unwrap_err();
457        assert!(err.to_string().contains("SSRF"), "got: {err}");
458    }
459
460    #[tokio::test]
461    async fn guarded_get_blocks_link_local_before_connecting() {
462        let err = guarded_get("http://[::ffff:169.254.169.254]/latest", true, None)
463            .await
464            .unwrap_err();
465        assert!(err.to_string().contains("SSRF"), "got: {err}");
466    }
467
468    #[tokio::test]
469    async fn guarded_get_rejects_non_http_scheme_without_sending() {
470        let err = guarded_get("file:///etc/passwd", true, None)
471            .await
472            .unwrap_err();
473        assert!(err.to_string().contains("scheme"), "got: {err}");
474    }
475
476    #[tokio::test]
477    async fn guarded_post_json_blocks_private_before_connecting() {
478        let err = guarded_post_json(
479            "http://169.254.169.254/latest/meta-data/",
480            &serde_json::json!({}),
481            true,
482            None,
483        )
484        .await
485        .unwrap_err();
486        assert!(err.to_string().contains("SSRF"), "got: {err}");
487    }
488}