dwara_core/config/
ssrf.rs

1//! SSRF egress filter (SEC-13).
2//!
3//! A configurable egress filter that rejects outbound connections to
4//! private/loopback/link-local/metadata endpoints. Applied to webhook
5//! deliveries and OPA callouts — any outbound dial the gateway makes
6//! on behalf of operator configuration that could be redirected to an
7//! internal address.
8//!
9//! # Design
10//!
11//! The filter is OPT-IN with an ALLOWLIST: when enabled, every resolved
12//! IP is checked against the deny set (RFC 1918, loopback, link-local,
13//! IPv6 ULA, cloud metadata endpoints). An optional allowlist exempts
14//! specific CIDRs (e.g. an internal alerting listener on 10/8).
15//!
16//! DNS REBINDING is mitigated by checking the resolved IP at connect
17//! time, not at config compile time — the webhook deliverer resolves
18//! and checks immediately before `TcpStream::connect`.
19//!
20//! The filter lives in the `config` domain because it uses
21//! [`crate::config::net`] IP/CIDR utilities and is a config-level
22//! policy (the deny set is config-bounded, not request-bounded).
23
24use std::net::IpAddr;
25
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28
29use super::net::{ip_in_net, parse_ip_or_cidr};
30
31/// The default deny set: CIDR ranges that are always rejected when the
32/// filter is enabled (unless the allowlist exempts them). Covers
33/// RFC 1918 private space, loopback, link-local, IPv6 ULA, the AWS/GCP/
34/// Azure cloud metadata endpoints, and the IPv6 loopback.
35const DEFAULT_DENY_CIDRS: &[&str] = &[
36    // IPv4 loopback
37    "127.0.0.0/8",
38    // IPv4 private (RFC 1918)
39    "10.0.0.0/8",
40    "172.16.0.0/12",
41    "192.168.0.0/16",
42    // IPv4 link-local
43    "169.254.0.0/16",
44    // IPv4 CGNAT (RFC 6598)
45    "100.64.0.0/10",
46    // IPv4 benchmarking (RFC 2544) — sometimes routed
47    "198.18.0.0/15",
48    // IPv4 multicast/reserved
49    "224.0.0.0/4",
50    "240.0.0.0/4",
51    // IPv4 broadcast
52    "255.255.255.255/32",
53    // IPv6 loopback
54    "::1/128",
55    // IPv6 link-local
56    "fe80::/10",
57    // IPv6 unique local
58    "fc00::/7",
59    // IPv6 multicast
60    "ff00::/8",
61    // IPv6 unspecified
62    "::/128",
63    // Cloud metadata endpoints (AWS/GCP/Azure — 169.254.169.254 is
64    // already in the link-local range above, but we list it explicitly
65    // for documentation; the GCP metadata endpoint is
66    // metadata.google.internal — DNS-based, caught at resolve time).
67];
68
69/// SSRF egress filter configuration. When `enabled` is true, outbound
70/// connections from webhook deliveries and OPA callouts are checked
71/// against the deny set; an IP in the deny set that is NOT in the
72/// allowlist is rejected. When `enabled` is false (the default), no
73/// filtering is applied (the v1 behavior — operator config is trusted).
74#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
75#[serde(deny_unknown_fields)]
76pub struct SsrfFilterConfig {
77    /// Whether the SSRF egress filter is enabled. Default false: the
78    /// v1 posture trusts operator-configured endpoints (an internal
79    /// alerting listener on 127.0.0.1 is a normal shape). Enable when
80    /// the gateway processes externally-sourced URLs (e.g. webhook
81    /// targets from a control-plane API).
82    #[serde(default, skip_serializing_if = "is_false")]
83    pub enabled: bool,
84    /// Additional CIDR ranges to EXEMPT from the deny set (allowlist).
85    /// Entries are IP or CIDR strings (e.g. `10.0.0.0/8` to allow all
86    /// of 10/8). An IP that matches any allowlist entry is accepted
87    /// even when it falls inside the default deny set.
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub allowlist: Vec<String>,
90}
91
92fn is_false(b: &bool) -> bool {
93    !b
94}
95
96/// A compiled SSRF filter: the parsed deny set and allowlist, ready for
97/// O(1) checks at connect time. Built from [`SsrfFilterConfig`] at
98/// config compile time; the deny set is the default plus any operator
99/// additions (future), the allowlist is the parsed config entries.
100#[derive(Debug, Clone, Default)]
101pub struct SsrfFilter {
102    /// The parsed deny CIDRs: (network, prefix) pairs.
103    deny: Vec<(IpAddr, u8)>,
104    /// The parsed allowlist CIDRs: (network, prefix) pairs.
105    allow: Vec<(IpAddr, u8)>,
106    /// Whether the filter is enabled.
107    enabled: bool,
108}
109
110impl SsrfFilter {
111    /// Build a filter from config. Invalid deny/allowlist entries are
112    /// silently skipped (validation rejects them at config compile
113    /// time; this is a defense-in-depth backstop).
114    pub fn from_config(cfg: &SsrfFilterConfig) -> Self {
115        if !cfg.enabled {
116            return Self::disabled();
117        }
118        let deny: Vec<(IpAddr, u8)> = DEFAULT_DENY_CIDRS
119            .iter()
120            .filter_map(|s| parse_ip_or_cidr(s))
121            .collect();
122        let allow: Vec<(IpAddr, u8)> = cfg
123            .allowlist
124            .iter()
125            .filter_map(|s| parse_ip_or_cidr(s))
126            .collect();
127        Self {
128            deny,
129            allow,
130            enabled: true,
131        }
132    }
133
134    /// A disabled filter (accepts everything).
135    pub fn disabled() -> Self {
136        Self::default()
137    }
138
139    /// Whether the filter is enabled.
140    pub fn is_enabled(&self) -> bool {
141        self.enabled
142    }
143
144    /// Check whether `ip` is allowed. Returns `Ok(())` when the IP is
145    /// accepted, `Err(reason)` when it is rejected. A disabled filter
146    /// always returns `Ok(())`.
147    pub fn check(&self, ip: IpAddr) -> Result<(), &'static str> {
148        if !self.enabled {
149            return Ok(());
150        }
151        // Allowlist takes precedence: an explicitly-allowed IP is
152        // accepted even when it falls inside the deny set.
153        if self
154            .allow
155            .iter()
156            .any(|(net, prefix)| ip_in_net(ip, *net, *prefix))
157        {
158            return Ok(());
159        }
160        if self
161            .deny
162            .iter()
163            .any(|(net, prefix)| ip_in_net(ip, *net, *prefix))
164        {
165            return Err(
166                "SSRF egress filter: target IP is in a denied range (private/loopback/\
167                        link-local/metadata)",
168            );
169        }
170        Ok(())
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn disabled_filter_accepts_all() {
180        let f = SsrfFilter::disabled();
181        assert!(f.check("127.0.0.1".parse().unwrap()).is_ok());
182        assert!(f.check("10.0.0.1".parse().unwrap()).is_ok());
183        assert!(f.check("8.8.8.8".parse().unwrap()).is_ok());
184    }
185
186    #[test]
187    fn enabled_filter_rejects_private() {
188        let cfg = SsrfFilterConfig {
189            enabled: true,
190            allowlist: vec![],
191        };
192        let f = SsrfFilter::from_config(&cfg);
193        assert!(f.check("127.0.0.1".parse().unwrap()).is_err());
194        assert!(f.check("10.0.0.1".parse().unwrap()).is_err());
195        assert!(f.check("192.168.1.1".parse().unwrap()).is_err());
196        assert!(f.check("169.254.169.254".parse().unwrap()).is_err());
197        assert!(f.check("::1".parse().unwrap()).is_err());
198    }
199
200    #[test]
201    fn enabled_filter_accepts_public() {
202        let cfg = SsrfFilterConfig {
203            enabled: true,
204            allowlist: vec![],
205        };
206        let f = SsrfFilter::from_config(&cfg);
207        assert!(f.check("8.8.8.8".parse().unwrap()).is_ok());
208        assert!(f.check("1.1.1.1".parse().unwrap()).is_ok());
209    }
210
211    #[test]
212    fn allowlist_exempts_private() {
213        let cfg = SsrfFilterConfig {
214            enabled: true,
215            allowlist: vec!["10.0.0.0/8".to_string()],
216        };
217        let f = SsrfFilter::from_config(&cfg);
218        assert!(f.check("10.0.0.1".parse().unwrap()).is_ok());
219        // Non-allowlisted private is still rejected.
220        assert!(f.check("127.0.0.1".parse().unwrap()).is_err());
221    }
222
223    #[test]
224    fn ipv6_link_local_rejected() {
225        let cfg = SsrfFilterConfig {
226            enabled: true,
227            allowlist: vec![],
228        };
229        let f = SsrfFilter::from_config(&cfg);
230        assert!(f.check("fe80::1".parse().unwrap()).is_err());
231        assert!(f.check("fc00::1".parse().unwrap()).is_err());
232    }
233}