1use std::collections::HashSet;
4use std::net::{IpAddr, Ipv4Addr};
5
6use once_cell::sync::Lazy;
7
8use crate::error::{Result, SeerError};
9
10static DOMAIN_ALLOWLIST: Lazy<Option<HashSet<String>>> = Lazy::new(|| {
16 let set: HashSet<String> = std::env::var("SEER_DOMAIN_ALLOWLIST")
17 .ok()?
18 .split(',')
19 .map(normalize_allowlist_entry)
20 .filter(|s| !s.is_empty())
21 .collect();
22
23 if set.is_empty() {
24 None
25 } else {
26 Some(set)
27 }
28});
29
30fn normalize_allowlist_entry(entry: &str) -> String {
36 let lowered = entry.trim().to_lowercase();
37 if lowered.is_ascii() {
38 lowered
39 } else {
40 domain_to_ascii(&lowered).unwrap_or(lowered)
41 }
42}
43
44pub fn normalize_domain(domain: &str) -> Result<String> {
58 let domain = domain.trim().to_lowercase();
59
60 let domain = domain
62 .strip_prefix("http://")
63 .or_else(|| domain.strip_prefix("https://"))
64 .unwrap_or(&domain);
65
66 let domain = domain.split('/').next().unwrap_or(domain);
68 let domain = domain.split('?').next().unwrap_or(domain);
69 let domain = domain.split('#').next().unwrap_or(domain);
70
71 let domain = domain.rsplit('@').next().unwrap_or(domain);
75
76 let domain = match domain.rsplit_once(':') {
80 Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
81 _ => domain,
82 };
83
84 let domain = domain.strip_prefix("www.").unwrap_or(domain);
86
87 let domain = domain.strip_suffix('.').unwrap_or(domain);
92
93 if domain.is_empty() || !domain.contains('.') {
95 return Err(SeerError::InvalidDomain(domain.to_string()));
96 }
97
98 let domain = if !domain.is_ascii() {
100 domain_to_ascii(domain)?
101 } else {
102 domain.to_string()
103 };
104
105 let valid = domain
109 .chars()
110 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_');
111 if !valid {
112 return Err(SeerError::InvalidDomain(domain.to_string()));
113 }
114
115 if domain.contains("..") || domain.starts_with('.') || domain.ends_with('.') {
117 return Err(SeerError::InvalidDomain(domain.to_string()));
118 }
119
120 if domain.len() > 253 {
122 return Err(SeerError::InvalidDomain(domain.to_string()));
123 }
124
125 for label in domain.split('.') {
127 if label.is_empty() || label.starts_with('-') || label.ends_with('-') {
129 return Err(SeerError::InvalidDomain(domain.to_string()));
130 }
131 if label.len() > 63 {
133 return Err(SeerError::InvalidDomain(domain.to_string()));
134 }
135 }
136
137 if let Some(ref allowlist) = *DOMAIN_ALLOWLIST {
139 if !domain_matches_allowlist(&domain, allowlist) {
140 let tld = domain.rsplit('.').next().unwrap_or(&domain).to_string();
141 return Err(SeerError::DomainNotAllowed {
142 domain: domain.to_string(),
143 tld,
144 });
145 }
146 }
147
148 Ok(domain.to_string())
149}
150
151fn domain_matches_allowlist(domain: &str, allowlist: &HashSet<String>) -> bool {
161 allowlist.iter().any(|entry| {
162 if domain == entry.as_str() {
163 return true;
164 }
165 domain.len() > entry.len()
167 && domain.ends_with(entry.as_str())
168 && domain.as_bytes()[domain.len() - entry.len() - 1] == b'.'
169 })
170}
171
172pub(crate) fn domain_to_ascii(domain: &str) -> Result<String> {
174 idna::domain_to_ascii(domain).map_err(|_| {
175 SeerError::InvalidDomain(format!("invalid internationalized domain: {}", domain))
176 })
177}
178
179pub fn is_private_or_reserved_ip(ip: &IpAddr) -> bool {
189 crate::net::is_reserved_ip(*ip)
190}
191
192fn is_private_or_reserved_ipv4(ip: &Ipv4Addr) -> bool {
197 crate::net::is_reserved_ip(IpAddr::V4(*ip))
198}
199
200pub fn describe_reserved_ip(ip: &IpAddr) -> Option<&'static str> {
204 match ip {
205 IpAddr::V4(v4) => {
206 if v4.is_unspecified() {
207 return Some("unspecified address (0.0.0.0) — domain has no routable IP");
208 }
209 if v4.is_loopback() {
210 return Some("loopback address (127.0.0.0/8)");
211 }
212 if v4.is_private() {
213 return Some("private network (RFC 1918)");
214 }
215 if v4.is_link_local() {
216 return Some("link-local address (169.254.0.0/16)");
217 }
218 let o = v4.octets();
219 if o[0] == 169 && o[1] == 254 && o[2] == 169 && o[3] == 254 {
220 return Some("cloud metadata endpoint (169.254.169.254)");
221 }
222 if o[0] == 169 && o[1] == 254 {
223 return Some("link-local address (169.254.0.0/16)");
224 }
225 if (o[0] == 192 && o[1] == 0 && o[2] == 2)
226 || (o[0] == 198 && o[1] == 51 && o[2] == 100)
227 || (o[0] == 203 && o[1] == 0 && o[2] == 113)
228 {
229 return Some("documentation/test range (RFC 5737)");
230 }
231 if v4.is_broadcast() {
232 return Some("broadcast address (255.255.255.255)");
233 }
234 if o[0] >= 224 && o[0] <= 239 {
235 return Some("multicast address (224.0.0.0/4)");
236 }
237 if o[0] >= 240 {
238 return Some("reserved address (240.0.0.0/4)");
239 }
240 if crate::net::is_reserved_ip(IpAddr::V4(*v4)) {
245 return Some("reserved/special-purpose address range");
246 }
247 None
248 }
249 IpAddr::V6(v6) => {
250 if v6.is_loopback() {
251 return Some("IPv6 loopback (::1)");
252 }
253 if v6.is_unspecified() {
254 return Some("IPv6 unspecified address (::) — domain has no routable IP");
255 }
256 let seg = v6.segments();
257 if (seg[0] & 0xfe00) == 0xfc00 {
258 return Some("IPv6 unique local address (fc00::/7)");
259 }
260 if (seg[0] & 0xffc0) == 0xfe80 {
261 return Some("IPv6 link-local address (fe80::/10)");
262 }
263 if (seg[0] & 0xffc0) == 0xfec0 {
264 return Some("IPv6 site-local address (fec0::/10)");
265 }
266 if seg[0] >> 8 == 0xff {
267 return Some("IPv6 multicast (ff00::/8)");
268 }
269 if let Some(v4) = v6.to_ipv4_mapped() {
270 if is_private_or_reserved_ipv4(&v4) {
271 return Some("IPv4-mapped IPv6 address in private/reserved range");
272 }
273 }
274 if crate::net::is_reserved_ip(IpAddr::V6(*v6)) {
277 return Some("reserved/special-purpose IPv6 range");
278 }
279 None
280 }
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use std::net::Ipv6Addr;
288
289 #[test]
290 fn allowlist_entry_idn_is_punycoded() {
291 assert_eq!(normalize_allowlist_entry("münchen.de"), "xn--mnchen-3ya.de");
294 assert_eq!(normalize_allowlist_entry(" Example.COM "), "example.com");
295 }
296
297 #[test]
298 fn allowlist_matches_idn_after_normalization() {
299 let mut set = HashSet::new();
300 set.insert(normalize_allowlist_entry("münchen.de"));
301 assert!(domain_matches_allowlist("xn--mnchen-3ya.de", &set));
303 }
304
305 #[test]
306 fn test_normalize_domain() {
307 assert_eq!(normalize_domain("example.com").unwrap(), "example.com");
308 assert_eq!(normalize_domain("EXAMPLE.COM").unwrap(), "example.com");
309 assert_eq!(
310 normalize_domain("https://www.example.com/path").unwrap(),
311 "example.com"
312 );
313 assert_eq!(
314 normalize_domain("http://example.com/").unwrap(),
315 "example.com"
316 );
317 assert_eq!(
318 normalize_domain(" WWW.EXAMPLE.COM ").unwrap(),
319 "example.com"
320 );
321
322 assert_eq!(
324 normalize_domain("example.com?query=1").unwrap(),
325 "example.com"
326 );
327 assert_eq!(
328 normalize_domain("example.com#section").unwrap(),
329 "example.com"
330 );
331 assert_eq!(
332 normalize_domain("https://example.com/path?q=1#frag").unwrap(),
333 "example.com"
334 );
335
336 assert_eq!(
338 normalize_domain("_dmarc.example.com").unwrap(),
339 "_dmarc.example.com"
340 );
341 assert_eq!(
342 normalize_domain("selector1._domainkey.example.com").unwrap(),
343 "selector1._domainkey.example.com"
344 );
345 assert_eq!(
346 normalize_domain("_sip._tcp.example.com").unwrap(),
347 "_sip._tcp.example.com"
348 );
349
350 assert!(normalize_domain("").is_err());
352 assert!(normalize_domain("nodots").is_err());
353 assert!(normalize_domain("example..com").is_err());
354 assert!(normalize_domain(".example.com").is_err());
355 assert!(normalize_domain("-example.com").is_err());
356 assert!(normalize_domain("example-.com").is_err());
357
358 assert_eq!(
360 normalize_domain("https://example.com:8443/admin").unwrap(),
361 "example.com"
362 );
363 assert_eq!(normalize_domain("example.com:443").unwrap(), "example.com");
364 assert_eq!(
365 normalize_domain("https://user@example.com/").unwrap(),
366 "example.com"
367 );
368 assert_eq!(
369 normalize_domain("https://user:pass@example.com:8443/path").unwrap(),
370 "example.com"
371 );
372
373 assert_eq!(normalize_domain("example.com.").unwrap(), "example.com");
375 assert_eq!(
376 normalize_domain("https://example.com.").unwrap(),
377 "example.com"
378 );
379 assert!(normalize_domain("example.com..").is_err());
382 }
383
384 #[test]
385 fn test_normalize_idn_domain() {
386 let result = normalize_domain("münchen.de").unwrap();
388 assert_eq!(result, "xn--mnchen-3ya.de");
389
390 let result = normalize_domain("例え.jp").unwrap();
392 assert_eq!(result, "xn--r8jz45g.jp");
393
394 let result = normalize_domain("中文.com").unwrap();
396 assert_eq!(result, "xn--fiq228c.com");
397
398 let result = normalize_domain("https://münchen.de/path").unwrap();
400 assert_eq!(result, "xn--mnchen-3ya.de");
401 }
402
403 #[test]
404 fn test_allowlist_not_set_allows_all() {
405 assert!(normalize_domain("example.com").is_ok());
408 assert!(normalize_domain("example.xyz").is_ok());
409 assert!(normalize_domain("example.co.uk").is_ok());
410 }
411
412 #[test]
413 fn allowlist_suffix_matching() {
414 let full: HashSet<String> = ["example.com".to_string()].into_iter().collect();
418 assert!(domain_matches_allowlist("example.com", &full));
419 assert!(domain_matches_allowlist("sub.example.com", &full));
420 assert!(
421 !domain_matches_allowlist("notexample.com", &full),
422 "label boundary"
423 );
424 assert!(!domain_matches_allowlist("example.org", &full));
425
426 let tld: HashSet<String> = ["com".to_string()].into_iter().collect();
427 assert!(
428 domain_matches_allowlist("example.com", &tld),
429 "bare TLD still works"
430 );
431 assert!(domain_matches_allowlist("a.b.com", &tld));
432 assert!(domain_matches_allowlist("com", &tld));
433 assert!(!domain_matches_allowlist("example.org", &tld));
434 assert!(
435 !domain_matches_allowlist("scam", &tld),
436 "must not match mid-label"
437 );
438 }
439
440 #[test]
441 fn test_is_private_or_reserved_ipv4() {
442 assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
444 10, 0, 0, 1
445 ))));
446 assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
447 172, 16, 0, 1
448 ))));
449 assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
450 192, 168, 1, 1
451 ))));
452
453 assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
455 127, 0, 0, 1
456 ))));
457
458 assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
460 169, 254, 1, 1
461 ))));
462
463 assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
465 169, 254, 169, 254
466 ))));
467
468 assert!(!is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
470 8, 8, 8, 8
471 ))));
472 assert!(!is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
473 1, 1, 1, 1
474 ))));
475 }
476
477 #[test]
478 fn test_is_private_or_reserved_ipv6() {
479 assert!(is_private_or_reserved_ip(&IpAddr::V6(Ipv6Addr::new(
481 0, 0, 0, 0, 0, 0, 0, 1
482 ))));
483
484 assert!(is_private_or_reserved_ip(&IpAddr::V6(Ipv6Addr::new(
486 0xfc00, 0, 0, 0, 0, 0, 0, 1
487 ))));
488
489 assert!(is_private_or_reserved_ip(&IpAddr::V6(Ipv6Addr::new(
491 0xfe80, 0, 0, 0, 0, 0, 0, 1
492 ))));
493
494 assert!(!is_private_or_reserved_ip(&IpAddr::V6(Ipv6Addr::new(
496 0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888
497 ))));
498 }
499
500 #[test]
501 fn describe_reserved_ip_agrees_with_net_on_previously_divergent_ranges() {
502 for ip in [
505 "100.64.0.1",
506 "198.18.0.1",
507 "192.0.0.1",
508 "0.1.2.3",
509 "240.0.0.1",
510 ] {
511 let addr: IpAddr = ip.parse().unwrap();
512 assert!(
513 describe_reserved_ip(&addr).is_some(),
514 "{ip} must be reported reserved"
515 );
516 assert!(is_private_or_reserved_ip(&addr), "{ip} bool check");
517 }
518 for ip in ["64:ff9b::a9fe:a9fe", "2002:a9fe:a9fe::", "::a9fe:a9fe"] {
520 let addr: IpAddr = ip.parse().unwrap();
521 assert!(
522 describe_reserved_ip(&addr).is_some(),
523 "{ip} must be reported reserved"
524 );
525 }
526 assert!(describe_reserved_ip(&"8.8.8.8".parse().unwrap()).is_none());
528 }
529}