1#![forbid(unsafe_code)]
14
15use std::net::{Ipv4Addr, Ipv6Addr};
16
17use url::{Host, Url};
18
19use rdap_types::error::{RdapError, Result};
20
21#[derive(Debug, Clone)]
25pub struct SsrfConfig {
26 pub enabled: bool,
28 pub blocked_domains: Vec<String>,
30 pub allowed_domains: Vec<String>,
33}
34
35impl Default for SsrfConfig {
36 fn default() -> Self {
37 Self {
38 enabled: true,
39 blocked_domains: Vec::new(),
40 allowed_domains: Vec::new(),
41 }
42 }
43}
44
45#[derive(Debug, Clone)]
49pub struct SsrfGuard {
50 config: SsrfConfig,
51}
52
53impl SsrfGuard {
54 pub fn new() -> Self {
56 Self::with_config(SsrfConfig::default())
57 }
58
59 pub fn with_config(config: SsrfConfig) -> Self {
61 Self { config }
62 }
63
64 pub fn validate(&self, raw_url: &str) -> Result<()> {
69 if !self.config.enabled {
70 return Ok(());
71 }
72
73 let url = Url::parse(raw_url).map_err(|e| RdapError::InvalidUrl {
75 url: raw_url.to_string(),
76 source: e,
77 })?;
78
79 if url.scheme() != "https" {
81 return Err(RdapError::InsecureScheme {
82 scheme: url.scheme().to_string(),
83 });
84 }
85
86 if !self.config.allowed_domains.is_empty() {
88 let host_str = url
89 .host_str()
90 .ok_or_else(|| RdapError::InvalidInput(format!("URL has no host: {raw_url}")))?;
91
92 let allowed = self.config.allowed_domains.iter().any(|d| {
93 let d = d.to_lowercase();
94 let h = host_str.to_lowercase();
95 h == d || h.ends_with(&format!(".{d}"))
96 });
97
98 if !allowed {
99 return Err(RdapError::SsrfBlocked {
100 url: raw_url.to_string(),
101 reason: format!("host '{host_str}' is not in the allowed-domains list"),
102 });
103 }
104
105 return Ok(());
107 }
108
109 match url.host() {
111 None => {
112 return Err(RdapError::InvalidInput(format!(
113 "URL has no host: {raw_url}"
114 )))
115 }
116
117 Some(Host::Domain(domain)) => {
118 for blocked in &self.config.blocked_domains {
120 let b = blocked.to_lowercase();
121 let d = domain.to_lowercase();
122 if d == b || d.ends_with(&format!(".{b}")) {
123 return Err(RdapError::SsrfBlocked {
124 url: raw_url.to_string(),
125 reason: format!("domain '{domain}' is in the blocked-domains list"),
126 });
127 }
128 }
129 }
131
132 Some(Host::Ipv4(v4)) => {
133 self.check_ipv4(v4, raw_url)?;
134 }
135
136 Some(Host::Ipv6(v6)) => {
137 self.check_ipv6(v6, raw_url)?;
138 }
139 }
140
141 Ok(())
142 }
143
144 fn check_ipv4(&self, ip: Ipv4Addr, raw_url: &str) -> Result<()> {
147 let reason = if ip.is_loopback() {
148 Some("IPv4 loopback address (127/8)")
149 } else if ip.is_private() {
150 Some("private IPv4 address (RFC 1918)")
151 } else if ip.is_link_local() {
152 Some("IPv4 link-local address (169.254/16)")
153 } else if ip.is_broadcast() {
154 Some("IPv4 broadcast address")
155 } else if ip.is_unspecified() {
156 Some("unspecified IPv4 address (0.0.0.0/8)")
157 } else {
158 None
159 };
160
161 if let Some(r) = reason {
162 return Err(RdapError::SsrfBlocked {
163 url: raw_url.to_string(),
164 reason: r.to_string(),
165 });
166 }
167 Ok(())
168 }
169
170 fn check_ipv6(&self, ip: Ipv6Addr, raw_url: &str) -> Result<()> {
171 let o = ip.octets();
172
173 let reason = if ip.is_loopback() {
174 Some("IPv6 loopback address (::1)")
176 } else if o[0] == 0xfe && (o[1] & 0xc0) == 0x80 {
177 Some("IPv6 link-local address (fe80::/10)")
179 } else if (o[0] & 0xfe) == 0xfc {
180 Some("IPv6 unique-local address (fc00::/7)")
182 } else if ip.is_unspecified() {
183 Some("unspecified IPv6 address (::/128)")
184 } else {
185 None
186 };
187
188 if let Some(r) = reason {
189 return Err(RdapError::SsrfBlocked {
190 url: raw_url.to_string(),
191 reason: r.to_string(),
192 });
193 }
194 Ok(())
195 }
196}
197
198impl Default for SsrfGuard {
199 fn default() -> Self {
200 Self::new()
201 }
202}
203
204#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn allows_public_https() {
212 let guard = SsrfGuard::new();
213 assert!(guard.validate("https://rdap.verisign.com/com/v1/").is_ok());
214 assert!(guard.validate("https://rdap.arin.net/registry/").is_ok());
215 }
216
217 #[test]
218 fn blocks_http() {
219 let guard = SsrfGuard::new();
220 let err = guard.validate("http://rdap.verisign.com/").unwrap_err();
221 assert!(matches!(err, RdapError::InsecureScheme { .. }));
222 }
223
224 #[test]
225 fn blocks_localhost() {
226 let guard = SsrfGuard::new();
227 assert!(guard
228 .validate("https://127.0.0.1/")
229 .unwrap_err()
230 .is_ssrf_blocked());
231 assert!(guard
232 .validate("https://[::1]/")
233 .unwrap_err()
234 .is_ssrf_blocked());
235 }
236
237 #[test]
238 fn blocks_private_ranges() {
239 let guard = SsrfGuard::new();
240 assert!(guard
241 .validate("https://10.0.0.1/")
242 .unwrap_err()
243 .is_ssrf_blocked());
244 assert!(guard
245 .validate("https://192.168.1.1/")
246 .unwrap_err()
247 .is_ssrf_blocked());
248 assert!(guard
249 .validate("https://172.16.0.1/")
250 .unwrap_err()
251 .is_ssrf_blocked());
252 }
253
254 #[test]
255 fn blocks_link_local() {
256 let guard = SsrfGuard::new();
257 assert!(guard
258 .validate("https://169.254.1.1/")
259 .unwrap_err()
260 .is_ssrf_blocked());
261 assert!(guard
262 .validate("https://[fe80::1]/")
263 .unwrap_err()
264 .is_ssrf_blocked());
265 }
266
267 #[test]
268 fn allowlist_overrides_blocklist() {
269 let guard = SsrfGuard::with_config(SsrfConfig {
270 enabled: true,
271 allowed_domains: vec!["rdap.verisign.com".into()],
272 blocked_domains: vec!["rdap.verisign.com".into()],
273 });
274 assert!(guard.validate("https://rdap.verisign.com/com/v1/").is_ok());
275 }
276
277 #[test]
278 fn allowlist_blocks_unlisted() {
279 let guard = SsrfGuard::with_config(SsrfConfig {
280 enabled: true,
281 allowed_domains: vec!["rdap.verisign.com".into()],
282 ..Default::default()
283 });
284 assert!(guard
285 .validate("https://rdap.arin.net/registry/")
286 .unwrap_err()
287 .is_ssrf_blocked());
288 }
289
290 #[test]
291 fn disabled_guard_allows_everything() {
292 let guard = SsrfGuard::with_config(SsrfConfig {
293 enabled: false,
294 ..Default::default()
295 });
296 assert!(guard.validate("http://127.0.0.1/").is_ok());
297 }
298}