1use std::collections::HashSet;
4use std::net::IpAddr;
5
6use std::sync::LazyLock;
7
8use crate::error::{Result, SeerError};
9
10static DOMAIN_ALLOWLIST: LazyLock<Option<HashSet<String>>> = LazyLock::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> {
65 normalize(domain, true)
66}
67
68pub fn normalize_host(host: &str) -> Result<String> {
76 normalize(host, false)
77}
78
79fn normalize(domain: &str, strip_www: bool) -> Result<String> {
80 let domain = domain.trim().to_lowercase();
81
82 let domain = domain
84 .strip_prefix("http://")
85 .or_else(|| domain.strip_prefix("https://"))
86 .unwrap_or(&domain);
87
88 let domain = domain.split('/').next().unwrap_or(domain);
90 let domain = domain.split('?').next().unwrap_or(domain);
91 let domain = domain.split('#').next().unwrap_or(domain);
92
93 let domain = domain.rsplit('@').next().unwrap_or(domain);
97
98 let domain = match domain.rsplit_once(':') {
102 Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
103 _ => domain,
104 };
105
106 let domain = domain.strip_suffix('.').unwrap_or(domain);
112
113 let domain = match domain.strip_prefix("www.") {
117 Some(rest) if strip_www && rest.contains('.') => rest,
118 _ => domain,
119 };
120
121 if domain.is_empty() || !domain.contains('.') {
123 return Err(SeerError::InvalidDomain(domain.to_string()));
124 }
125
126 let domain = if !domain.is_ascii() {
128 domain_to_ascii(domain)?
129 } else {
130 domain.to_string()
131 };
132
133 let valid = domain
137 .chars()
138 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_');
139 if !valid {
140 return Err(SeerError::InvalidDomain(domain.to_string()));
141 }
142
143 if domain.contains("..") || domain.starts_with('.') || domain.ends_with('.') {
145 return Err(SeerError::InvalidDomain(domain.to_string()));
146 }
147
148 if domain.len() > 253 {
150 return Err(SeerError::InvalidDomain(domain.to_string()));
151 }
152
153 for label in domain.split('.') {
155 if label.is_empty() || label.starts_with('-') || label.ends_with('-') {
157 return Err(SeerError::InvalidDomain(domain.to_string()));
158 }
159 if label.len() > 63 {
161 return Err(SeerError::InvalidDomain(domain.to_string()));
162 }
163 }
164
165 if let Some(ref allowlist) = *DOMAIN_ALLOWLIST {
167 if !domain_matches_allowlist(&domain, allowlist) {
168 let tld = domain.rsplit('.').next().unwrap_or(&domain).to_string();
169 return Err(SeerError::DomainNotAllowed {
170 domain: domain.to_string(),
171 tld,
172 });
173 }
174 }
175
176 Ok(domain.to_string())
177}
178
179fn domain_matches_allowlist(domain: &str, allowlist: &HashSet<String>) -> bool {
189 allowlist.iter().any(|entry| {
190 if domain == entry.as_str() {
191 return true;
192 }
193 domain.len() > entry.len()
195 && domain.ends_with(entry.as_str())
196 && domain.as_bytes()[domain.len() - entry.len() - 1] == b'.'
197 })
198}
199
200pub(crate) fn domain_to_ascii(domain: &str) -> Result<String> {
202 idna::domain_to_ascii(domain).map_err(|_| {
203 SeerError::InvalidDomain(format!("invalid internationalized domain: {}", domain))
204 })
205}
206
207pub fn describe_reserved_ip(ip: &IpAddr) -> Option<&'static str> {
212 match ip {
213 IpAddr::V4(v4) => {
214 if v4.is_unspecified() {
215 return Some("unspecified address (0.0.0.0) — domain has no routable IP");
216 }
217 if v4.is_loopback() {
218 return Some("loopback address (127.0.0.0/8)");
219 }
220 if v4.is_private() {
221 return Some("private network (RFC 1918)");
222 }
223 let o = v4.octets();
227 if o[0] == 169 && o[1] == 254 && o[2] == 169 && o[3] == 254 {
228 return Some("cloud metadata endpoint (169.254.169.254)");
229 }
230 if v4.is_link_local() {
231 return Some("link-local address (169.254.0.0/16)");
232 }
233 if (o[0] == 192 && o[1] == 0 && o[2] == 2)
234 || (o[0] == 198 && o[1] == 51 && o[2] == 100)
235 || (o[0] == 203 && o[1] == 0 && o[2] == 113)
236 {
237 return Some("documentation/test range (RFC 5737)");
238 }
239 if v4.is_broadcast() {
240 return Some("broadcast address (255.255.255.255)");
241 }
242 if o[0] >= 224 && o[0] <= 239 {
243 return Some("multicast address (224.0.0.0/4)");
244 }
245 if o[0] >= 240 {
246 return Some("reserved address (240.0.0.0/4)");
247 }
248 if crate::net::is_reserved_ip(IpAddr::V4(*v4)) {
253 return Some("reserved/special-purpose address range");
254 }
255 None
256 }
257 IpAddr::V6(v6) => {
258 if v6.is_loopback() {
259 return Some("IPv6 loopback (::1)");
260 }
261 if v6.is_unspecified() {
262 return Some("IPv6 unspecified address (::) — domain has no routable IP");
263 }
264 let seg = v6.segments();
265 if (seg[0] & 0xfe00) == 0xfc00 {
266 return Some("IPv6 unique local address (fc00::/7)");
267 }
268 if (seg[0] & 0xffc0) == 0xfe80 {
269 return Some("IPv6 link-local address (fe80::/10)");
270 }
271 if (seg[0] & 0xffc0) == 0xfec0 {
272 return Some("IPv6 site-local address (fec0::/10)");
273 }
274 if seg[0] >> 8 == 0xff {
275 return Some("IPv6 multicast (ff00::/8)");
276 }
277 if let Some(v4) = v6.to_ipv4_mapped() {
278 if crate::net::is_reserved_ip(IpAddr::V4(v4)) {
279 return Some("IPv4-mapped IPv6 address in private/reserved range");
280 }
281 }
282 if crate::net::is_reserved_ip(IpAddr::V6(*v6)) {
285 return Some("reserved/special-purpose IPv6 range");
286 }
287 None
288 }
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn allowlist_entry_idn_is_punycoded() {
298 assert_eq!(normalize_allowlist_entry("münchen.de"), "xn--mnchen-3ya.de");
301 assert_eq!(normalize_allowlist_entry(" Example.COM "), "example.com");
302 }
303
304 #[test]
305 fn allowlist_matches_idn_after_normalization() {
306 let mut set = HashSet::new();
307 set.insert(normalize_allowlist_entry("münchen.de"));
308 assert!(domain_matches_allowlist("xn--mnchen-3ya.de", &set));
310 }
311
312 #[test]
313 fn test_normalize_domain() {
314 assert_eq!(normalize_domain("example.com").unwrap(), "example.com");
315 assert_eq!(normalize_domain("EXAMPLE.COM").unwrap(), "example.com");
316 assert_eq!(
317 normalize_domain("https://www.example.com/path").unwrap(),
318 "example.com"
319 );
320 assert_eq!(
321 normalize_domain("http://example.com/").unwrap(),
322 "example.com"
323 );
324 assert_eq!(
325 normalize_domain(" WWW.EXAMPLE.COM ").unwrap(),
326 "example.com"
327 );
328
329 assert_eq!(
331 normalize_domain("example.com?query=1").unwrap(),
332 "example.com"
333 );
334 assert_eq!(
335 normalize_domain("example.com#section").unwrap(),
336 "example.com"
337 );
338 assert_eq!(
339 normalize_domain("https://example.com/path?q=1#frag").unwrap(),
340 "example.com"
341 );
342
343 assert_eq!(
345 normalize_domain("_dmarc.example.com").unwrap(),
346 "_dmarc.example.com"
347 );
348 assert_eq!(
349 normalize_domain("selector1._domainkey.example.com").unwrap(),
350 "selector1._domainkey.example.com"
351 );
352 assert_eq!(
353 normalize_domain("_sip._tcp.example.com").unwrap(),
354 "_sip._tcp.example.com"
355 );
356
357 assert!(normalize_domain("").is_err());
359 assert!(normalize_domain("nodots").is_err());
360 assert!(normalize_domain("example..com").is_err());
361 assert!(normalize_domain(".example.com").is_err());
362 assert!(normalize_domain("-example.com").is_err());
363 assert!(normalize_domain("example-.com").is_err());
364
365 assert_eq!(
367 normalize_domain("https://example.com:8443/admin").unwrap(),
368 "example.com"
369 );
370 assert_eq!(normalize_domain("example.com:443").unwrap(), "example.com");
371 assert_eq!(
372 normalize_domain("https://user@example.com/").unwrap(),
373 "example.com"
374 );
375 assert_eq!(
376 normalize_domain("https://user:pass@example.com:8443/path").unwrap(),
377 "example.com"
378 );
379
380 assert_eq!(normalize_domain("example.com.").unwrap(), "example.com");
382 assert_eq!(
383 normalize_domain("https://example.com.").unwrap(),
384 "example.com"
385 );
386 assert!(normalize_domain("example.com..").is_err());
389 }
390
391 #[test]
392 fn www_is_not_stripped_when_it_would_leave_a_bare_tld() {
393 assert_eq!(normalize_domain("www.com").unwrap(), "www.com");
396 assert_eq!(normalize_domain("WWW.NET.").unwrap(), "www.net");
397 assert_eq!(normalize_domain("https://www.io/x").unwrap(), "www.io");
398 assert_eq!(normalize_domain("www.example.com.").unwrap(), "example.com");
400 }
401
402 #[test]
403 fn normalize_host_keeps_www() {
404 assert_eq!(
407 normalize_host("HTTPS://WWW.Example.com:443/path").unwrap(),
408 "www.example.com"
409 );
410 assert_eq!(normalize_host("www.com").unwrap(), "www.com");
411 assert_eq!(
413 normalize_host("sub.example.com.").unwrap(),
414 "sub.example.com"
415 );
416 assert_eq!(normalize_host("münchen.de").unwrap(), "xn--mnchen-3ya.de");
417 assert!(normalize_host("nodots").is_err());
418 assert!(normalize_host("bad_label-.com").is_err());
419 }
420
421 #[test]
422 fn describe_reserved_ip_names_the_metadata_endpoint() {
423 assert_eq!(
425 describe_reserved_ip(&"169.254.169.254".parse().unwrap()),
426 Some("cloud metadata endpoint (169.254.169.254)")
427 );
428 assert_eq!(
429 describe_reserved_ip(&"169.254.1.1".parse().unwrap()),
430 Some("link-local address (169.254.0.0/16)")
431 );
432 }
433
434 #[test]
435 fn test_normalize_idn_domain() {
436 let result = normalize_domain("münchen.de").unwrap();
438 assert_eq!(result, "xn--mnchen-3ya.de");
439
440 let result = normalize_domain("例え.jp").unwrap();
442 assert_eq!(result, "xn--r8jz45g.jp");
443
444 let result = normalize_domain("中文.com").unwrap();
446 assert_eq!(result, "xn--fiq228c.com");
447
448 let result = normalize_domain("https://münchen.de/path").unwrap();
450 assert_eq!(result, "xn--mnchen-3ya.de");
451
452 assert_eq!(
454 normalize_domain("пример.рф").unwrap(),
455 "xn--e1afmkfd.xn--p1ai"
456 );
457 assert_eq!(
458 normalize_domain("ПРИМЕР.РФ").unwrap(),
459 "xn--e1afmkfd.xn--p1ai"
460 );
461
462 assert_eq!(
464 normalize_domain("xn--e1afmkfd.xn--p1ai").unwrap(),
465 "xn--e1afmkfd.xn--p1ai"
466 );
467 }
468
469 #[test]
470 fn test_allowlist_not_set_allows_all() {
471 assert!(normalize_domain("example.com").is_ok());
474 assert!(normalize_domain("example.xyz").is_ok());
475 assert!(normalize_domain("example.co.uk").is_ok());
476 }
477
478 #[test]
479 fn allowlist_suffix_matching() {
480 let full: HashSet<String> = ["example.com".to_string()].into_iter().collect();
484 assert!(domain_matches_allowlist("example.com", &full));
485 assert!(domain_matches_allowlist("sub.example.com", &full));
486 assert!(
487 !domain_matches_allowlist("notexample.com", &full),
488 "label boundary"
489 );
490 assert!(!domain_matches_allowlist("example.org", &full));
491
492 let tld: HashSet<String> = ["com".to_string()].into_iter().collect();
493 assert!(
494 domain_matches_allowlist("example.com", &tld),
495 "bare TLD still works"
496 );
497 assert!(domain_matches_allowlist("a.b.com", &tld));
498 assert!(domain_matches_allowlist("com", &tld));
499 assert!(!domain_matches_allowlist("example.org", &tld));
500 assert!(
501 !domain_matches_allowlist("scam", &tld),
502 "must not match mid-label"
503 );
504 }
505
506 #[test]
507 fn reserved_ip_check_blocks_private_ranges_only() {
508 for ip in [
509 "10.0.0.1",
510 "172.16.0.1",
511 "192.168.1.1",
512 "127.0.0.1",
513 "169.254.1.1",
514 "169.254.169.254",
515 "::1",
516 "fc00::1",
517 "fe80::1",
518 ] {
519 assert!(crate::net::is_reserved_ip(ip.parse().unwrap()), "{ip}");
520 }
521 for ip in ["8.8.8.8", "1.1.1.1", "2001:4860:4860::8888"] {
522 assert!(!crate::net::is_reserved_ip(ip.parse().unwrap()), "{ip}");
523 }
524 }
525
526 #[test]
527 fn describe_reserved_ip_agrees_with_net_on_previously_divergent_ranges() {
528 for ip in [
531 "100.64.0.1",
532 "198.18.0.1",
533 "192.0.0.1",
534 "0.1.2.3",
535 "240.0.0.1",
536 ] {
537 let addr: IpAddr = ip.parse().unwrap();
538 assert!(
539 describe_reserved_ip(&addr).is_some(),
540 "{ip} must be reported reserved"
541 );
542 assert!(crate::net::is_reserved_ip(addr), "{ip} bool check");
543 }
544 for ip in ["64:ff9b::a9fe:a9fe", "2002:a9fe:a9fe::", "::a9fe:a9fe"] {
546 let addr: IpAddr = ip.parse().unwrap();
547 assert!(
548 describe_reserved_ip(&addr).is_some(),
549 "{ip} must be reported reserved"
550 );
551 }
552 assert!(describe_reserved_ip(&"8.8.8.8".parse().unwrap()).is_none());
554 }
555}