Skip to main content

submilli_engine/stdlib/http/
policy.rs

1//! Network egress policy for outbound HTTP — SSRF protection.
2//!
3//! A [`NetworkPolicy`] decides whether a resolved IP may be connected to.
4//! [`PolicyResolver`] enforces it as reqwest's custom DNS resolver: it resolves
5//! the host and drops policy-forbidden addresses, so the connection only ever
6//! targets vetted IPs. Filtering at resolution rather than on the host string is
7//! what closes the DNS-rebinding window — a public name that resolves to an
8//! internal IP is caught, and reqwest connects to exactly the addresses we
9//! approved.
10
11use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
12use std::sync::Arc;
13
14use ipnet::IpNet;
15use reqwest::dns::{Addrs, Name, Resolve, Resolving};
16
17/// Outbound-address policy. The default (`enforce == false`) permits every
18/// address — the interpreter library and `submilli run` stay unrestricted. The
19/// `submilli-server` binary opts into [`NetworkPolicy::deny_private`].
20#[derive(Clone, Debug, Default)]
21pub struct NetworkPolicy {
22    observer: Option<EgressObserver>,
23    /// Master switch. `false` (default) permits everything — no filtering. When
24    /// `true`, private/loopback/special IP space is blocked except where an
25    /// opt-out below applies.
26    enforce: bool,
27    allow_localhost: bool,
28    allow_private: bool,
29    /// Explicit `--allow-ip` entries; an address inside any of these is always
30    /// permitted, overriding every other rule.
31    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    /// Observe refused destinations without changing the network policy.
45    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    /// Permit every address. The library/CLI default.
57    pub fn allow_all() -> Self {
58        Self::default()
59    }
60
61    /// A reqwest client builder whose DNS resolution is filtered by this policy:
62    /// a name resolving only to forbidden addresses fails to resolve. Every
63    /// outbound client the runtime builds — `submilli:http`, model providers,
64    /// MCP servers — starts from this, so one policy governs them all. Literal
65    /// IP hosts never reach a resolver; refuse those with
66    /// [`Self::check_literal_host`] before sending.
67    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    /// Refuse a URL whose host is a literal IP the policy forbids. Host names
72    /// pass here and are judged at resolution.
73    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    /// Block private, loopback, link-local and other special-purpose IP space.
94    /// The secure base the server layers opt-outs onto.
95    pub fn deny_private() -> Self {
96        Self {
97            enforce: true,
98            ..Self::default()
99        }
100    }
101
102    /// Allow IPv4 + IPv6 loopback (`127.0.0.0/8`, `::1`).
103    #[must_use]
104    pub fn allow_localhost(mut self, yes: bool) -> Self {
105        self.allow_localhost = yes;
106        self
107    }
108
109    /// Allow all RFC1918 / CGNAT / IPv6-ULA private ranges.
110    #[must_use]
111    pub fn allow_private(mut self, yes: bool) -> Self {
112        self.allow_private = yes;
113        self
114    }
115
116    /// Allow a specific address or CIDR range, overriding every block.
117    #[must_use]
118    pub fn allow_cidr(mut self, net: IpNet) -> Self {
119        self.allow.push(net);
120        self
121    }
122
123    /// Whether a request may connect to `ip`.
124    pub fn permits(&self, ip: IpAddr) -> bool {
125        if !self.enforce {
126            return true;
127        }
128        // An IPv4-mapped IPv6 address (`::ffff:10.0.0.1`) reaches the same host
129        // as the bare IPv4 — classify it as the embedded v4 or it bypasses the
130        // private/loopback checks below.
131        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        // Unspecified, link-local (incl. the 169.254.169.254 cloud-metadata
142        // endpoint) and broadcast are never legitimate sandbox targets; only an
143        // explicit allow-list entry (checked above) can reach them.
144        !is_special(ip)
145    }
146}
147
148/// reqwest DNS resolver that drops policy-forbidden addresses post-resolution.
149#[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/// Why a host name yielded no address to connect to.
188#[derive(Debug)]
189pub enum ResolveFailure {
190    /// It resolved, but only to addresses the policy forbids.
191    Blocked(String),
192    /// DNS itself failed; the policy had nothing to judge.
193    Lookup(std::io::Error),
194}
195
196impl NetworkPolicy {
197    /// Resolve `host` and keep the addresses the policy permits. The resolver
198    /// installed on every outbound client runs this; a caller whose transport
199    /// hides the resolver's error (rmcp wraps it as text) runs it first via
200    /// [`Self::check_url`] so the refusal is reported in its own words.
201    pub async fn resolve_permitted(&self, host: &str) -> Result<Vec<SocketAddr>, ResolveFailure> {
202        // Port 0 — reqwest overrides it with the request's port. Resolution
203        // happens here once; reqwest connects to exactly what we return.
204        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    /// Refuse a URL the policy forbids, whether its host is a literal IP or a
216    /// name that resolves only to forbidden addresses. A name that fails to
217    /// resolve at all passes: that failure belongs to the connection attempt,
218    /// which reports it in its own terms. Skips DNS entirely when the policy
219    /// permits everything.
220    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
252/// RFC1918 (v4), CGNAT (`100.64.0.0/10`), and IPv6 ULA (`fc00::/7`).
253fn 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
260/// Special-purpose space that is never a legitimate outbound target.
261fn 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
271/// `100.64.0.0/10` — RFC6598 carrier-grade NAT.
272fn is_cgnat(v4: Ipv4Addr) -> bool {
273    let [a, b, ..] = v4.octets();
274    a == 100 && (64..=127).contains(&b)
275}
276
277/// `fc00::/7` — RFC4193 unique local addresses.
278fn is_ula(v6: Ipv6Addr) -> bool {
279    (v6.octets()[0] & 0xfe) == 0xfc
280}
281
282/// `fe80::/10` — link-local unicast (`Ipv6Addr::is_unicast_link_local` is unstable).
283fn 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",      // CGNAT
316            "127.0.0.1",       // loopback
317            "::1",             // loopback v6
318            "169.254.169.254", // link-local (cloud metadata)
319            "fe80::1",         // link-local v6
320            "fc00::1",         // ULA
321            "0.0.0.0",         // unspecified
322            "255.255.255.255", // broadcast
323        ] {
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        // The reqwest resolver keeps only `permits`-approved IPs; verify the
393        // decision directly (end-to-end SSRF is covered by the server's tests).
394        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}