Skip to main content

rsigma_runtime/
egress.rs

1//! Egress policy enforcement for outbound HTTP traffic.
2//!
3//! Both the [pipeline source resolver](crate::sources::http) and the
4//! [HTTP enricher](crate::enrichment::HttpEnricher) call out to operator-
5//! configured URLs. Without a policy gate, any user with write access to
6//! a Sigma rule or pipeline could point the daemon at well-known
7//! sensitive endpoints (cloud metadata services at `169.254.169.254`,
8//! internal admin APIs on link-local addresses, etc.) and trigger SSRF.
9//!
10//! [`EgressPolicy`] expresses a deny list of address classes (cloud
11//! metadata, link-local, optionally loopback / private) and is applied
12//! at *DNS resolution time* via [`EgressFilteredResolver`], so a DNS
13//! rebinding attack that swaps the upstream's IP after a host-string
14//! check cannot defeat it: the connect itself never sees a denied
15//! address.
16//!
17//! ## Defaults
18//!
19//! [`EgressPolicy::default()`] blocks link-local addresses (which
20//! includes the canonical cloud-metadata endpoints `169.254.169.254`
21//! and `fe80::/10`) and known cloud-metadata IPv6 addresses, but
22//! leaves loopback and RFC1918 private addresses reachable. This
23//! matches typical real-world deployments where the daemon may need
24//! to reach an internal threat-intel API on a private IP while still
25//! refusing to talk to a cloud metadata service.
26//!
27//! Operators can tighten the policy with
28//! [`EgressPolicy::strict()`] (blocks loopback and private too) or
29//! relax it with [`EgressPolicy::permissive()`] when running in a
30//! controlled environment where SSRF is not a concern.
31
32use std::net::{IpAddr, Ipv6Addr, SocketAddr};
33use std::sync::{Arc, OnceLock};
34
35use reqwest::dns::{Addrs, Name, Resolve, Resolving};
36
37/// Process-wide egress policy used by the daemon's default HTTP clients
38/// (sources and enrichers). Configured at startup via
39/// [`set_default_egress_policy`]; defaults to [`EgressPolicy::default()`]
40/// when unset.
41static DEFAULT_EGRESS_POLICY: OnceLock<EgressPolicy> = OnceLock::new();
42
43/// Install the process-wide egress policy. Returns an error if a policy
44/// was already installed; first-set wins so the daemon's startup config
45/// loader can call this once and library consumers can call it before
46/// any client is built.
47pub fn set_default_egress_policy(policy: EgressPolicy) -> Result<(), EgressPolicy> {
48    DEFAULT_EGRESS_POLICY.set(policy)
49}
50
51/// Return the configured process-wide egress policy, or
52/// [`EgressPolicy::default()`] if none was installed.
53pub fn default_egress_policy() -> EgressPolicy {
54    DEFAULT_EGRESS_POLICY
55        .get()
56        .copied()
57        .unwrap_or_else(EgressPolicy::default)
58}
59
60/// Outbound HTTP egress policy.
61///
62/// Toggled via the [`Self::with_block_*`](EgressPolicy::with_block_link_local)
63/// builder helpers, with [`Self::default()`], [`Self::permissive()`], and
64/// [`Self::strict()`] as presets.
65#[derive(Debug, Clone, Copy)]
66pub struct EgressPolicy {
67    block_link_local: bool,
68    block_cloud_metadata: bool,
69    block_loopback: bool,
70    block_private: bool,
71}
72
73impl Default for EgressPolicy {
74    fn default() -> Self {
75        Self {
76            block_link_local: true,
77            block_cloud_metadata: true,
78            block_loopback: false,
79            block_private: false,
80        }
81    }
82}
83
84impl EgressPolicy {
85    /// Permit every address. Intended for tests and tightly-controlled
86    /// environments where the operator has already vetted every URL.
87    pub fn permissive() -> Self {
88        Self {
89            block_link_local: false,
90            block_cloud_metadata: false,
91            block_loopback: false,
92            block_private: false,
93        }
94    }
95
96    /// Block every category: link-local, cloud metadata, loopback, and
97    /// RFC1918 private. The daemon can still reach public endpoints,
98    /// which is what most production deployments want.
99    pub fn strict() -> Self {
100        Self {
101            block_link_local: true,
102            block_cloud_metadata: true,
103            block_loopback: true,
104            block_private: true,
105        }
106    }
107
108    pub fn with_block_link_local(mut self, block: bool) -> Self {
109        self.block_link_local = block;
110        self
111    }
112    pub fn with_block_cloud_metadata(mut self, block: bool) -> Self {
113        self.block_cloud_metadata = block;
114        self
115    }
116    pub fn with_block_loopback(mut self, block: bool) -> Self {
117        self.block_loopback = block;
118        self
119    }
120    pub fn with_block_private(mut self, block: bool) -> Self {
121        self.block_private = block;
122        self
123    }
124
125    /// Decide whether `ip` is permitted under the policy.
126    pub fn permit_ip(&self, ip: IpAddr) -> Result<(), EgressDenial> {
127        match ip {
128            IpAddr::V4(v4) => {
129                if self.block_link_local && v4.is_link_local() {
130                    return Err(EgressDenial::LinkLocal(ip));
131                }
132                if self.block_loopback && v4.is_loopback() {
133                    return Err(EgressDenial::Loopback(ip));
134                }
135                if self.block_private && v4.is_private() {
136                    return Err(EgressDenial::Private(ip));
137                }
138                // Broadcast / multicast / unspecified rarely come back
139                // from DNS, but they are equally unsafe as link-local
140                // when they do. Treat as link-local for the purpose of
141                // the deny reason.
142                if self.block_link_local
143                    && (v4.is_broadcast() || v4.is_multicast() || v4.is_unspecified())
144                {
145                    return Err(EgressDenial::LinkLocal(ip));
146                }
147            }
148            IpAddr::V6(v6) => {
149                let segs = v6.segments();
150                if self.block_link_local && (segs[0] & 0xffc0) == 0xfe80 {
151                    return Err(EgressDenial::LinkLocal(ip));
152                }
153                if self.block_cloud_metadata && is_known_cloud_metadata_v6(v6) {
154                    return Err(EgressDenial::CloudMetadata(ip));
155                }
156                if self.block_loopback && v6.is_loopback() {
157                    return Err(EgressDenial::Loopback(ip));
158                }
159                // Unique local addresses (fc00::/7) are the IPv6 RFC4193
160                // analog of RFC1918.
161                if self.block_private && (segs[0] & 0xfe00) == 0xfc00 {
162                    return Err(EgressDenial::Private(ip));
163                }
164                if self.block_link_local && (v6.is_multicast() || v6.is_unspecified()) {
165                    return Err(EgressDenial::LinkLocal(ip));
166                }
167                // Recurse on IPv4-mapped IPv6 (::ffff:a.b.c.d) so a host
168                // that resolves to a v4-in-v6 wrapper does not bypass
169                // the v4 deny rules.
170                if let Some(v4) = v6.to_ipv4_mapped() {
171                    return self.permit_ip(IpAddr::V4(v4));
172                }
173            }
174        }
175        Ok(())
176    }
177}
178
179fn is_known_cloud_metadata_v6(v6: Ipv6Addr) -> bool {
180    // AWS IPv6 instance metadata: fd00:ec2::254. The address lives in
181    // the unique-local space but is published as the IMDS endpoint, so
182    // we deny it independently of the broader private-address toggle.
183    v6 == Ipv6Addr::new(0xfd00, 0x00ec, 0x0002, 0, 0, 0, 0, 0x0254)
184}
185
186/// Reason an address was denied by an [`EgressPolicy`].
187#[derive(Debug, Clone)]
188pub enum EgressDenial {
189    LinkLocal(IpAddr),
190    CloudMetadata(IpAddr),
191    Loopback(IpAddr),
192    Private(IpAddr),
193}
194
195impl std::fmt::Display for EgressDenial {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            Self::LinkLocal(ip) => {
199                write!(f, "egress policy denied link-local address {ip}")
200            }
201            Self::CloudMetadata(ip) => {
202                write!(f, "egress policy denied cloud-metadata address {ip}")
203            }
204            Self::Loopback(ip) => write!(f, "egress policy denied loopback address {ip}"),
205            Self::Private(ip) => write!(f, "egress policy denied private address {ip}"),
206        }
207    }
208}
209
210impl std::error::Error for EgressDenial {}
211
212/// A [`reqwest::dns::Resolve`] implementation that delegates to
213/// `tokio::net::lookup_host` and then filters the resolved addresses
214/// through an [`EgressPolicy`]. Used by both the HTTP source resolver
215/// and the HTTP enricher so they share one safe deny list.
216pub struct EgressFilteredResolver {
217    policy: EgressPolicy,
218}
219
220impl EgressFilteredResolver {
221    pub fn new(policy: EgressPolicy) -> Self {
222        Self { policy }
223    }
224
225    /// Wrap the resolver in the shared `Arc<EgressFilteredResolver>` shape
226    /// [`reqwest::ClientBuilder::dns_resolver`] expects. Reqwest's
227    /// `dns_resolver` is generic over `R: Resolve + 'static + Sized`, so
228    /// the resolver type must be concrete (not `dyn Resolve`).
229    pub fn into_dns_resolver(self) -> Arc<Self> {
230        Arc::new(self)
231    }
232}
233
234impl Resolve for EgressFilteredResolver {
235    fn resolve(&self, name: Name) -> Resolving {
236        let policy = self.policy;
237        let host = name.as_str().to_string();
238        Box::pin(async move {
239            // `tokio::net::lookup_host` needs a `host:port`; the port
240            // is replaced by reqwest with the URL's port.
241            let lookup_target = format!("{host}:0");
242            let resolved: Vec<SocketAddr> = tokio::net::lookup_host(lookup_target)
243                .await
244                .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
245                .collect();
246
247            let mut allowed: Vec<SocketAddr> = Vec::with_capacity(resolved.len());
248            let mut first_denial: Option<EgressDenial> = None;
249            for sa in resolved {
250                match policy.permit_ip(sa.ip()) {
251                    Ok(()) => allowed.push(sa),
252                    Err(denial) => {
253                        if first_denial.is_none() {
254                            first_denial = Some(denial);
255                        }
256                    }
257                }
258            }
259            if allowed.is_empty() {
260                let message: String = match first_denial {
261                    Some(d) => d.to_string(),
262                    None => format!("no addresses resolved for '{host}'"),
263                };
264                return Err(Box::<dyn std::error::Error + Send + Sync>::from(message));
265            }
266            let addrs: Addrs = Box::new(allowed.into_iter());
267            Ok(addrs)
268        })
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use std::net::{Ipv4Addr, Ipv6Addr};
276
277    fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr {
278        IpAddr::V4(Ipv4Addr::new(a, b, c, d))
279    }
280
281    #[allow(clippy::too_many_arguments)]
282    fn v6(s0: u16, s1: u16, s2: u16, s3: u16, s4: u16, s5: u16, s6: u16, s7: u16) -> IpAddr {
283        IpAddr::V6(Ipv6Addr::new(s0, s1, s2, s3, s4, s5, s6, s7))
284    }
285
286    #[test]
287    fn default_blocks_link_local_and_cloud_metadata() {
288        let p = EgressPolicy::default();
289        assert!(matches!(
290            p.permit_ip(v4(169, 254, 169, 254)),
291            Err(EgressDenial::LinkLocal(_))
292        ));
293        assert!(matches!(
294            p.permit_ip(v6(0xfe80, 0, 0, 0, 0, 0, 0, 1)),
295            Err(EgressDenial::LinkLocal(_))
296        ));
297        assert!(matches!(
298            p.permit_ip(v6(0xfd00, 0x00ec, 0x0002, 0, 0, 0, 0, 0x0254)),
299            Err(EgressDenial::CloudMetadata(_))
300        ));
301    }
302
303    #[test]
304    fn default_allows_loopback_and_private() {
305        // Internal threat-intel services commonly live on private
306        // addresses; the default policy must let them through.
307        let p = EgressPolicy::default();
308        assert!(p.permit_ip(v4(127, 0, 0, 1)).is_ok());
309        assert!(p.permit_ip(v4(10, 0, 0, 1)).is_ok());
310        assert!(p.permit_ip(v4(192, 168, 1, 1)).is_ok());
311        assert!(p.permit_ip(v4(8, 8, 8, 8)).is_ok());
312        assert!(p.permit_ip(v6(0, 0, 0, 0, 0, 0, 0, 1)).is_ok());
313    }
314
315    #[test]
316    fn strict_blocks_loopback_and_private() {
317        let p = EgressPolicy::strict();
318        assert!(matches!(
319            p.permit_ip(v4(127, 0, 0, 1)),
320            Err(EgressDenial::Loopback(_))
321        ));
322        assert!(matches!(
323            p.permit_ip(v4(10, 0, 0, 1)),
324            Err(EgressDenial::Private(_))
325        ));
326        assert!(matches!(
327            p.permit_ip(v6(0xfc00, 0, 0, 0, 0, 0, 0, 1)),
328            Err(EgressDenial::Private(_))
329        ));
330        // Public addresses still allowed.
331        assert!(p.permit_ip(v4(8, 8, 8, 8)).is_ok());
332    }
333
334    #[test]
335    fn permissive_allows_everything() {
336        let p = EgressPolicy::permissive();
337        assert!(p.permit_ip(v4(169, 254, 169, 254)).is_ok());
338        assert!(p.permit_ip(v4(127, 0, 0, 1)).is_ok());
339        assert!(p.permit_ip(v4(10, 0, 0, 1)).is_ok());
340        assert!(
341            p.permit_ip(v6(0xfd00, 0x00ec, 0x0002, 0, 0, 0, 0, 0x0254))
342                .is_ok()
343        );
344    }
345
346    #[test]
347    fn ipv4_mapped_ipv6_inherits_v4_rules() {
348        // ::ffff:169.254.169.254 must be denied as link-local even
349        // though its IPv6 representation does not match fe80::/10.
350        let p = EgressPolicy::default();
351        let mapped = Ipv4Addr::new(169, 254, 169, 254).to_ipv6_mapped();
352        assert!(matches!(
353            p.permit_ip(IpAddr::V6(mapped)),
354            Err(EgressDenial::LinkLocal(_))
355        ));
356    }
357
358    #[test]
359    fn builder_overrides_individual_categories() {
360        // Operators may want metadata blocked but private allowed
361        // (typical case); flipping link-local to false should not
362        // accidentally re-enable cloud metadata, since the IPv6
363        // cloud-metadata address has its own toggle.
364        let p = EgressPolicy::default().with_block_link_local(false);
365        assert!(p.permit_ip(v4(169, 254, 169, 254)).is_ok());
366        assert!(matches!(
367            p.permit_ip(v6(0xfd00, 0x00ec, 0x0002, 0, 0, 0, 0, 0x0254)),
368            Err(EgressDenial::CloudMetadata(_))
369        ));
370    }
371
372    #[tokio::test(flavor = "multi_thread")]
373    async fn filtered_resolver_denies_link_local_lookup() {
374        // Resolving a literal IP via getaddrinfo returns that IP; the
375        // resolver filter must reject it just like any other deny case.
376        let resolver = EgressFilteredResolver::new(EgressPolicy::default());
377        let name: reqwest::dns::Name = "169.254.169.254".parse().unwrap();
378        let result = resolver.resolve(name).await;
379        // `Addrs` is `Box<dyn Iterator>` which has no `Debug`, so we
380        // cannot use `expect_err`. Match the result manually instead.
381        let err = match result {
382            Ok(_) => panic!("policy must deny link-local literal"),
383            Err(e) => e,
384        };
385        let msg = format!("{err}");
386        assert!(
387            msg.contains("link-local"),
388            "expected link-local denial, got: {msg}"
389        );
390    }
391
392    #[tokio::test(flavor = "multi_thread")]
393    async fn filtered_resolver_permits_public_lookup() {
394        // 8.8.8.8 is a literal public IP; the lookup short-circuits in
395        // tokio's resolver without hitting DNS.
396        let resolver = EgressFilteredResolver::new(EgressPolicy::default());
397        let name: reqwest::dns::Name = "8.8.8.8".parse().unwrap();
398        if resolver.resolve(name).await.is_err() {
399            panic!("public IP must be permitted by default policy");
400        }
401    }
402}