Skip to main content

mcpls_core/transport/
allowlist.rs

1//! `Host` and `Origin` allowlist types for the HTTP transport's DNS-rebinding
2//! protection.
3
4/// The loopback host names a loopback-bound server answers to.
5const LOOPBACK_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "[::1]"];
6
7/// Why a string is not a valid [`HttpPath`].
8///
9/// # Examples
10///
11/// ```
12/// use mcpls_core::{HttpPath, InvalidHttpPath};
13///
14/// assert_eq!("mcp".parse::<HttpPath>(), Err(InvalidHttpPath::MissingLeadingSlash));
15/// ```
16#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum InvalidHttpPath {
19    /// The path is empty or does not start with `/`.
20    #[error("must start with `/`")]
21    MissingLeadingSlash,
22    /// The path is exactly `/`; the service already answers at the root.
23    #[error("must not be `/`: the service already answers at the root path")]
24    Root,
25    /// The path contains `//` or ends with `/`.
26    #[error("must not contain an empty segment (`//` or a trailing `/`)")]
27    EmptySegment,
28    /// The path contains a `.` or `..` segment.
29    #[error("must not contain a `.` or `..` segment")]
30    DotSegment,
31    /// The path contains a character outside `A-Z a-z 0-9 - . _ ~`.
32    #[error("contains `{0}`; only ASCII letters, digits and `-._~` are allowed in a segment")]
33    InvalidCharacter(char),
34}
35
36/// URL path the MCP service is mounted at, validated so mounting it cannot
37/// panic.
38///
39/// Starts with `/`, is not `/` itself, has no empty or dot segments, and each
40/// segment uses only ASCII letters, digits and `-._~`. Wildcard, parameter
41/// and percent-encoded forms are rejected rather than normalized.
42///
43/// # Examples
44///
45/// ```
46/// use mcpls_core::{HttpPath, InvalidHttpPath};
47///
48/// assert_eq!("/api/mcp".parse::<HttpPath>().unwrap().as_str(), "/api/mcp");
49/// assert_eq!(HttpPath::default().as_str(), "/mcp");
50/// assert_eq!("/".parse::<HttpPath>(), Err(InvalidHttpPath::Root));
51/// assert_eq!("/a/{id}".parse::<HttpPath>(), Err(InvalidHttpPath::InvalidCharacter('{')));
52/// ```
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct HttpPath(Box<str>);
55
56impl HttpPath {
57    /// The validated path, starting with `/`.
58    #[must_use]
59    pub fn as_str(&self) -> &str {
60        &self.0
61    }
62}
63
64impl Default for HttpPath {
65    fn default() -> Self {
66        Self("/mcp".into())
67    }
68}
69
70impl std::fmt::Display for HttpPath {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.write_str(&self.0)
73    }
74}
75
76impl std::str::FromStr for HttpPath {
77    type Err = InvalidHttpPath;
78
79    fn from_str(s: &str) -> Result<Self, Self::Err> {
80        let rest = s
81            .strip_prefix('/')
82            .ok_or(InvalidHttpPath::MissingLeadingSlash)?;
83        if rest.is_empty() {
84            return Err(InvalidHttpPath::Root);
85        }
86        for segment in rest.split('/') {
87            if segment.is_empty() {
88                return Err(InvalidHttpPath::EmptySegment);
89            }
90            if matches!(segment, "." | "..") {
91                return Err(InvalidHttpPath::DotSegment);
92            }
93            if let Some(c) = segment
94                .chars()
95                .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | '~')))
96            {
97                return Err(InvalidHttpPath::InvalidCharacter(c));
98            }
99        }
100        Ok(Self(s.into()))
101    }
102}
103
104/// Why a string is not a valid [`AllowedOrigin`].
105///
106/// # Examples
107///
108/// ```
109/// use mcpls_core::{AllowedOrigin, InvalidAllowedOrigin};
110///
111/// assert_eq!("*".parse::<AllowedOrigin>(), Err(InvalidAllowedOrigin::Wildcard));
112/// assert_eq!("null".parse::<AllowedOrigin>(), Err(InvalidAllowedOrigin::Null));
113/// ```
114#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
115#[non_exhaustive]
116pub enum InvalidAllowedOrigin {
117    /// The string is not a URI, or its authority is not a host with an
118    /// optional port (an IPv6 host needs brackets).
119    #[error("not a valid origin")]
120    Malformed,
121    /// `null`, the origin of sandboxed and opaque contexts.
122    #[error("`null` is not an origin")]
123    Null,
124    /// A wildcard such as `*` or `:*`.
125    #[error("wildcards are not allowed")]
126    Wildcard,
127    /// The scheme is missing or is neither `http` nor `https`.
128    #[error("the scheme must be `http` or `https`")]
129    UnsupportedScheme,
130    /// The authority carries `user:password@`.
131    #[error("user information is not allowed")]
132    UserInfo,
133    /// The host is empty.
134    #[error("the host is missing")]
135    MissingHost,
136    /// A path other than `/`.
137    #[error("a path is not allowed")]
138    Path,
139    /// A query string.
140    #[error("a query is not allowed")]
141    Query,
142}
143
144/// `http` or `https`, the schemes a browser page can have.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum OriginScheme {
147    Http,
148    Https,
149}
150
151impl OriginScheme {
152    const fn default_port(self) -> u16 {
153        match self {
154            Self::Http => 80,
155            Self::Https => 443,
156        }
157    }
158
159    const fn as_str(self) -> &'static str {
160        match self {
161            Self::Http => "http",
162            Self::Https => "https",
163        }
164    }
165}
166
167/// A browser origin allowed to reach the HTTP transport, validated so it
168/// round-trips through `rmcp`'s allowlist.
169///
170/// `rmcp` silently drops allowlist entries it cannot parse, so an invalid
171/// string would leave the origin blocked with no error. This type is built
172/// only from `http://` or `https://` origins with a host and no userinfo,
173/// path or query. The host is lowercased, IPv6 hosts keep their brackets, and
174/// a missing port becomes the scheme default, and a port that is not a
175/// number up to 65535 is rejected, so every entry names exactly one
176/// `(scheme, host, port)`. Surrounding whitespace is ignored.
177///
178/// # Examples
179///
180/// ```
181/// use mcpls_core::AllowedOrigin;
182///
183/// let origin: AllowedOrigin = "https://App.Example.com".parse().unwrap();
184/// assert_eq!(origin.to_string(), "https://app.example.com:443");
185/// assert_eq!("http://[::1]:8080".parse::<AllowedOrigin>().unwrap().to_string(), "http://[::1]:8080");
186/// assert!("https://example.com/path".parse::<AllowedOrigin>().is_err());
187/// ```
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct AllowedOrigin {
190    scheme: OriginScheme,
191    host: Box<str>,
192    port: u16,
193}
194
195impl AllowedOrigin {
196    /// The origins of a page served by a loopback listener on `port`:
197    /// `localhost`, `127.0.0.1` and `[::1]`.
198    ///
199    /// # Examples
200    ///
201    /// ```
202    /// use mcpls_core::AllowedOrigin;
203    ///
204    /// let origins = AllowedOrigin::loopback(3000);
205    /// assert_eq!(origins[1].to_string(), "http://127.0.0.1:3000");
206    /// ```
207    #[must_use]
208    pub fn loopback(port: u16) -> [Self; 3] {
209        LOOPBACK_HOSTS.map(|host| Self {
210            scheme: OriginScheme::Http,
211            host: host.into(),
212            port,
213        })
214    }
215}
216
217impl std::fmt::Display for AllowedOrigin {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        write!(f, "{}://{}:{}", self.scheme.as_str(), self.host, self.port)
220    }
221}
222
223impl std::str::FromStr for AllowedOrigin {
224    type Err = InvalidAllowedOrigin;
225
226    fn from_str(s: &str) -> Result<Self, Self::Err> {
227        use axum::http::Uri;
228
229        let s = s.trim();
230        if s.eq_ignore_ascii_case("null") {
231            return Err(InvalidAllowedOrigin::Null);
232        }
233        if s.contains('*') {
234            return Err(InvalidAllowedOrigin::Wildcard);
235        }
236        let uri: Uri = s.parse().map_err(|_| InvalidAllowedOrigin::Malformed)?;
237        let scheme = match uri.scheme_str() {
238            Some("http") => OriginScheme::Http,
239            Some("https") => OriginScheme::Https,
240            _ => return Err(InvalidAllowedOrigin::UnsupportedScheme),
241        };
242        let authority = uri.authority().ok_or(InvalidAllowedOrigin::Malformed)?;
243        if authority.as_str().contains('@') {
244            return Err(InvalidAllowedOrigin::UserInfo);
245        }
246        if uri.path() != "/" {
247            return Err(InvalidAllowedOrigin::Path);
248        }
249        if uri.query().is_some() {
250            return Err(InvalidAllowedOrigin::Query);
251        }
252        let host = authority.host();
253        if host.is_empty() {
254            return Err(InvalidAllowedOrigin::MissingHost);
255        }
256        let port = match AuthorityPort::of(authority).ok_or(InvalidAllowedOrigin::Malformed)? {
257            AuthorityPort::Absent | AuthorityPort::Empty => scheme.default_port(),
258            AuthorityPort::Number(port) => port,
259        };
260        Ok(Self {
261            scheme,
262            host: host.to_ascii_lowercase().into(),
263            port,
264        })
265    }
266}
267
268/// The port part of an [`axum::http::uri::Authority`].
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270enum AuthorityPort {
271    /// No `:` after the host.
272    Absent,
273    /// A `:` with no digits after it.
274    Empty,
275    /// A decimal port that fits in `u16`.
276    Number(u16),
277}
278
279impl AuthorityPort {
280    /// `None` when the text after the host is not `:` followed by decimal
281    /// digits of a `u16`.
282    fn of(authority: &axum::http::uri::Authority) -> Option<Self> {
283        match authority.as_str().get(authority.host().len()..)? {
284            "" => Some(Self::Absent),
285            ":" => Some(Self::Empty),
286            suffix => suffix
287                .strip_prefix(':')
288                .filter(|digits| digits.bytes().all(|b| b.is_ascii_digit()))
289                .and_then(|digits| digits.parse().ok())
290                .map(Self::Number),
291        }
292    }
293}
294
295/// Why a string is not a valid [`AllowedHost`].
296///
297/// # Examples
298///
299/// ```
300/// use mcpls_core::{AllowedHost, InvalidAllowedHost};
301///
302/// assert_eq!("*".parse::<AllowedHost>(), Err(InvalidAllowedHost::Wildcard));
303/// assert_eq!("example.com:".parse::<AllowedHost>(), Err(InvalidAllowedHost::InvalidPort));
304/// assert_eq!("example.com:443".parse::<AllowedHost>(), Err(InvalidAllowedHost::DefaultPort));
305/// ```
306#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
307#[non_exhaustive]
308pub enum InvalidAllowedHost {
309    /// The string is not a host with an optional port (an IPv6 host needs
310    /// brackets), or carries a scheme or path.
311    #[error("not a valid host")]
312    Malformed,
313    /// A wildcard such as `*` or `*.example.com`.
314    #[error("wildcards are not allowed")]
315    Wildcard,
316    /// The authority carries `user:password@`.
317    #[error("user information is not allowed")]
318    UserInfo,
319    /// The host is empty.
320    #[error("the host is missing")]
321    MissingHost,
322    /// The port is empty (`host:`), `0`, or is not a number up to 65535.
323    #[error("the port must be a number from 1 to 65535")]
324    InvalidPort,
325    /// The port is `80` or `443`. Clients and proxies omit these from `Host`,
326    /// so a pin to one would answer `403` to nearly every client.
327    #[error(
328        "ports 80 and 443 are omitted from `Host` by clients, so the pin would never match; \
329         list the host without a port"
330    )]
331    DefaultPort,
332    /// The string has a non-ASCII character; `Host` carries the punycode
333    /// (`xn--`) form of an internationalized name.
334    #[error("only ASCII is allowed; write an internationalized name in punycode (`xn--...`)")]
335    NonAscii,
336    /// The host ends with a `.` (a fully qualified name). `rmcp` compares
337    /// hosts as plain lowercase strings, so the dotted and undotted forms
338    /// are different hosts; list the form clients send, without the dot.
339    #[error("a trailing dot is not allowed; list the name without it")]
340    TrailingDot,
341}
342
343/// A port an [`AllowedHost`] can be pinned to: 1 to 65535, never `80` or `443`.
344///
345/// Clients and proxies omit the default ports from `Host`, and `0` never
346/// appears in it, so none of the three can match a request.
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348struct PinnedPort(std::num::NonZeroU16);
349
350impl PinnedPort {
351    fn new(port: u16) -> Result<Self, InvalidAllowedHost> {
352        match port {
353            80 | 443 => Err(InvalidAllowedHost::DefaultPort),
354            _ => std::num::NonZeroU16::new(port)
355                .map(Self)
356                .ok_or(InvalidAllowedHost::InvalidPort),
357        }
358    }
359
360    const fn get(self) -> u16 {
361        self.0.get()
362    }
363}
364
365/// A `Host` header value allowed to reach the HTTP transport, validated so it
366/// round-trips through `rmcp`'s allowlist.
367///
368/// `rmcp` falls back to matching an entry it cannot parse as a raw host name,
369/// so an invalid string would leave the host blocked with no error. This type
370/// is a host with an optional port, nothing else: no wildcard, userinfo,
371/// scheme or path. The host is lowercased and IPv6 hosts keep their brackets.
372/// `rmcp` compares hosts as plain strings, so a trailing dot and non-ASCII
373/// (use punycode) are rejected rather than normalized. Without a port, any
374/// port matches; with one, only that port does, and a request that omits the
375/// port (clients and proxies omit `:80` and `:443`) does not match, so list
376/// the host without a port unless a non-default port is really meant. A pin to
377/// `80`, `443` or `0` is therefore rejected at parse time.
378/// Surrounding whitespace is ignored.
379///
380/// # Examples
381///
382/// ```
383/// use mcpls_core::AllowedHost;
384///
385/// let any_port: AllowedHost = "MCP.Example.com".parse().unwrap();
386/// assert_eq!(any_port.to_string(), "mcp.example.com");
387/// assert_eq!(any_port.port(), None);
388///
389/// let pinned: AllowedHost = "[::1]:8080".parse().unwrap();
390/// assert_eq!(pinned.to_string(), "[::1]:8080");
391/// assert_eq!(pinned.port(), Some(8080));
392/// assert!("https://example.com".parse::<AllowedHost>().is_err());
393/// assert!("example.com:443".parse::<AllowedHost>().is_err());
394/// ```
395#[derive(Debug, Clone, PartialEq, Eq)]
396pub struct AllowedHost {
397    host: Box<str>,
398    port: Option<PinnedPort>,
399}
400
401impl AllowedHost {
402    /// The loopback names `localhost`, `127.0.0.1` and `[::1]`, on any port.
403    ///
404    /// # Examples
405    ///
406    /// ```
407    /// use mcpls_core::AllowedHost;
408    ///
409    /// assert_eq!(AllowedHost::loopback()[1].to_string(), "127.0.0.1");
410    /// ```
411    #[must_use]
412    pub fn loopback() -> [Self; 3] {
413        LOOPBACK_HOSTS.map(|host| Self {
414            host: host.into(),
415            port: None,
416        })
417    }
418
419    /// The host name or IP literal, lowercased; an IPv6 literal keeps its
420    /// brackets.
421    #[must_use]
422    pub fn host(&self) -> &str {
423        &self.host
424    }
425
426    /// The port the host is pinned to, or `None` for any port. Never `0`,
427    /// `80` or `443`.
428    #[must_use]
429    pub fn port(&self) -> Option<u16> {
430        self.port.map(PinnedPort::get)
431    }
432
433    /// An IP literal on any port: a client reaching a `:80` or `:443` bind
434    /// sends no port in `Host`.
435    fn ip_literal(ip: std::net::IpAddr) -> Self {
436        let host = match ip {
437            std::net::IpAddr::V4(ip) => ip.to_string(),
438            std::net::IpAddr::V6(ip) => format!("[{ip}]"),
439        };
440        Self {
441            host: host.into(),
442            port: None,
443        }
444    }
445}
446
447impl std::fmt::Display for AllowedHost {
448    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449        match self.port() {
450            Some(port) => write!(f, "{}:{port}", self.host),
451            None => f.write_str(&self.host),
452        }
453    }
454}
455
456impl std::str::FromStr for AllowedHost {
457    type Err = InvalidAllowedHost;
458
459    fn from_str(s: &str) -> Result<Self, Self::Err> {
460        use axum::http::uri::Authority;
461
462        let s = s.trim();
463        if s.contains('*') {
464            return Err(InvalidAllowedHost::Wildcard);
465        }
466        if s.contains('@') {
467            return Err(InvalidAllowedHost::UserInfo);
468        }
469        if !s.is_ascii() {
470            return Err(InvalidAllowedHost::NonAscii);
471        }
472        let authority = Authority::try_from(s).map_err(|_| InvalidAllowedHost::Malformed)?;
473        let host = authority.host();
474        if host.is_empty() {
475            return Err(InvalidAllowedHost::MissingHost);
476        }
477        if host.ends_with('.') {
478            return Err(InvalidAllowedHost::TrailingDot);
479        }
480        let port = match AuthorityPort::of(&authority).ok_or(InvalidAllowedHost::InvalidPort)? {
481            AuthorityPort::Absent => None,
482            AuthorityPort::Empty => return Err(InvalidAllowedHost::InvalidPort),
483            AuthorityPort::Number(port) => Some(PinnedPort::new(port)?),
484        };
485        Ok(Self {
486            host: host.to_ascii_lowercase().into(),
487            port,
488        })
489    }
490}
491
492/// The `Host` values the server accepts: the loopback names, the bound IP
493/// literal when it is neither unspecified nor loopback, and `configured`.
494///
495/// The bound IP literal is safe to allow because DNS rebinding needs a
496/// hostname, and it is allowed on any port because a client reaching a `:80`
497/// or `:443` bind omits the port from `Host`.
498pub(super) fn effective_allowed_hosts(
499    local_addr: std::net::SocketAddr,
500    configured: &[AllowedHost],
501) -> Vec<AllowedHost> {
502    let ip = local_addr.ip();
503    let bound_ip = (!ip.is_unspecified() && !ip.is_loopback()).then(|| AllowedHost::ip_literal(ip));
504    AllowedHost::loopback()
505        .into_iter()
506        .chain(bound_ip)
507        .chain(configured.iter().cloned())
508        .collect()
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn test_http_path_accepts_valid_paths() {
517        for ok in [
518            "/mcp",
519            "/a",
520            "/api/v1/mcp",
521            "/a-b_c.d~e",
522            "/A1/2b",
523            "/.well-known",
524        ] {
525            assert_eq!(ok.parse::<crate::HttpPath>().unwrap().as_str(), ok);
526        }
527    }
528
529    #[test]
530    fn test_http_path_rejects_each_invalid_shape() {
531        use crate::InvalidHttpPath as E;
532
533        let cases = [
534            ("", E::MissingLeadingSlash),
535            ("mcp", E::MissingLeadingSlash),
536            ("/", E::Root),
537            ("//", E::EmptySegment),
538            ("/a//b", E::EmptySegment),
539            ("/a/", E::EmptySegment),
540            ("/.", E::DotSegment),
541            ("/a/../b", E::DotSegment),
542            ("/{id}", E::InvalidCharacter('{')),
543            ("/a/*rest", E::InvalidCharacter('*')),
544            ("/:id", E::InvalidCharacter(':')),
545            ("/a?b", E::InvalidCharacter('?')),
546            ("/a#b", E::InvalidCharacter('#')),
547            ("/a%20b", E::InvalidCharacter('%')),
548            ("/a b", E::InvalidCharacter(' ')),
549            ("/\u{e9}", E::InvalidCharacter('\u{e9}')),
550        ];
551        for (input, expected) in cases {
552            assert_eq!(input.parse::<crate::HttpPath>(), Err(expected), "{input:?}");
553        }
554    }
555
556    proptest::proptest! {
557        #[test]
558        fn test_every_valid_http_path_nests_without_panic(
559            raw in "(/[A-Za-z0-9._~-]{1,6}){1,4}|[ -~]{0,12}"
560        ) {
561            if let Ok(path) = raw.parse::<crate::HttpPath>() {
562                let _ = axum::Router::<()>::new()
563                    .nest_service(path.as_str(), axum::routing::get(|| async {}));
564            }
565        }
566    }
567
568    #[test]
569    fn test_allowed_origin_normalizes_scheme_host_and_port() {
570        for (input, expected) in [
571            ("https://app.example.com", "https://app.example.com:443"),
572            ("http://app.example.com", "http://app.example.com:80"),
573            (
574                "HTTPS://App.Example.COM:8443",
575                "https://app.example.com:8443",
576            ),
577            ("http://app.example.com/", "http://app.example.com:80"),
578            ("http://[::1]:8080", "http://[::1]:8080"),
579            ("http://[2001:DB8::1]", "http://[2001:db8::1]:80"),
580            ("http://127.0.0.1:3000", "http://127.0.0.1:3000"),
581            ("  https://a.example.com  ", "https://a.example.com:443"),
582        ] {
583            let origin: crate::AllowedOrigin = input.parse().unwrap();
584            assert_eq!(origin.to_string(), expected, "{input}");
585            assert_eq!(origin.to_string().parse(), Ok(origin), "{input}");
586        }
587    }
588
589    #[test]
590    fn test_allowed_origin_rejects_each_invalid_shape() {
591        use crate::InvalidAllowedOrigin as Invalid;
592
593        for (input, expected) in [
594            ("null", Invalid::Null),
595            ("NULL", Invalid::Null),
596            ("*", Invalid::Wildcard),
597            ("http://example.com:*", Invalid::Wildcard),
598            ("http://*.example.com", Invalid::Wildcard),
599            ("ftp://example.com", Invalid::UnsupportedScheme),
600            ("example.com", Invalid::UnsupportedScheme),
601            ("//example.com", Invalid::UnsupportedScheme),
602            ("http://user:pass@example.com", Invalid::UserInfo),
603            ("http://example.com/app", Invalid::Path),
604            ("http://example.com?x=1", Invalid::Query),
605            ("http://::1:8080", Invalid::Malformed),
606            ("https://a.com:99999", Invalid::Malformed),
607            ("https://a.com:+80", Invalid::Malformed),
608            ("https://[::1]x:80", Invalid::Malformed),
609            ("http://", Invalid::Malformed),
610            ("", Invalid::Malformed),
611        ] {
612            assert_eq!(
613                input.parse::<crate::AllowedOrigin>(),
614                Err(expected),
615                "{input:?}"
616            );
617        }
618    }
619
620    #[test]
621    fn test_allowed_host_normalizes_case_and_keeps_ipv6_brackets() {
622        for (input, expected, port) in [
623            ("example.com", "example.com", None),
624            ("Example.COM:8080", "example.com:8080", Some(8080)),
625            ("[::1]", "[::1]", None),
626            ("[2001:DB8::1]:8443", "[2001:db8::1]:8443", Some(8443)),
627            ("127.0.0.1:3000", "127.0.0.1:3000", Some(3000)),
628            ("  a.example  ", "a.example", None),
629        ] {
630            let host: crate::AllowedHost = input.parse().unwrap();
631            assert_eq!(host.to_string(), expected, "{input}");
632            assert_eq!(host.port(), port, "{input}");
633            assert_eq!(host.to_string().parse(), Ok(host), "{input}");
634        }
635    }
636
637    #[test]
638    fn test_allowed_host_rejects_each_invalid_shape() {
639        use crate::InvalidAllowedHost as Invalid;
640
641        for (input, expected) in [
642            ("*", Invalid::Wildcard),
643            ("*.example.com", Invalid::Wildcard),
644            ("example.com:*", Invalid::Wildcard),
645            ("user@example.com", Invalid::UserInfo),
646            ("user:pass@example.com:80", Invalid::UserInfo),
647            ("", Invalid::Malformed),
648            ("https://example.com", Invalid::Malformed),
649            ("example.com/path", Invalid::Malformed),
650            ("::1", Invalid::Malformed),
651            (":8080", Invalid::MissingHost),
652            ("example.com:", Invalid::InvalidPort),
653            ("example.com:99999", Invalid::InvalidPort),
654            ("example.com.", Invalid::TrailingDot),
655            ("example.com.:8080", Invalid::TrailingDot),
656            ("b\u{fc}cher.example", Invalid::NonAscii),
657            ("example.com:+80", Invalid::InvalidPort),
658        ] {
659            assert_eq!(
660                input.parse::<crate::AllowedHost>(),
661                Err(expected),
662                "{input:?}"
663            );
664        }
665    }
666
667    #[test]
668    fn test_allowed_host_rejects_default_and_zero_ports_on_the_parsed_value() {
669        use crate::InvalidAllowedHost as Invalid;
670
671        for (input, expected) in [
672            ("x.example:80", Invalid::DefaultPort),
673            ("x.example:443", Invalid::DefaultPort),
674            ("[::1]:443", Invalid::DefaultPort),
675            ("[2001:db8::1]:80", Invalid::DefaultPort),
676            ("1.2.3.4:80", Invalid::DefaultPort),
677            ("localhost:443", Invalid::DefaultPort),
678            ("X.EXAMPLE:443", Invalid::DefaultPort),
679            ("  x.example:443  ", Invalid::DefaultPort),
680            ("x.example:0443", Invalid::DefaultPort),
681            ("x.example:080", Invalid::DefaultPort),
682            ("x.example:000080", Invalid::DefaultPort),
683            ("x.example:0", Invalid::InvalidPort),
684            ("x.example:00", Invalid::InvalidPort),
685            ("x.example:65536", Invalid::InvalidPort),
686            ("x.example:", Invalid::InvalidPort),
687            (":443", Invalid::MissingHost),
688            ("*.example:443", Invalid::Wildcard),
689            ("u@x.example:443", Invalid::UserInfo),
690            ("::1:443", Invalid::Malformed),
691            ("[::1:443", Invalid::Malformed),
692        ] {
693            assert_eq!(
694                input.parse::<crate::AllowedHost>(),
695                Err(expected),
696                "{input:?}"
697            );
698        }
699    }
700
701    #[test]
702    fn test_allowed_host_accepts_every_other_port_unchanged() {
703        for input in [
704            "x.example",
705            "x.example:8443",
706            "x.example:81",
707            "x.example:442",
708            "x.example:1",
709            "x.example:65535",
710            "[::1]:8080",
711        ] {
712            let host: crate::AllowedHost = input.parse().unwrap();
713            assert_eq!(host.to_string(), input, "{input}");
714        }
715    }
716
717    #[test]
718    fn test_default_port_error_carries_the_guidance() {
719        let message = crate::InvalidAllowedHost::DefaultPort.to_string();
720        assert!(
721            message.contains("list the host without a port"),
722            "{message}"
723        );
724    }
725
726    #[test]
727    fn test_effective_allowed_hosts_by_bind_address() {
728        let names = |bind: &str, configured: &[&str]| -> Vec<String> {
729            let configured: Vec<crate::AllowedHost> =
730                configured.iter().map(|h| h.parse().unwrap()).collect();
731            effective_allowed_hosts(bind.parse().unwrap(), &configured)
732                .iter()
733                .map(ToString::to_string)
734                .collect()
735        };
736        let loopback = LOOPBACK_HOSTS;
737
738        assert_eq!(names("127.0.0.1:3000", &[]), loopback);
739        assert_eq!(names("[::1]:3000", &[]), loopback);
740        assert_eq!(names("0.0.0.0:3000", &[]), loopback);
741        assert_eq!(names("[::]:3000", &[]), loopback);
742        assert_eq!(
743            names("192.168.1.5:3000", &["mcp.example.com"]),
744            [loopback.as_slice(), &["192.168.1.5", "mcp.example.com"]].concat()
745        );
746        assert_eq!(
747            names("[2001:db8::5]:3000", &[]),
748            [loopback.as_slice(), &["[2001:db8::5]"]].concat()
749        );
750    }
751}