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}