what_core/
egress_guard.rs1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
20
21#[derive(Debug, Clone)]
23pub enum EgressError {
24 BadScheme(String),
26 Userinfo,
28 NoHost,
30 ZoneId,
32 Parse(String),
34 Resolve(String),
36 Blocked(String),
38 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
59fn is_forbidden_v4(ip: Ipv4Addr) -> bool {
63 let o = ip.octets();
64 ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_broadcast() || ip.is_documentation()|| ip.is_unspecified() || o[0] == 0 || (o[0] == 100 && (64..=127).contains(&o[1])) || ip.is_multicast() || o[0] >= 240 }
75
76fn is_forbidden_v6(ip: Ipv6Addr) -> bool {
79 if let Some(v4) = ip.to_ipv4_mapped() {
80 return is_forbidden_v4(v4);
81 }
82 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() || ip.is_unspecified() || ip.is_multicast() || (seg[0] & 0xfe00) == 0xfc00 || (seg[0] & 0xffc0) == 0xfe80 || (seg[0] == 0x2001 && seg[1] == 0x0db8) }
97
98pub 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
106pub 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
125pub 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 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 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 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"))); 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 let u = parse_fetch_url("http://2130706433/").unwrap(); 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 assert!(validate_fetch_target(&u, true, &[]).await.is_ok());
245 assert!(validate_fetch_target(&u, false, &["127.0.0.1".to_string()])
247 .await
248 .is_ok());
249 assert!(validate_fetch_target(&u, false, &[]).await.is_err());
251 }
252}