Skip to main content

postrust_proxy/saas/
validation.rs

1//! Checks on what tenants send, before any of it is stored.
2//!
3//! Everything a tenant names -- a domain, a backend address, a header -- ends
4//! up somewhere it is trusted: a unique index, a certificate, a connection the
5//! proxy opens on the tenant's behalf. The rules live here, in one place, so
6//! that every path a value can enter by applies the same ones. Two spellings of
7//! one rule is how `VICTIM.test` came to be registrable alongside
8//! `victim.test`.
9
10use crate::error::{ProxyError, ProxyResult};
11use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
12
13/// The canonical form of a domain name, or why it cannot be one.
14///
15/// Lowercased, with one trailing dot removed, and then held to the LDH rule
16/// (letters, digits, hyphen) that every name a certificate authority will issue
17/// for, and every name a `Host` header can carry unencoded, obeys:
18///
19/// - ASCII only. An internationalised name has to arrive in its punycode
20///   (`xn--`) form. Accepting Unicode would mean picking one of several
21///   incompatible normalisations, and two tenants could then register names
22///   that render alike and compare different.
23/// - at least two labels. A single label resolves through the resolver's search
24///   list, which makes it a name for something inside the network, not on it.
25/// - each label 1 to 63 characters, not starting or ending with a hyphen; the
26///   whole at most 253.
27/// - not an IP address, in any spelling a resolver will accept. `127.1` and
28///   `0x7f.1` are IPv4 to `inet_aton`, so a name whose last label is numeric
29///   (or `0x` hex) is refused: no real top-level domain is.
30///
31/// Every place a domain enters the system goes through this, so the stored
32/// form is the compared form.
33pub 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
93/// Digits, or `0x` followed by hex digits: what `inet_aton` reads as a number.
94fn 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/// Names that are never a tenant's to claim.
109///
110/// The platform's own hostnames belong here -- the API's, the dashboard's, the
111/// one the proxy's TLS listener answers for itself. Registration of these is
112/// refused before verification is even attempted: an unverified row for the
113/// platform's name is harmless only for as long as every later check remembers
114/// to look at `verification_status`, and that is one forgotten check away from
115/// a tenant's certificate being served for it.
116///
117/// [`Default`] reserves the special-use names that never denote a public host
118/// (RFC 6761, 6762, 7686, 8375, 9476): `localhost`, `local`, `internal`,
119/// `arpa`, `invalid`, `onion`, `alt`, and `localdomain`. Add the platform's own
120/// with [`with_exact`](Self::with_exact) and [`with_suffix`](Self::with_suffix).
121#[derive(Clone, Debug)]
122pub struct ReservedDomains {
123    exact: Vec<String>,
124    suffixes: Vec<String>,
125}
126
127/// Special-use names. Suffixes, so `foo.localhost` is refused with `localhost`.
128const 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    /// Nothing reserved at all, not even the special-use names.
150    ///
151    /// For tests and for embedders that do their own checking. The special-use
152    /// names cannot be verified anyway, so leaving them out costs little, but
153    /// there is no reason to.
154    pub fn none() -> Self {
155        Self {
156            exact: Vec::new(),
157            suffixes: Vec::new(),
158        }
159    }
160
161    /// Reserve exactly this name, and not its subdomains.
162    ///
163    /// Panics if `name` is not a valid domain: a reservation that silently
164    /// matches nothing is worse than a startup failure.
165    pub fn with_exact(mut self, name: &str) -> Self {
166        self.exact.push(normalize_reservation(name));
167        self
168    }
169
170    /// Reserve this name and every name under it.
171    ///
172    /// `with_suffix("example.com")` reserves `example.com` and
173    /// `api.example.com`, not `notexample.com`. Panics on an invalid name, as
174    /// [`with_exact`](Self::with_exact) does.
175    pub fn with_suffix(mut self, suffix: &str) -> Self {
176        self.suffixes.push(normalize_reservation(suffix));
177        self
178    }
179
180    /// Whether a name, already in [`normalize_domain`] form, is reserved.
181    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
187/// Lowercase and strip a trailing dot, for a reservation.
188///
189/// Not [`normalize_domain`]: single-label suffixes (`internal`) are exactly
190/// what that refuses, and are legitimate here.
191fn 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
202/// `name` is `suffix` or a subdomain of it, on a label boundary.
203fn 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
210/// Whether the proxy must refuse to connect to this address on a tenant's
211/// behalf.
212///
213/// True for everything that is not ordinary public unicast: loopback, private
214/// (RFC 1918, and ULA for IPv6), link-local -- which includes
215/// `169.254.169.254`, every cloud's metadata service -- CGNAT, unspecified,
216/// multicast, broadcast, the documentation and benchmarking ranges, and the
217/// IETF-reserved blocks. IPv6 forms that carry an IPv4 address (mapped,
218/// compatible, NAT64, 6to4) are judged by the address they carry, since that is
219/// where the packet ends up.
220///
221/// Shared by domain verification, which connects, and backend validation,
222/// which stores an address something will connect to later.
223pub 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 // "this network", including 0.0.0.0
233        || a == 10
234        || a == 127
235        || (a == 100 && (64..128).contains(&b)) // CGNAT, 100.64.0.0/10
236        || (a == 169 && b == 254) // link-local, and the metadata service
237        || (a == 172 && (16..32).contains(&b))
238        || (a == 192 && b == 168)
239        || (a == 192 && b == 0 && c == 0) // IETF protocol assignments
240        || (a == 192 && b == 0 && c == 2) // TEST-NET-1
241        || (a == 198 && (18..20).contains(&b)) // benchmarking, 198.18.0.0/15
242        || (a == 198 && b == 51 && c == 100) // TEST-NET-2
243        || (a == 203 && b == 0 && c == 113) // TEST-NET-3
244        || a >= 224 // multicast, reserved, and 255.255.255.255
245}
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    // ::ffff:a.b.c.d (mapped) and ::a.b.c.d (the deprecated compatible form):
254    // the packet goes to the IPv4 address, so judge that.
255    if let Some(v4) = ip.to_ipv4() {
256        return is_forbidden_v4(v4);
257    }
258    // NAT64, 64:ff9b::/96: the gateway forwards to the embedded address.
259    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    // 6to4, 2002::/16: the relay forwards to the address in bits 16..48.
263    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) // local-use NAT64, 64:ff9b:1::/48
268        || (s[0] == 0x100 && s[1..4] == [0, 0, 0]) // discard-only, 100::/64
269        // IETF protocol assignments, 2001::/23. Includes Teredo (2001::/32),
270        // which tunnels to an address this cannot judge, and benchmarking.
271        || (s[0] == 0x2001 && s[1] < 0x200)
272        || (s[0] == 0x2001 && s[1] == 0xdb8) // documentation
273        || (s[0] & 0xfff0) == 0x3ff0 // documentation, 3fff::/20 (RFC 9637)
274        || (s[0] & 0xfe00) == 0xfc00 // unique local, fc00::/7
275        || (s[0] & 0xffc0) == 0xfe80 // link-local, fe80::/10
276        || (s[0] & 0xffc0) == 0xfec0 // site-local (deprecated), fec0::/10
277        || (s[0] & 0xff00) == 0xff00 // multicast
278}
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
286/// Check a tenant backend's `host:port`, returning it in canonical form.
287///
288/// Refuses an IP literal in any [forbidden range](is_forbidden_address),
289/// `localhost` and the other special-use names, single-label names (which
290/// resolve through the search list to hosts inside the network), and anything
291/// that is not a valid hostname. A port is required.
292///
293/// A hostname is checked only for what it says, not for what it resolves to:
294/// resolution at write time proves nothing about resolution at connect time,
295/// so whatever eventually connects to a tenant backend must check the address
296/// it actually connects to, with [`is_forbidden_address`].
297pub 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
343/// Check a backend's scheme. Only `http` and `https` mean anything.
344pub 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
353/// Headers a tenant may not set on a forwarded request.
354///
355/// Each of these changes how the request is framed or routed rather than what
356/// it says -- a second `Content-Length` or a `Transfer-Encoding` is the raw
357/// material of request smuggling, and `Host` decides which upstream virtual
358/// host answers. They belong to the proxy.
359const 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
371/// The most headers a route may add or remove. Generous; there to bound the row.
372const MAX_HEADERS: usize = 64;
373
374/// Check headers a route adds: valid names and values, none of them protected.
375pub 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        // `HeaderValue::from_str` refuses every control character but tab,
388        // which is what keeps a CR or LF from splitting the header.
389        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
403/// Check header names a route removes.
404pub 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
424/// Check an upstream's health-check path: absolute, and nothing that could
425/// break out of the request line.
426pub 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
442/// Check a route's path pattern and methods.
443///
444/// A prefix or exact pattern is a path, so it gets the health-check path rules.
445/// A regex has to compile -- with a size limit, so that a pattern which would
446/// take the matcher megabytes is refused here rather than at match time.
447pub 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
489/// Check a name a tenant gives a resource: present, and fits its column.
490pub 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        // Cyrillic 'і', which renders like the Latin one.
523        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}