1use crate::error::{ProxyError, ProxyResult};
11use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
12
13pub fn normalize_domain(input: &str) -> ProxyResult<String> {
34 if input.is_empty() {
35 return Err(invalid("the domain is empty"));
36 }
37 if !input.is_ascii() {
38 return Err(invalid(
39 "the domain contains non-ASCII characters; send an internationalised \
40 name in its punycode (xn--) form",
41 ));
42 }
43 if input.parse::<IpAddr>().is_ok() || input.contains(':') || input.starts_with('[') {
44 return Err(invalid("an IP address is not a domain"));
45 }
46
47 let name = input
48 .strip_suffix('.')
49 .unwrap_or(input)
50 .to_ascii_lowercase();
51 if name.len() > 253 {
52 return Err(invalid("the domain is longer than 253 characters"));
53 }
54
55 let labels: Vec<&str> = name.split('.').collect();
56 if labels.len() < 2 {
57 return Err(invalid(
58 "the domain must have at least two labels (a single label is not a public name)",
59 ));
60 }
61 for label in &labels {
62 check_label(label)?;
63 }
64
65 let last = labels[labels.len() - 1];
66 if is_numeric_label(last) {
67 return Err(invalid(
68 "the domain's last label is numeric, which resolvers read as an IP address",
69 ));
70 }
71
72 Ok(name)
73}
74
75fn check_label(label: &str) -> ProxyResult<()> {
76 if label.is_empty() || label.len() > 63 {
77 return Err(invalid("each label must be 1 to 63 characters"));
78 }
79 if label.starts_with('-') || label.ends_with('-') {
80 return Err(invalid("a label must not start or end with a hyphen"));
81 }
82 if !label
83 .bytes()
84 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
85 {
86 return Err(invalid(
87 "a label may contain only letters, digits and hyphens",
88 ));
89 }
90 Ok(())
91}
92
93fn is_numeric_label(label: &str) -> bool {
95 if label.bytes().all(|b| b.is_ascii_digit()) {
96 return true;
97 }
98 match label.strip_prefix("0x") {
99 Some(hex) => hex.bytes().all(|b| b.is_ascii_hexdigit()),
100 None => false,
101 }
102}
103
104fn invalid(reason: &str) -> ProxyError {
105 ProxyError::Validation(format!("invalid domain: {reason}"))
106}
107
108#[derive(Clone, Debug)]
122pub struct ReservedDomains {
123 exact: Vec<String>,
124 suffixes: Vec<String>,
125}
126
127const SPECIAL_USE: &[&str] = &[
129 "localhost",
130 "local",
131 "localdomain",
132 "internal",
133 "arpa",
134 "invalid",
135 "onion",
136 "alt",
137];
138
139impl Default for ReservedDomains {
140 fn default() -> Self {
141 Self {
142 exact: Vec::new(),
143 suffixes: SPECIAL_USE.iter().map(|s| s.to_string()).collect(),
144 }
145 }
146}
147
148impl ReservedDomains {
149 pub fn none() -> Self {
155 Self {
156 exact: Vec::new(),
157 suffixes: Vec::new(),
158 }
159 }
160
161 pub fn with_exact(mut self, name: &str) -> Self {
166 self.exact.push(normalize_reservation(name));
167 self
168 }
169
170 pub fn with_suffix(mut self, suffix: &str) -> Self {
176 self.suffixes.push(normalize_reservation(suffix));
177 self
178 }
179
180 pub fn is_reserved(&self, normalized: &str) -> bool {
182 self.exact.iter().any(|e| e == normalized)
183 || self.suffixes.iter().any(|s| is_under(normalized, s))
184 }
185}
186
187fn normalize_reservation(name: &str) -> String {
192 let name = name.strip_suffix('.').unwrap_or(name).to_ascii_lowercase();
193 assert!(
194 !name.is_empty()
195 && name.is_ascii()
196 && name.split('.').all(|label| check_label(label).is_ok()),
197 "reserved domain {name:?} is not a valid domain name"
198 );
199 name
200}
201
202fn is_under(name: &str, suffix: &str) -> bool {
204 name == suffix
205 || name
206 .strip_suffix(suffix)
207 .is_some_and(|rest| rest.ends_with('.'))
208}
209
210pub fn is_forbidden_address(ip: IpAddr) -> bool {
224 match ip {
225 IpAddr::V4(v4) => is_forbidden_v4(v4),
226 IpAddr::V6(v6) => is_forbidden_v6(v6),
227 }
228}
229
230fn is_forbidden_v4(ip: Ipv4Addr) -> bool {
231 let [a, b, c, _] = ip.octets();
232 a == 0 || a == 10
234 || a == 127
235 || (a == 100 && (64..128).contains(&b)) || (a == 169 && b == 254) || (a == 172 && (16..32).contains(&b))
238 || (a == 192 && b == 168)
239 || (a == 192 && b == 0 && c == 0) || (a == 192 && b == 0 && c == 2) || (a == 198 && (18..20).contains(&b)) || (a == 198 && b == 51 && c == 100) || (a == 203 && b == 0 && c == 113) || a >= 224 }
246
247fn is_forbidden_v6(ip: Ipv6Addr) -> bool {
248 let s = ip.segments();
249
250 if ip.is_unspecified() || ip.is_loopback() {
251 return true;
252 }
253 if let Some(v4) = ip.to_ipv4() {
256 return is_forbidden_v4(v4);
257 }
258 if s[0] == 0x64 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] {
260 return is_forbidden_v4(embedded_v4(s[6], s[7]));
261 }
262 if s[0] == 0x2002 {
264 return is_forbidden_v4(embedded_v4(s[1], s[2]));
265 }
266
267 (s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1) || (s[0] == 0x100 && s[1..4] == [0, 0, 0]) || (s[0] == 0x2001 && s[1] < 0x200)
272 || (s[0] == 0x2001 && s[1] == 0xdb8) || (s[0] & 0xfff0) == 0x3ff0 || (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 || (s[0] & 0xffc0) == 0xfec0 || (s[0] & 0xff00) == 0xff00 }
279
280fn embedded_v4(high: u16, low: u16) -> Ipv4Addr {
281 let [a, b] = high.to_be_bytes();
282 let [c, d] = low.to_be_bytes();
283 Ipv4Addr::new(a, b, c, d)
284}
285
286pub fn validate_backend_address(address: &str) -> ProxyResult<String> {
298 let bad = |reason: &str| ProxyError::Validation(format!("invalid backend address: {reason}"));
299
300 let (host, port) = if let Some(rest) = address.strip_prefix('[') {
301 let (host, after) = rest
302 .split_once(']')
303 .ok_or_else(|| bad("an unterminated IPv6 literal"))?;
304 let port = after
305 .strip_prefix(':')
306 .ok_or_else(|| bad("expected host:port"))?;
307 (host, port)
308 } else {
309 address
310 .rsplit_once(':')
311 .ok_or_else(|| bad("expected host:port"))?
312 };
313
314 let port: u16 = port
315 .parse()
316 .ok()
317 .filter(|p| *p != 0)
318 .ok_or_else(|| bad("the port must be a number from 1 to 65535"))?;
319
320 if let Ok(ip) = host.parse::<IpAddr>() {
321 if is_forbidden_address(ip) {
322 return Err(bad(
323 "the address is loopback, private, link-local or otherwise not public",
324 ));
325 }
326 return Ok(match ip {
327 IpAddr::V4(v4) => format!("{v4}:{port}"),
328 IpAddr::V6(v6) => format!("[{v6}]:{port}"),
329 });
330 }
331 if address.starts_with('[') {
332 return Err(bad("the bracketed host is not an IPv6 address"));
333 }
334
335 let host =
336 normalize_domain(host).map_err(|_| bad("the host is not a valid public hostname"))?;
337 if ReservedDomains::default().is_reserved(&host) {
338 return Err(bad("the host is a special-use name such as localhost"));
339 }
340 Ok(format!("{host}:{port}"))
341}
342
343pub fn validate_scheme(scheme: &str) -> ProxyResult<()> {
345 match scheme {
346 "http" | "https" => Ok(()),
347 _ => Err(ProxyError::Validation(
348 "invalid backend scheme: must be http or https".into(),
349 )),
350 }
351}
352
353const PROTECTED_HEADERS: &[&str] = &[
360 "host",
361 "content-length",
362 "transfer-encoding",
363 "connection",
364 "keep-alive",
365 "proxy-connection",
366 "te",
367 "trailer",
368 "upgrade",
369];
370
371const MAX_HEADERS: usize = 64;
373
374pub fn validate_add_headers<'a>(
376 headers: impl IntoIterator<Item = (&'a String, &'a String)>,
377) -> ProxyResult<()> {
378 let mut count = 0;
379 for (name, value) in headers {
380 count += 1;
381 validate_header_name(name)?;
382 if PROTECTED_HEADERS.contains(&name.to_ascii_lowercase().as_str()) {
383 return Err(ProxyError::Validation(format!(
384 "header {name} is managed by the proxy and cannot be set by a route"
385 )));
386 }
387 if value.len() > 8192 || http::HeaderValue::from_str(value).is_err() {
390 return Err(ProxyError::Validation(format!(
391 "the value for header {name} is not a valid header value"
392 )));
393 }
394 }
395 if count > MAX_HEADERS {
396 return Err(ProxyError::Validation(format!(
397 "at most {MAX_HEADERS} headers may be added"
398 )));
399 }
400 Ok(())
401}
402
403pub fn validate_remove_headers(headers: &[String]) -> ProxyResult<()> {
405 if headers.len() > MAX_HEADERS {
406 return Err(ProxyError::Validation(format!(
407 "at most {MAX_HEADERS} headers may be removed"
408 )));
409 }
410 headers
411 .iter()
412 .try_for_each(|name| validate_header_name(name))
413}
414
415fn validate_header_name(name: &str) -> ProxyResult<()> {
416 if name.len() > 256 || http::HeaderName::from_bytes(name.as_bytes()).is_err() {
417 return Err(ProxyError::Validation(format!(
418 "{name:?} is not a valid header name"
419 )));
420 }
421 Ok(())
422}
423
424pub fn validate_health_check_path(path: &str) -> ProxyResult<()> {
427 if !path.starts_with('/')
428 || path.len() > 500
429 || path
430 .bytes()
431 .any(|b| b.is_ascii_whitespace() || b.is_ascii_control())
432 || !path.is_ascii()
433 {
434 return Err(ProxyError::Validation(
435 "health_check_path must start with / and contain no whitespace or control characters"
436 .into(),
437 ));
438 }
439 Ok(())
440}
441
442pub fn validate_route_match(
448 path_pattern: &str,
449 path_type: &crate::saas::types::DomainPathMatchType,
450 methods: Option<&[String]>,
451) -> ProxyResult<()> {
452 use crate::saas::types::DomainPathMatchType;
453
454 match path_type {
455 DomainPathMatchType::Regex => {
456 if path_pattern.len() > 500
457 || regex::RegexBuilder::new(path_pattern)
458 .size_limit(1 << 20)
459 .build()
460 .is_err()
461 {
462 return Err(ProxyError::Validation(
463 "path_pattern is not a valid regular expression".into(),
464 ));
465 }
466 }
467 DomainPathMatchType::Prefix | DomainPathMatchType::Exact => {
468 validate_health_check_path(path_pattern).map_err(|_| {
469 ProxyError::Validation(
470 "path_pattern must start with / and contain no whitespace or control \
471 characters"
472 .into(),
473 )
474 })?;
475 }
476 }
477
478 for method in methods.unwrap_or_default() {
479 if method.is_empty() || method.len() > 32 || !method.bytes().all(|b| b.is_ascii_uppercase())
480 {
481 return Err(ProxyError::Validation(format!(
482 "{method:?} is not an HTTP method; use the upper-case name, such as GET"
483 )));
484 }
485 }
486 Ok(())
487}
488
489pub fn validate_resource_name(what: &str, name: &str) -> ProxyResult<()> {
491 if name.trim().is_empty() || name.len() > 255 || name.chars().any(char::is_control) {
492 return Err(ProxyError::Validation(format!(
493 "{what} name must be 1 to 255 characters with no control characters"
494 )));
495 }
496 Ok(())
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn a_domain_is_lowercased_and_loses_one_trailing_dot() {
505 assert_eq!(normalize_domain("VICTIM.test").unwrap(), "victim.test");
506 assert_eq!(normalize_domain("Victim.Test.").unwrap(), "victim.test");
507 assert_eq!(
508 normalize_domain("xn--bcher-kva.example").unwrap(),
509 "xn--bcher-kva.example"
510 );
511 }
512
513 #[test]
514 fn two_trailing_dots_are_not_one_name() {
515 assert!(normalize_domain("victim.test..").is_err());
516 }
517
518 #[test]
519 fn non_ascii_is_refused_with_a_pointer_to_punycode() {
520 let err = normalize_domain("bücher.example").unwrap_err().to_string();
521 assert!(err.contains("punycode"), "{err}");
522 assert!(normalize_domain("vіctim.test").is_err());
524 }
525
526 #[test]
527 fn ip_literals_are_refused_in_every_spelling() {
528 for ip in [
529 "127.0.0.1",
530 "10.0.0.1",
531 "::1",
532 "[::1]",
533 "::ffff:127.0.0.1",
534 "127.1",
535 "0x7f.1",
536 "0177.0.0.1",
537 "1.2.3.4.",
538 ] {
539 assert!(normalize_domain(ip).is_err(), "{ip} was accepted");
540 }
541 }
542
543 #[test]
544 fn single_labels_and_ldh_violations_are_refused() {
545 for name in [
546 "",
547 "localhost",
548 "intranet",
549 "-a.test",
550 "a-.test",
551 "a_b.test",
552 "a b.test",
553 "a..test",
554 ".test",
555 "a.test/x",
556 "a.test:443",
557 ] {
558 assert!(normalize_domain(name).is_err(), "{name:?} was accepted");
559 }
560 let long_label = format!("{}.test", "a".repeat(64));
561 assert!(normalize_domain(&long_label).is_err());
562 let long_name = format!("{}.test", vec!["a".repeat(60); 5].join("."));
563 assert!(long_name.len() > 253);
564 assert!(normalize_domain(&long_name).is_err());
565 }
566
567 #[test]
568 fn reserved_names_cover_their_subdomains_on_a_label_boundary() {
569 let reserved = ReservedDomains::default()
570 .with_exact("API.Platform.example")
571 .with_suffix("platform.example");
572 assert!(reserved.is_reserved("platform.example"));
573 assert!(reserved.is_reserved("api.platform.example"));
574 assert!(reserved.is_reserved("a.b.platform.example"));
575 assert!(!reserved.is_reserved("notplatform.example"));
576 assert!(reserved.is_reserved("foo.localhost"));
577 assert!(reserved.is_reserved("metadata.internal"));
578 assert!(!reserved.is_reserved("customer.example"));
579
580 let exact = ReservedDomains::none().with_exact("app.example");
581 assert!(exact.is_reserved("app.example"));
582 assert!(!exact.is_reserved("x.app.example"));
583 }
584
585 #[test]
586 #[should_panic]
587 fn an_invalid_reservation_fails_loudly() {
588 let _ = ReservedDomains::none().with_suffix("bad name");
589 }
590
591 #[test]
592 fn forbidden_addresses() {
593 for ip in [
594 "0.0.0.0",
595 "10.1.2.3",
596 "100.64.0.1",
597 "100.127.255.255",
598 "127.0.0.1",
599 "169.254.169.254",
600 "172.16.0.1",
601 "172.31.255.255",
602 "192.168.1.1",
603 "192.0.0.8",
604 "192.0.2.1",
605 "198.18.0.1",
606 "198.51.100.1",
607 "203.0.113.1",
608 "224.0.0.1",
609 "240.0.0.1",
610 "255.255.255.255",
611 "::",
612 "::1",
613 "::ffff:127.0.0.1",
614 "::ffff:169.254.169.254",
615 "::10.0.0.1",
616 "64:ff9b::a9fe:a9fe",
617 "2002:7f00:1::",
618 "2001::1",
619 "2001:db8::1",
620 "3fff::1",
621 "fc00::1",
622 "fd12:3456::1",
623 "fe80::1",
624 "fec0::1",
625 "ff02::1",
626 "100::1",
627 ] {
628 let parsed: IpAddr = ip.parse().unwrap();
629 assert!(is_forbidden_address(parsed), "{ip} was allowed");
630 }
631 }
632
633 #[test]
634 fn public_addresses_are_allowed() {
635 for ip in [
636 "1.1.1.1",
637 "8.8.8.8",
638 "100.63.255.255",
639 "100.128.0.0",
640 "172.15.255.255",
641 "172.32.0.0",
642 "93.184.215.14",
643 "2606:4700:4700::1111",
644 "::ffff:8.8.8.8",
645 "64:ff9b::808:808",
646 "2002:808:808::",
647 ] {
648 let parsed: IpAddr = ip.parse().unwrap();
649 assert!(!is_forbidden_address(parsed), "{ip} was refused");
650 }
651 }
652
653 #[test]
654 fn backend_addresses() {
655 assert_eq!(
656 validate_backend_address("API.Example.com:8080").unwrap(),
657 "api.example.com:8080"
658 );
659 assert_eq!(
660 validate_backend_address("8.8.8.8:443").unwrap(),
661 "8.8.8.8:443"
662 );
663 assert_eq!(
664 validate_backend_address("[2606:4700::1]:443").unwrap(),
665 "[2606:4700::1]:443"
666 );
667 for bad in [
668 "169.254.169.254:80",
669 "10.0.0.5:5432",
670 "127.0.0.1:8080",
671 "[::1]:80",
672 "[::ffff:127.0.0.1]:80",
673 "localhost:80",
674 "LOCALHOST:80",
675 "db.localhost:80",
676 "metadata.google.internal:80",
677 "backend:80",
678 "2130706433:80",
679 "127.1:80",
680 "example.com",
681 "example.com:0",
682 "example.com:99999",
683 "example.com:http",
684 "[::1:80",
685 "[example.com]:80",
686 "exa mple.com:80",
687 "example.com:80\r\nX: y",
688 ] {
689 assert!(
690 validate_backend_address(bad).is_err(),
691 "{bad:?} was accepted"
692 );
693 }
694 }
695
696 #[test]
697 fn schemes() {
698 assert!(validate_scheme("http").is_ok());
699 assert!(validate_scheme("https").is_ok());
700 assert!(validate_scheme("ftp").is_err());
701 assert!(validate_scheme("HTTP").is_err());
702 assert!(validate_scheme("").is_err());
703 }
704
705 #[test]
706 fn headers() {
707 let ok = std::collections::HashMap::from([("X-Tenant".to_string(), "a b".to_string())]);
708 assert!(validate_add_headers(&ok).is_ok());
709
710 for (name, value) in [
711 ("X-Bad", "a\r\nInjected: yes"),
712 ("X-Bad", "a\nb"),
713 ("X Bad", "v"),
714 ("X-Bad:", "v"),
715 ("", "v"),
716 ("Host", "evil.test"),
717 ("transfer-encoding", "chunked"),
718 ("Content-Length", "0"),
719 ] {
720 let headers = std::collections::HashMap::from([(name.to_string(), value.to_string())]);
721 assert!(
722 validate_add_headers(&headers).is_err(),
723 "{name:?}: {value:?} was accepted"
724 );
725 }
726
727 assert!(validate_remove_headers(&["x-powered-by".into()]).is_ok());
728 assert!(validate_remove_headers(&["bad\r\nname".into()]).is_err());
729 }
730
731 #[test]
732 fn health_check_paths() {
733 assert!(validate_health_check_path("/health").is_ok());
734 assert!(validate_health_check_path("/health?full=1").is_ok());
735 for bad in ["health", "", "/a b", "/a\r\nHost: x", "/a\tb", "http://x/"] {
736 assert!(
737 validate_health_check_path(bad).is_err(),
738 "{bad:?} was accepted"
739 );
740 }
741 }
742
743 #[test]
744 fn route_matches() {
745 use crate::saas::types::DomainPathMatchType::*;
746 assert!(validate_route_match("/api", &Prefix, Some(&["GET".into()])).is_ok());
747 assert!(validate_route_match("^/v[0-9]+/", &Regex, None).is_ok());
748 assert!(validate_route_match("api", &Prefix, None).is_err());
749 assert!(validate_route_match("(", &Regex, None).is_err());
750 assert!(validate_route_match("/", &Exact, Some(&["get".into()])).is_err());
751 assert!(validate_route_match("/", &Exact, Some(&["GET\r\n".into()])).is_err());
752 }
753}