Skip to main content

rdap_security/
lib.rs

1//! SSRF (Server-Side Request Forgery) protection for RDAP clients.
2//!
3//! Every outbound URL is validated before the HTTP request is issued.
4//! The guard blocks:
5//!
6//! - Non-HTTPS schemes
7//! - IPv4 loopback (127/8), private (RFC 1918), link-local (169.254/16)
8//! - IPv6 loopback (::1), link-local (fe80::/10), unique-local (fc00::/7)
9//! - Explicitly blocked domain patterns
10//!
11//! Allowed domains (allowlist) take priority over all other checks.
12
13#![forbid(unsafe_code)]
14
15use std::net::{Ipv4Addr, Ipv6Addr};
16
17use url::{Host, Url};
18
19use rdap_types::error::{RdapError, Result};
20
21// ── Configuration ─────────────────────────────────────────────────────────────
22
23/// Configuration for the SSRF guard.
24#[derive(Debug, Clone)]
25pub struct SsrfConfig {
26    /// When `false` all checks are skipped (for testing only — never in production).
27    pub enabled: bool,
28    /// Additional domain suffixes to block (e.g., "internal.corp").
29    pub blocked_domains: Vec<String>,
30    /// If non-empty, only these domain suffixes are allowed.
31    /// Takes priority over `blocked_domains` and IP checks.
32    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// ── Guard ─────────────────────────────────────────────────────────────────────
46
47/// SSRF guard — validates a URL before any network call.
48#[derive(Debug, Clone)]
49pub struct SsrfGuard {
50    config: SsrfConfig,
51}
52
53impl SsrfGuard {
54    /// Creates a new guard with the default (most restrictive) configuration.
55    pub fn new() -> Self {
56        Self::with_config(SsrfConfig::default())
57    }
58
59    /// Creates a guard with a custom configuration.
60    pub fn with_config(config: SsrfConfig) -> Self {
61        Self { config }
62    }
63
64    /// Validates a URL string.
65    ///
66    /// Returns `Ok(())` if the URL is safe to fetch, or a [`RdapError`]
67    /// explaining why it was blocked.
68    pub fn validate(&self, raw_url: &str) -> Result<()> {
69        if !self.config.enabled {
70            return Ok(());
71        }
72
73        // ── Parse URL ────────────────────────────────────────────────────────
74        let url = Url::parse(raw_url).map_err(|e| RdapError::InvalidUrl {
75            url: raw_url.to_string(),
76            source: e,
77        })?;
78
79        // ── Enforce HTTPS ────────────────────────────────────────────────────
80        if url.scheme() != "https" {
81            return Err(RdapError::InsecureScheme {
82                scheme: url.scheme().to_string(),
83            });
84        }
85
86        // ── Allowlist (highest priority) ─────────────────────────────────────
87        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            // Allowlisted — skip all further checks.
106            return Ok(());
107        }
108
109        // ── Use typed host to avoid string re-parsing ─────────────────────────
110        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                // ── Domain blocklist check ────────────────────────────────────
119                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                // Plain domain names are otherwise allowed.
130            }
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    // ── Private IP checkers ───────────────────────────────────────────────────
145
146    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            // ::1/128
175            Some("IPv6 loopback address (::1)")
176        } else if o[0] == 0xfe && (o[1] & 0xc0) == 0x80 {
177            // fe80::/10 — link-local
178            Some("IPv6 link-local address (fe80::/10)")
179        } else if (o[0] & 0xfe) == 0xfc {
180            // fc00::/7 — unique-local (private)
181            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// ── Tests ─────────────────────────────────────────────────────────────────────
205
206#[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}