Skip to main content

mockforge_proxy/
egress.rs

1//! Upstream egress guard (SSRF mitigation, #1012 / MF-002).
2//!
3//! The browser/mobile proxy strips a configurable prefix (default
4//! `/proxy/`) from request paths; anything that remains and parses as an
5//! absolute `http(s)://` URL used to be forwarded verbatim to an
6//! attacker-controlled host (CWE-918). This module provides the second
7//! layer of defense for URLs that *are* allowed through (explicit opt-in):
8//!
9//! - denylisted IP ranges (loopback, RFC1918, link-local 169.254.0.0/16,
10//!   cloud-metadata endpoints, IPv4-mapped IPv6 equivalents, `::1`,
11//!   unique-local and link-local IPv6),
12//! - denylisted cloud-metadata hostnames,
13//! - DNS resolution before connecting, so a hostname that rebinding
14//!   attacks point at private space is caught before any socket is
15//!   opened.
16//!
17//! The guard applies only to URLs derived from the *request path*
18//! (attacker-controlled). Operator-configured upstreams (`target_url`,
19//! rule targets) are trusted configuration and are not re-checked.
20
21use std::future::Future;
22use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
23
24/// Hostname denylist for cloud metadata services.
25const BLOCKED_METADATA_HOSTS: &[&str] = &[
26    "metadata.google.internal",
27    "metadata.goog",
28    "metadata",
29    "instance-data",
30    "instance-data.ec2.internal",
31];
32
33/// Link-local metadata service endpoints (AWS/GCP/Azure IMDS, Alibaba IMDS,
34/// and AWS IMDSv2 over IPv6).
35const BLOCKED_METADATA_IPS: &[IpAddr] = &[
36    IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
37    IpAddr::V4(Ipv4Addr::new(100, 100, 100, 200)), // Alibaba Cloud IMDS
38    IpAddr::V6(Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254)), // AWS IMDSv6
39];
40
41/// Why an upstream URL was refused.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum EgressError {
44    /// URL could not be parsed at all.
45    InvalidUrl(String),
46    /// Host resolved to (or was literally) a blocked address.
47    BlockedAddress { host: String, ip: IpAddr },
48    /// Host is on the cloud-metadata hostname denylist.
49    BlockedHost(String),
50}
51
52impl std::fmt::Display for EgressError {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            EgressError::InvalidUrl(u) => write!(f, "invalid upstream URL: {}", u),
56            EgressError::BlockedAddress { host, ip } => {
57                write!(f, "upstream host {} resolves to blocked address {}", host, ip)
58            }
59            EgressError::BlockedHost(h) => write!(f, "upstream host {} is blocked", h),
60        }
61    }
62}
63
64impl std::error::Error for EgressError {}
65
66/// Optional explicit allowlist for upstreams. When present it overrides the
67/// egress denylist: a URL matching one of these prefixes/hosts is proxied
68/// even if it would otherwise be blocked.
69///
70/// # Security
71/// Only set entries here for hosts you genuinely intend the proxy to reach;
72/// an allowlist entry pointing at loopback/private space re-opens SSRF by
73/// design.
74#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76pub struct UpstreamAllowlist {
77    /// URL prefixes to allow verbatim, e.g. `http://api.example.com/`.
78    #[serde(default)]
79    pub url_prefixes: Vec<String>,
80    /// Bare hostnames (no scheme/port) to allow regardless of address,
81    /// e.g. `api.example.com`.
82    #[serde(default)]
83    pub hosts: Vec<String>,
84}
85
86impl UpstreamAllowlist {
87    fn allows(&self, url: &url::Url) -> bool {
88        let Some(host) = url.host_str() else {
89            return false;
90        };
91        if self.hosts.iter().any(|h| h.eq_ignore_ascii_case(host)) {
92            return true;
93        }
94        self.url_prefixes.iter().any(|p| url.as_str().starts_with(p))
95    }
96}
97
98/// Result of a pre-flight check on an upstream URL.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum EgressDecision {
101    /// Forward as requested.
102    Allowed,
103    /// Refuse with this reason.
104    Blocked(EgressError),
105}
106
107/// Egress guard over request-derived upstream URLs.
108#[derive(Debug, Clone, Default)]
109pub struct EgressGuard {
110    allowlist: Option<UpstreamAllowlist>,
111}
112
113impl EgressGuard {
114    pub fn new(allowlist: Option<UpstreamAllowlist>) -> Self {
115        Self { allowlist }
116    }
117
118    /// Synchronous checks that need no network: literal IPs, metadata
119    /// hostnames, and allowlist matching.
120    ///
121    /// Exposed separately so callers (and tests) can exercise the pure
122    /// logic without DNS.
123    pub fn check_without_dns(&self, target: &str) -> EgressDecision {
124        let url = match target.parse::<url::Url>() {
125            Ok(u) => u,
126            Err(_) => return EgressDecision::Blocked(EgressError::InvalidUrl(target.to_string())),
127        };
128        // Explicit operator opt-in wins over every denylist entry.
129        if self.allowlist.as_ref().is_some_and(|a| a.allows(&url)) {
130            return EgressDecision::Allowed;
131        }
132
133        match url.host() {
134            Some(url::Host::Domain(domain)) => {
135                let normalized = domain.trim_end_matches('.').to_ascii_lowercase();
136                if BLOCKED_METADATA_HOSTS.contains(&normalized.as_str()) {
137                    return EgressDecision::Blocked(EgressError::BlockedHost(normalized));
138                }
139                EgressDecision::Allowed
140            }
141            Some(url::Host::Ipv4(ip)) => {
142                if is_blocked_ip(IpAddr::V4(ip)) {
143                    EgressDecision::Blocked(EgressError::BlockedAddress {
144                        host: url.host_str().unwrap_or_default().to_string(),
145                        ip: IpAddr::V4(ip),
146                    })
147                } else {
148                    EgressDecision::Allowed
149                }
150            }
151            Some(url::Host::Ipv6(ip)) => {
152                if is_blocked_ip(IpAddr::V6(ip)) {
153                    EgressDecision::Blocked(EgressError::BlockedAddress {
154                        host: url.host_str().unwrap_or_default().to_string(),
155                        ip: IpAddr::V6(ip),
156                    })
157                } else {
158                    EgressDecision::Allowed
159                }
160            }
161            None => EgressDecision::Blocked(EgressError::InvalidUrl(target.to_string())),
162        }
163    }
164
165    /// Full check with an injected resolver: synchronous rules first, then
166    /// resolve non-literal hostnames and re-check every returned address.
167    ///
168    /// Resolution closes the DNS-rebinding window between check and
169    /// connect: whatever addresses the name currently points at must all
170    /// be public. Residual risk: the OS resolver could answer differently
171    /// when reqwest dials; pinning the checked address onto the connection
172    /// is out of scope here.
173    pub async fn check_with_resolver<R, F>(&self, target: &str, resolver: R) -> EgressDecision
174    where
175        R: FnOnce(String) -> F,
176        F: Future<Output = std::io::Result<Vec<IpAddr>>>,
177    {
178        match self.check_without_dns(target) {
179            blocked @ EgressDecision::Blocked(_) => return blocked,
180            EgressDecision::Allowed => {}
181        }
182
183        let Ok(url) = target.parse::<url::Url>() else {
184            return EgressDecision::Blocked(EgressError::InvalidUrl(target.to_string()));
185        };
186        // Allowlisted entries skip resolution entirely.
187        if self.allowlist.as_ref().is_some_and(|a| a.allows(&url)) {
188            return EgressDecision::Allowed;
189        }
190        let Some(url::Host::Domain(domain)) = url.host() else {
191            // Literal IP already validated above.
192            return EgressDecision::Allowed;
193        };
194
195        let domain = domain.trim_end_matches('.').to_ascii_lowercase();
196        match resolver(domain).await {
197            Ok(ips) => {
198                for ip in ips {
199                    if is_blocked_ip(ip) {
200                        return EgressDecision::Blocked(EgressError::BlockedAddress {
201                            host: url.host_str().unwrap_or_default().to_string(),
202                            ip,
203                        });
204                    }
205                }
206                EgressDecision::Allowed
207            }
208            // Cannot verify the name points at public space: fail closed.
209            Err(e) => EgressDecision::Blocked(EgressError::InvalidUrl(format!(
210                "{} (DNS resolution failed: {})",
211                target, e
212            ))),
213        }
214    }
215
216    /// Full check using blocking system DNS off the async runtime's core
217    /// threads.
218    pub async fn check(&self, target: &str) -> EgressDecision {
219        self.check_with_resolver(target, |host| async move {
220            tokio::task::spawn_blocking(move || {
221                use std::net::ToSocketAddrs;
222                Ok((host.as_str(), 0u16).to_socket_addrs()?.map(|sa| sa.ip()).collect::<Vec<_>>())
223            })
224            .await
225            .map_err(|e| std::io::Error::other(e.to_string()))?
226        })
227        .await
228    }
229}
230
231/// True when the address is in a range the proxy must never dial.
232pub fn is_blocked_ip(ip: IpAddr) -> bool {
233    if BLOCKED_METADATA_IPS.contains(&ip) {
234        return true;
235    }
236    match ip {
237        IpAddr::V4(v4) => is_blocked_ipv4(v4),
238        IpAddr::V6(v6) => {
239            // ::1 and the unspecified address.
240            if v6.is_loopback() || v6.is_unspecified() {
241                return true;
242            }
243            // IPv4-mapped / IPv4-compatible: evaluate the embedded v4.
244            if let Some(embedded) = v6.to_ipv4_mapped() {
245                return is_blocked_ipv4(embedded);
246            }
247            if let Some(embedded) = embedded_v4_compat(v6) {
248                return is_blocked_ipv4(embedded);
249            }
250            // fc00::/7 unique-local, fe80::/10 link-local.
251            (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80
252        }
253    }
254}
255
256fn is_blocked_ipv4(v4: Ipv4Addr) -> bool {
257    let o = v4.octets();
258    v4.is_loopback()
259        || v4.is_private() // RFC1918: 10/8, 172.16/12, 192.168/16
260        || v4.is_link_local() // 169.254/16 (incl. cloud metadata)
261        || o[0] == 0 // 0.0.0.0/8 "this network"
262}
263
264/// `to_ipv4_mapped` covers `::ffff:a.b.c.d`; some stacks also accept the
265/// deprecated IPv4-compatible form `::a.b.c.d`. Treat both as v4.
266fn embedded_v4_compat(v6: Ipv6Addr) -> Option<Ipv4Addr> {
267    let segs = v6.segments();
268    if segs[0..5] == [0, 0, 0, 0, 0] && segs[5] == 0 && !(segs[6] == 0 && segs[7] <= 1) {
269        Some(Ipv4Addr::new(
270            (segs[6] >> 8) as u8,
271            segs[6] as u8,
272            (segs[7] >> 8) as u8,
273            segs[7] as u8,
274        ))
275    } else {
276        None
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn metadata_ipv4_is_blocked() {
286        assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))));
287        assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 10, 1))));
288    }
289
290    #[test]
291    fn link_local_rfc1918_loopback_blocked() {
292        assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
293        assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3))));
294        assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 5))));
295        assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
296        assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
297    }
298
299    #[test]
300    fn ipv6_equivalents_blocked() {
301        assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
302        // ::ffff:169.254.169.254
303        assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xa9fe, 0xa9fe))));
304        assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254))));
305        assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1))));
306        assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1))));
307    }
308
309    #[test]
310    fn public_ips_allowed() {
311        assert!(!is_blocked_ip(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34))));
312        assert!(!is_blocked_ip(IpAddr::V4(Ipv4Addr::new(172, 32, 0, 1))));
313        assert!(!is_blocked_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
314        assert!(!is_blocked_ip(IpAddr::V6(Ipv6Addr::new(
315            0x2606, 0x2800, 0x220, 0x1, 0x248, 0x1893, 0x25c8, 0x1946
316        ))));
317    }
318
319    #[test]
320    fn guard_blocks_metadata_url() {
321        let guard = EgressGuard::new(None);
322        assert_eq!(
323            guard.check_without_dns("http://169.254.169.254/latest/meta-data/"),
324            EgressDecision::Blocked(EgressError::BlockedAddress {
325                host: "169.254.169.254".to_string(),
326                ip: IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
327            })
328        );
329    }
330
331    #[test]
332    fn guard_blocks_metadata_hostnames() {
333        let guard = EgressGuard::new(None);
334        assert!(matches!(
335            guard.check_without_dns("http://metadata.google.internal/computeMetadata/v1/"),
336            EgressDecision::Blocked(EgressError::BlockedHost(_))
337        ));
338        assert!(matches!(
339            guard.check_without_dns("http://METADATA.goog/foo"),
340            EgressDecision::Blocked(EgressError::BlockedHost(_))
341        ));
342    }
343
344    #[test]
345    fn guard_allows_public_literal() {
346        let guard = EgressGuard::new(None);
347        assert_eq!(guard.check_without_dns("https://93.184.216.34/x"), EgressDecision::Allowed);
348    }
349
350    #[test]
351    fn allowlist_overrides_blocklist() {
352        let guard = EgressGuard::new(Some(UpstreamAllowlist {
353            url_prefixes: vec!["http://169.254.169.254/".to_string()],
354            hosts: Vec::new(),
355        }));
356        assert_eq!(
357            guard.check_without_dns("http://169.254.169.254/latest/meta-data/"),
358            EgressDecision::Allowed
359        );
360    }
361
362    #[tokio::test]
363    async fn dns_rebinding_to_private_is_blocked() {
364        let guard = EgressGuard::new(None);
365        let decision = guard
366            .check_with_resolver("http://evil.example.com/", |host| async move {
367                assert_eq!(host, "evil.example.com");
368                Ok(vec![
369                    IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)),
370                    IpAddr::V4(Ipv4Addr::LOCALHOST),
371                ])
372            })
373            .await;
374        assert_eq!(
375            decision,
376            EgressDecision::Blocked(EgressError::BlockedAddress {
377                host: "evil.example.com".to_string(),
378                ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
379            })
380        );
381    }
382
383    #[tokio::test]
384    async fn dns_failure_fails_closed() {
385        let guard = EgressGuard::new(None);
386        let decision = guard
387            .check_with_resolver("http://nx.example.com/", |_host| async move {
388                Err(std::io::Error::other("nx"))
389            })
390            .await;
391        assert!(matches!(decision, EgressDecision::Blocked(EgressError::InvalidUrl(_))));
392    }
393}