1const LOOPBACK_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "[::1]"];
6
7#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum InvalidHttpPath {
19 #[error("must start with `/`")]
21 MissingLeadingSlash,
22 #[error("must not be `/`: the service already answers at the root path")]
24 Root,
25 #[error("must not contain an empty segment (`//` or a trailing `/`)")]
27 EmptySegment,
28 #[error("must not contain a `.` or `..` segment")]
30 DotSegment,
31 #[error("contains `{0}`; only ASCII letters, digits and `-._~` are allowed in a segment")]
33 InvalidCharacter(char),
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct HttpPath(Box<str>);
55
56impl HttpPath {
57 #[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#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
115#[non_exhaustive]
116pub enum InvalidAllowedOrigin {
117 #[error("not a valid origin")]
120 Malformed,
121 #[error("`null` is not an origin")]
123 Null,
124 #[error("wildcards are not allowed")]
126 Wildcard,
127 #[error("the scheme must be `http` or `https`")]
129 UnsupportedScheme,
130 #[error("user information is not allowed")]
132 UserInfo,
133 #[error("the host is missing")]
135 MissingHost,
136 #[error("a path is not allowed")]
138 Path,
139 #[error("a query is not allowed")]
141 Query,
142}
143
144#[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#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct AllowedOrigin {
190 scheme: OriginScheme,
191 host: Box<str>,
192 port: u16,
193}
194
195impl AllowedOrigin {
196 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270enum AuthorityPort {
271 Absent,
273 Empty,
275 Number(u16),
277}
278
279impl AuthorityPort {
280 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#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
307#[non_exhaustive]
308pub enum InvalidAllowedHost {
309 #[error("not a valid host")]
312 Malformed,
313 #[error("wildcards are not allowed")]
315 Wildcard,
316 #[error("user information is not allowed")]
318 UserInfo,
319 #[error("the host is missing")]
321 MissingHost,
322 #[error("the port must be a number from 1 to 65535")]
324 InvalidPort,
325 #[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 #[error("only ASCII is allowed; write an internationalized name in punycode (`xn--...`)")]
335 NonAscii,
336 #[error("a trailing dot is not allowed; list the name without it")]
340 TrailingDot,
341}
342
343#[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#[derive(Debug, Clone, PartialEq, Eq)]
396pub struct AllowedHost {
397 host: Box<str>,
398 port: Option<PinnedPort>,
399}
400
401impl AllowedHost {
402 #[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 #[must_use]
422 pub fn host(&self) -> &str {
423 &self.host
424 }
425
426 #[must_use]
429 pub fn port(&self) -> Option<u16> {
430 self.port.map(PinnedPort::get)
431 }
432
433 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
492pub(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}