submilli_engine/stdlib/http/
policy.rs1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
12use std::sync::Arc;
13
14use ipnet::IpNet;
15use reqwest::dns::{Addrs, Name, Resolve, Resolving};
16
17#[derive(Clone, Debug, Default)]
21pub struct NetworkPolicy {
22 observer: Option<EgressObserver>,
23 enforce: bool,
27 allow_localhost: bool,
28 allow_private: bool,
29 allow: Vec<IpNet>,
32}
33
34#[derive(Clone)]
35struct EgressObserver(Arc<dyn Fn(&str) + Send + Sync>);
36
37impl std::fmt::Debug for EgressObserver {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 f.write_str("EgressObserver")
40 }
41}
42
43impl NetworkPolicy {
44 pub fn with_denial_observer(mut self, observer: Arc<dyn Fn(&str) + Send + Sync>) -> Self {
46 self.observer = Some(EgressObserver(observer));
47 self
48 }
49
50 fn observe_denial(&self, host: &str) {
51 if let Some(observer) = &self.observer {
52 observer.0(host);
53 }
54 }
55
56 pub fn allow_all() -> Self {
58 Self::default()
59 }
60
61 pub fn client_builder(self: &Arc<Self>) -> reqwest::ClientBuilder {
68 reqwest::Client::builder().dns_resolver(Arc::new(PolicyResolver::new(Arc::clone(self))))
69 }
70
71 pub fn check_literal_host(&self, url: &str) -> Result<(), String> {
74 let ip = match url::Url::parse(url)
75 .ok()
76 .and_then(|u| u.host().map(|h| h.to_owned()))
77 {
78 Some(url::Host::Ipv4(v4)) => IpAddr::V4(v4),
79 Some(url::Host::Ipv6(v6)) => IpAddr::V6(v6),
80 _ => return Ok(()),
81 };
82 if self.permits(ip) {
83 Ok(())
84 } else {
85 self.observe_denial(&ip.to_string());
86 Err(format!(
87 "blocked by network policy: {ip} is private/loopback IP space; \
88 allow-list it on the server with --allow-ip / --allow-localhost / --allow-private"
89 ))
90 }
91 }
92
93 pub fn deny_private() -> Self {
96 Self {
97 enforce: true,
98 ..Self::default()
99 }
100 }
101
102 #[must_use]
104 pub fn allow_localhost(mut self, yes: bool) -> Self {
105 self.allow_localhost = yes;
106 self
107 }
108
109 #[must_use]
111 pub fn allow_private(mut self, yes: bool) -> Self {
112 self.allow_private = yes;
113 self
114 }
115
116 #[must_use]
118 pub fn allow_cidr(mut self, net: IpNet) -> Self {
119 self.allow.push(net);
120 self
121 }
122
123 pub fn permits(&self, ip: IpAddr) -> bool {
125 if !self.enforce {
126 return true;
127 }
128 let ip = normalize(ip);
132 if self.allow.iter().any(|net| net.contains(&ip)) {
133 return true;
134 }
135 if ip.is_loopback() {
136 return self.allow_localhost;
137 }
138 if is_private(ip) {
139 return self.allow_private;
140 }
141 !is_special(ip)
145 }
146}
147
148#[derive(Debug)]
150pub struct PolicyResolver {
151 policy: Arc<NetworkPolicy>,
152}
153
154impl PolicyResolver {
155 pub fn new(policy: Arc<NetworkPolicy>) -> Self {
156 Self { policy }
157 }
158}
159
160#[derive(Debug)]
161pub(super) struct EgressDenied(pub String);
162
163impl std::fmt::Display for EgressDenied {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 f.write_str(&self.0)
166 }
167}
168impl std::error::Error for EgressDenied {}
169
170impl Resolve for PolicyResolver {
171 fn resolve(&self, name: Name) -> Resolving {
172 let policy = self.policy.clone();
173 Box::pin(async move {
174 let permitted = policy.resolve_permitted(name.as_str()).await.map_err(
175 |e| -> Box<dyn std::error::Error + Send + Sync> {
176 match e {
177 ResolveFailure::Blocked(message) => Box::new(EgressDenied(message)),
178 ResolveFailure::Lookup(error) => Box::new(error),
179 }
180 },
181 )?;
182 Ok(Box::new(permitted.into_iter()) as Addrs)
183 })
184 }
185}
186
187#[derive(Debug)]
189pub enum ResolveFailure {
190 Blocked(String),
192 Lookup(std::io::Error),
194}
195
196impl NetworkPolicy {
197 pub async fn resolve_permitted(&self, host: &str) -> Result<Vec<SocketAddr>, ResolveFailure> {
202 let resolved = tokio::net::lookup_host((host, 0))
205 .await
206 .map_err(ResolveFailure::Lookup)?;
207 let permitted: Vec<SocketAddr> = resolved.filter(|addr| self.permits(addr.ip())).collect();
208 if permitted.is_empty() {
209 self.observe_denial(host);
210 return Err(ResolveFailure::Blocked(blocked_message(host)));
211 }
212 Ok(permitted)
213 }
214
215 pub async fn check_url(&self, url: &str) -> Result<(), String> {
221 self.check_literal_host(url)?;
222 if !self.enforce {
223 return Ok(());
224 }
225 let Some(host) = url::Url::parse(url)
226 .ok()
227 .and_then(|u| u.host_str().map(str::to_owned))
228 else {
229 return Ok(());
230 };
231 match self.resolve_permitted(&host).await {
232 Ok(_) | Err(ResolveFailure::Lookup(_)) => Ok(()),
233 Err(ResolveFailure::Blocked(message)) => Err(message),
234 }
235 }
236}
237
238fn blocked_message(host: &str) -> String {
239 format!(
240 "blocked by network policy: {host} resolves only to private/loopback IP space; \
241 allow-list it on the server with --allow-ip / --allow-localhost / --allow-private",
242 )
243}
244
245fn normalize(ip: IpAddr) -> IpAddr {
246 match ip {
247 IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
248 v4 => v4,
249 }
250}
251
252fn is_private(ip: IpAddr) -> bool {
254 match ip {
255 IpAddr::V4(v4) => v4.is_private() || is_cgnat(v4),
256 IpAddr::V6(v6) => is_ula(v6),
257 }
258}
259
260fn is_special(ip: IpAddr) -> bool {
262 if ip.is_unspecified() {
263 return true;
264 }
265 match ip {
266 IpAddr::V4(v4) => v4.is_link_local() || v4.is_broadcast(),
267 IpAddr::V6(v6) => is_v6_link_local(v6),
268 }
269}
270
271fn is_cgnat(v4: Ipv4Addr) -> bool {
273 let [a, b, ..] = v4.octets();
274 a == 100 && (64..=127).contains(&b)
275}
276
277fn is_ula(v6: Ipv6Addr) -> bool {
279 (v6.octets()[0] & 0xfe) == 0xfc
280}
281
282fn is_v6_link_local(v6: Ipv6Addr) -> bool {
284 let [a, b, ..] = v6.octets();
285 a == 0xfe && (b & 0xc0) == 0x80
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 fn ip(s: &str) -> IpAddr {
293 s.parse().unwrap()
294 }
295
296 fn cidr(s: &str) -> IpNet {
297 s.parse().unwrap()
298 }
299
300 #[test]
301 fn allow_all_permits_everything() {
302 let p = NetworkPolicy::allow_all();
303 for s in ["10.0.0.1", "127.0.0.1", "8.8.8.8", "169.254.169.254", "::1"] {
304 assert!(p.permits(ip(s)), "{s}");
305 }
306 }
307
308 #[test]
309 fn deny_private_blocks_internal_space() {
310 let p = NetworkPolicy::deny_private();
311 for s in [
312 "10.0.0.1",
313 "172.16.5.9",
314 "192.168.1.1",
315 "100.64.0.1", "127.0.0.1", "::1", "169.254.169.254", "fe80::1", "fc00::1", "0.0.0.0", "255.255.255.255", ] {
324 assert!(!p.permits(ip(s)), "should block {s}");
325 }
326 }
327
328 #[test]
329 fn deny_private_permits_public() {
330 let p = NetworkPolicy::deny_private();
331 for s in ["8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:4700::1111"] {
332 assert!(p.permits(ip(s)), "should permit {s}");
333 }
334 }
335
336 #[test]
337 fn ipv4_mapped_ipv6_is_classified_as_v4() {
338 let p = NetworkPolicy::deny_private();
339 assert!(
340 !p.permits(ip("::ffff:10.0.0.1")),
341 "mapped private must block"
342 );
343 assert!(
344 !p.permits(ip("::ffff:127.0.0.1")),
345 "mapped loopback must block"
346 );
347 assert!(p.permits(ip("::ffff:8.8.8.8")), "mapped public ok");
348 }
349
350 #[test]
351 fn allow_localhost_toggles_loopback_only() {
352 let p = NetworkPolicy::deny_private().allow_localhost(true);
353 assert!(p.permits(ip("127.0.0.1")));
354 assert!(p.permits(ip("::1")));
355 assert!(!p.permits(ip("10.0.0.1")), "private still blocked");
356 }
357
358 #[test]
359 fn allow_private_toggles_private_only() {
360 let p = NetworkPolicy::deny_private().allow_private(true);
361 assert!(p.permits(ip("10.0.0.1")));
362 assert!(p.permits(ip("fc00::1")));
363 assert!(!p.permits(ip("127.0.0.1")), "loopback still blocked");
364 assert!(
365 !p.permits(ip("169.254.169.254")),
366 "link-local still blocked"
367 );
368 }
369
370 #[test]
371 fn allow_cidr_overrides_blocks() {
372 let p = NetworkPolicy::deny_private().allow_cidr(cidr("10.1.0.0/16"));
373 assert!(p.permits(ip("10.1.2.3")));
374 assert!(!p.permits(ip("10.2.0.1")), "outside the allowed range");
375
376 let host = NetworkPolicy::deny_private().allow_cidr(cidr("169.254.169.254/32"));
377 assert!(
378 host.permits(ip("169.254.169.254")),
379 "explicit metadata allow"
380 );
381 }
382
383 #[test]
384 fn blocked_error_names_the_policy_and_host() {
385 let msg = blocked_message("internal.example");
386 assert!(msg.contains("blocked by network policy"), "{msg}");
387 assert!(msg.contains("internal.example"), "{msg}");
388 }
389
390 #[test]
391 fn permits_filters_blocked_addrs() {
392 let p = NetworkPolicy::deny_private();
395 assert!(!p.permits("10.0.0.1".parse().unwrap()), "rfc1918 blocked");
396 assert!(p.permits("8.8.8.8".parse().unwrap()), "public allowed");
397 assert!(!p.permits("127.0.0.1".parse().unwrap()), "loopback blocked");
398 }
399}