1use std::future::Future;
22use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
23
24const BLOCKED_METADATA_HOSTS: &[&str] = &[
26 "metadata.google.internal",
27 "metadata.goog",
28 "metadata",
29 "instance-data",
30 "instance-data.ec2.internal",
31];
32
33const BLOCKED_METADATA_IPS: &[IpAddr] = &[
36 IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
37 IpAddr::V4(Ipv4Addr::new(100, 100, 100, 200)), IpAddr::V6(Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254)), ];
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum EgressError {
44 InvalidUrl(String),
46 BlockedAddress { host: String, ip: IpAddr },
48 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#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76pub struct UpstreamAllowlist {
77 #[serde(default)]
79 pub url_prefixes: Vec<String>,
80 #[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#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum EgressDecision {
101 Allowed,
103 Blocked(EgressError),
105}
106
107#[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 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 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 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 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 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 Err(e) => EgressDecision::Blocked(EgressError::InvalidUrl(format!(
210 "{} (DNS resolution failed: {})",
211 target, e
212 ))),
213 }
214 }
215
216 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
231pub 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 if v6.is_loopback() || v6.is_unspecified() {
241 return true;
242 }
243 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 (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() || v4.is_link_local() || o[0] == 0 }
263
264fn 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 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}