Skip to main content

what_core/
egress_guard.rs

1//! SSRF egress guard for template-driven `fetch` directives.
2//!
3//! Server-rendered pages can interpolate request-time input (`#query.*#`,
4//! `#user.*#`, `#session.*#`) into `fetch` URLs, which the server then fetches.
5//! Without a guard, `fetch.x = "#query.u#"` + `?u=http://169.254.169.254/…`
6//! turns the server into a proxy for internal/metadata endpoints (SSRF).
7//!
8//! This is an **application-layer** guard: it resolves the target host and
9//! rejects requests to loopback/private/link-local/ULA/metadata addresses. It is
10//! not a substitute for network egress policy — it does not pin the resolved IP
11//! to the socket, so a determined DNS-rebinding attacker with control of a
12//! resolver could still win the resolve-vs-connect race. It closes the realistic
13//! cases (literal internal IPs, private DNS records, redirect-to-internal) at a
14//! complexity proportionate to a small self-hosted framework.
15//!
16//! Applied ONLY to `fetch`/datasource requests (attacker-influenceable), never to
17//! framework-owned outbound (R2 uploads, auth backend) which use a fixed host.
18
19use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
20
21/// Why an outbound fetch was refused.
22#[derive(Debug, Clone)]
23pub enum EgressError {
24    /// Scheme other than http/https.
25    BadScheme(String),
26    /// URL carried `user:pass@` credentials.
27    Userinfo,
28    /// URL had no host.
29    NoHost,
30    /// Host carried an IPv6 zone id (`%eth0`).
31    ZoneId,
32    /// URL failed to parse.
33    Parse(String),
34    /// DNS resolution failed / returned nothing.
35    Resolve(String),
36    /// The host (or a resolved address) is in a forbidden range.
37    Blocked(String),
38    /// Exceeded the redirect hop limit.
39    TooManyRedirects,
40}
41
42impl std::fmt::Display for EgressError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            EgressError::BadScheme(s) => write!(f, "disallowed URL scheme '{s}' (only http/https)"),
46            EgressError::Userinfo => write!(f, "URL must not contain credentials (user:pass@)"),
47            EgressError::NoHost => write!(f, "URL has no host"),
48            EgressError::ZoneId => write!(f, "URL host must not contain an IPv6 zone id"),
49            EgressError::Parse(e) => write!(f, "invalid URL: {e}"),
50            EgressError::Resolve(e) => write!(f, "could not resolve host: {e}"),
51            EgressError::Blocked(w) => {
52                write!(f, "refused fetch to non-public address: {w} (set [server] allow_private_fetch or fetch_allowed_hosts to override)")
53            }
54            EgressError::TooManyRedirects => write!(f, "too many redirects"),
55        }
56    }
57}
58
59/// True if an IPv4 address must not be fetched (loopback/private/link-local/
60/// CGNAT/multicast/reserved/broadcast/documentation/unspecified/0.0.0.0-8).
61/// Uses stable `Ipv4Addr` predicates plus explicit ranges the stdlib lacks.
62fn is_forbidden_v4(ip: Ipv4Addr) -> bool {
63    let o = ip.octets();
64    ip.is_loopback()            // 127.0.0.0/8
65        || ip.is_private()      // 10/8, 172.16/12, 192.168/16
66        || ip.is_link_local()   // 169.254.0.0/16
67        || ip.is_broadcast()    // 255.255.255.255
68        || ip.is_documentation()// 192.0.2/24, 198.51.100/24, 203.0.113/24
69        || ip.is_unspecified()  // 0.0.0.0
70        || o[0] == 0            // 0.0.0.0/8
71        || (o[0] == 100 && (64..=127).contains(&o[1])) // 100.64/10 CGNAT
72        || ip.is_multicast()    // 224.0.0.0/4
73        || o[0] >= 240          // 240.0.0.0/4 reserved
74}
75
76/// True if an IPv6 address must not be fetched. IPv4-mapped/compatible forms are
77/// normalized to IPv4 first so `::ffff:169.254.169.254` is caught by v4 rules.
78fn is_forbidden_v6(ip: Ipv6Addr) -> bool {
79    if let Some(v4) = ip.to_ipv4_mapped() {
80        return is_forbidden_v4(v4);
81    }
82    // `to_ipv4()` also maps deprecated `::a.b.c.d`; apply v4 rules there too,
83    // but not to `::1`/`::` which map to 0.0.0.1/0.0.0.0 and are handled below.
84    if let Some(v4) = ip.to_ipv4() {
85        if !ip.is_loopback() && !ip.is_unspecified() {
86            return is_forbidden_v4(v4);
87        }
88    }
89    let seg = ip.segments();
90    ip.is_loopback()                    // ::1
91        || ip.is_unspecified()          // ::
92        || ip.is_multicast()            // ff00::/8
93        || (seg[0] & 0xfe00) == 0xfc00  // fc00::/7  unique-local
94        || (seg[0] & 0xffc0) == 0xfe80  // fe80::/10 link-local
95        || (seg[0] == 0x2001 && seg[1] == 0x0db8) // 2001:db8::/32 documentation
96}
97
98/// Whether an IP is forbidden as an outbound fetch target.
99pub fn is_forbidden_ip(ip: IpAddr) -> bool {
100    match ip {
101        IpAddr::V4(v4) => is_forbidden_v4(v4),
102        IpAddr::V6(v6) => is_forbidden_v6(v6),
103    }
104}
105
106/// Parse and structurally validate a fetch URL: http/https only, no credentials,
107/// a host, and no IPv6 zone id. Returns the parsed URL (canonical host — numeric
108/// IP encodings like `2130706433` / `0177.0.0.1` are normalized by the parser).
109pub fn parse_fetch_url(raw: &str) -> Result<reqwest::Url, EgressError> {
110    let url = reqwest::Url::parse(raw).map_err(|e| EgressError::Parse(e.to_string()))?;
111    match url.scheme() {
112        "http" | "https" => {}
113        other => return Err(EgressError::BadScheme(other.to_string())),
114    }
115    if !url.username().is_empty() || url.password().is_some() {
116        return Err(EgressError::Userinfo);
117    }
118    let host = url.host_str().ok_or(EgressError::NoHost)?;
119    if host.contains('%') {
120        return Err(EgressError::ZoneId);
121    }
122    Ok(url)
123}
124
125/// Async host validation: allowlist and broad opt-out first, then a literal-IP
126/// check, else DNS resolution with every resolved address checked. `allow_private`
127/// disables the IP check entirely; `allowed_hosts` exempts specific hostnames.
128pub async fn validate_fetch_target(
129    url: &reqwest::Url,
130    allow_private: bool,
131    allowed_hosts: &[String],
132) -> Result<(), EgressError> {
133    let host = url.host_str().ok_or(EgressError::NoHost)?;
134
135    if allowed_hosts.iter().any(|h| h.eq_ignore_ascii_case(host)) {
136        return Ok(());
137    }
138    if allow_private {
139        return Ok(());
140    }
141
142    // Literal IP host — check without touching DNS.
143    if let Ok(ip) = host.parse::<IpAddr>() {
144        return if is_forbidden_ip(ip) {
145            Err(EgressError::Blocked(ip.to_string()))
146        } else {
147            Ok(())
148        };
149    }
150
151    // Hostname — resolve and check every address.
152    let port = url.port_or_known_default().unwrap_or(80);
153    let addrs = tokio::net::lookup_host((host, port))
154        .await
155        .map_err(|e| EgressError::Resolve(e.to_string()))?;
156    let mut saw_any = false;
157    for sa in addrs {
158        saw_any = true;
159        if is_forbidden_ip(sa.ip()) {
160            return Err(EgressError::Blocked(format!("{host} -> {}", sa.ip())));
161        }
162    }
163    if !saw_any {
164        return Err(EgressError::Resolve(format!("no addresses for {host}")));
165    }
166    Ok(())
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    fn v4(s: &str) -> IpAddr {
174        IpAddr::V4(s.parse().unwrap())
175    }
176    fn v6(s: &str) -> IpAddr {
177        IpAddr::V6(s.parse().unwrap())
178    }
179
180    #[test]
181    fn blocks_ipv4_internal_ranges() {
182        for s in [
183            "127.0.0.1", "127.1.2.3", "10.0.0.1", "172.16.5.4", "172.31.255.255",
184            "192.168.1.1", "169.254.169.254", "0.0.0.0", "0.1.2.3",
185            "100.64.0.1", "224.0.0.1", "240.0.0.1", "255.255.255.255", "192.0.2.5",
186        ] {
187            assert!(is_forbidden_ip(v4(s)), "{s} should be forbidden");
188        }
189    }
190
191    #[test]
192    fn allows_public_ipv4() {
193        for s in ["1.1.1.1", "8.8.8.8", "93.184.216.34", "172.15.0.1", "172.32.0.1", "100.63.0.1", "100.128.0.1"] {
194            assert!(!is_forbidden_ip(v4(s)), "{s} should be allowed");
195        }
196    }
197
198    #[test]
199    fn blocks_ipv6_internal_and_mapped() {
200        for s in ["::1", "::", "fe80::1", "fc00::1", "fd00::1", "2001:db8::1", "ff02::1"] {
201            assert!(is_forbidden_ip(v6(s)), "{s} should be forbidden");
202        }
203        // IPv4-mapped metadata address must be caught by v4 rules
204        assert!(is_forbidden_ip(v6("::ffff:169.254.169.254")));
205        assert!(is_forbidden_ip(v6("::ffff:127.0.0.1")));
206    }
207
208    #[test]
209    fn allows_public_ipv6() {
210        assert!(!is_forbidden_ip(v6("2606:4700:4700::1111"))); // cloudflare
211        assert!(!is_forbidden_ip(v6("::ffff:8.8.8.8")));
212    }
213
214    #[test]
215    fn parse_rejects_bad_schemes_and_userinfo() {
216        assert!(matches!(parse_fetch_url("file:///etc/passwd"), Err(EgressError::BadScheme(_))));
217        assert!(matches!(parse_fetch_url("gopher://x/"), Err(EgressError::BadScheme(_))));
218        assert!(matches!(parse_fetch_url("http://user:pass@example.com/"), Err(EgressError::Userinfo)));
219        assert!(parse_fetch_url("https://example.com/path").is_ok());
220    }
221
222    #[test]
223    fn parser_canonicalizes_numeric_hosts() {
224        // Decimal and octal IPv4 encodings normalize to a literal the IP check catches.
225        let u = parse_fetch_url("http://2130706433/").unwrap(); // 127.0.0.1
226        assert_eq!(u.host_str().unwrap().parse::<IpAddr>().unwrap(), v4("127.0.0.1"));
227        let u = parse_fetch_url("http://0177.0.0.1/").unwrap();
228        assert_eq!(u.host_str().unwrap().parse::<IpAddr>().unwrap(), v4("127.0.0.1"));
229    }
230
231    #[tokio::test]
232    async fn validate_blocks_literal_metadata_ip() {
233        let u = parse_fetch_url("http://169.254.169.254/latest/meta-data/").unwrap();
234        assert!(matches!(
235            validate_fetch_target(&u, false, &[]).await,
236            Err(EgressError::Blocked(_))
237        ));
238    }
239
240    #[tokio::test]
241    async fn validate_allows_when_opted_out_or_allowlisted() {
242        let u = parse_fetch_url("http://127.0.0.1:3000/api").unwrap();
243        // broad opt-out
244        assert!(validate_fetch_target(&u, true, &[]).await.is_ok());
245        // per-host allowlist
246        assert!(validate_fetch_target(&u, false, &["127.0.0.1".to_string()])
247            .await
248            .is_ok());
249        // neither → blocked
250        assert!(validate_fetch_target(&u, false, &[]).await.is_err());
251    }
252}