1use std::ops::Range;
2use std::sync::Arc;
3
4use anyhow::{bail, ensure, Context as _};
5use futures_util::future::{BoxFuture, Shared};
6use spin_expressions::Resolver;
7use url::Host;
8
9pub const SERVICE_CHAINING_DOMAIN: &str = "spin.internal";
11pub const SERVICE_CHAINING_DOMAIN_SUFFIX: &str = ".spin.internal";
13
14pub type SharedFutureResult<T> = Shared<BoxFuture<'static, Result<Arc<T>, Arc<anyhow::Error>>>>;
16
17#[derive(Clone)]
19pub struct OutboundAllowedHosts {
20 allowed_hosts_future: SharedFutureResult<AllowedHostsConfig>,
21 disallowed_host_handler: Option<Arc<dyn DisallowedHostHandler>>,
22}
23
24impl OutboundAllowedHosts {
25 pub fn new(
27 allowed_hosts_future: SharedFutureResult<AllowedHostsConfig>,
28 disallowed_host_handler: Option<Arc<dyn DisallowedHostHandler>>,
29 ) -> Self {
30 Self {
31 allowed_hosts_future,
32 disallowed_host_handler,
33 }
34 }
35
36 pub async fn check_url(&self, url: &str, scheme: &str) -> anyhow::Result<bool> {
41 tracing::debug!("Checking outbound networking request to '{url}'");
42 let url = match OutboundUrl::parse(url, scheme) {
43 Ok(url) => url,
44 Err(err) => {
45 tracing::warn!(%err,
46 "A component tried to make a request to a url that could not be parsed: {url}",
47 );
48 return Ok(false);
49 }
50 };
51
52 let allowed_hosts = self.resolve().await?;
53 let is_allowed = allowed_hosts.allows(&url);
54 if !is_allowed {
55 tracing::debug!("Disallowed outbound networking request to '{url}'");
56 self.report_disallowed_host(url.scheme(), &url.authority());
57 }
58 Ok(is_allowed)
59 }
60
61 pub async fn check_relative_url(&self, schemes: &[&str]) -> anyhow::Result<bool> {
66 tracing::debug!("Checking relative outbound networking request with schemes {schemes:?}");
67 let allowed_hosts = self.resolve().await?;
68 let is_allowed = allowed_hosts.allows_relative_url(schemes);
69 if !is_allowed {
70 tracing::debug!(
71 "Disallowed relative outbound networking request with schemes {schemes:?}"
72 );
73 let scheme = schemes.first().unwrap_or(&"");
74 self.report_disallowed_host(scheme, "self");
75 }
76 Ok(is_allowed)
77 }
78
79 async fn resolve(&self) -> anyhow::Result<Arc<AllowedHostsConfig>> {
80 self.allowed_hosts_future
81 .clone()
82 .await
83 .map_err(anyhow::Error::msg)
84 }
85
86 fn report_disallowed_host(&self, scheme: &str, authority: &str) {
87 if let Some(handler) = &self.disallowed_host_handler {
88 handler.handle_disallowed_host(scheme, authority);
89 }
90 }
91}
92
93pub trait DisallowedHostHandler: Send + Sync {
95 fn handle_disallowed_host(&self, scheme: &str, authority: &str);
97}
98
99impl<F: Fn(&str, &str) + Send + Sync> DisallowedHostHandler for F {
100 fn handle_disallowed_host(&self, scheme: &str, authority: &str) {
101 self(scheme, authority);
102 }
103}
104
105#[derive(Eq, Debug, Clone)]
107pub struct AllowedHostConfig {
108 original: String,
109 scheme: SchemeConfig,
110 host: HostConfig,
111 port: PortConfig,
112}
113
114impl AllowedHostConfig {
115 pub fn parse(url: impl Into<String>) -> anyhow::Result<Self> {
117 let original = url.into();
118 let url = original.trim();
119 let Some((scheme, rest)) = url.split_once("://") else {
120 match url {
121 "*" | ":" | "" | "?" => bail!("{url:?} is not an allowed outbound host format.\nHosts must be in the form <scheme>://<host>[:<port>], with '*' wildcards allowed for each.\nIf you intended to allow all outbound networking, you can use '*://*:*' - this will obviate all network sandboxing.\nLearn more: https://spinframework.dev/v3/http-outbound#granting-http-permissions-to-components"),
122 _ => bail!("{url:?} does not contain a scheme (e.g., 'http://' or '*://')\nLearn more: https://spinframework.dev/v3/http-outbound#granting-http-permissions-to-components"),
123 }
124 };
125 let (host, rest) = rest.rsplit_once(':').unwrap_or((rest, ""));
126 let port = match rest.split_once('/') {
127 Some((port, path)) => {
128 if !path.is_empty() {
129 bail!("{url:?} has a path but is not allowed to");
130 }
131 port
132 }
133 None => rest,
134 };
135
136 let port = PortConfig::parse(port, scheme)
137 .with_context(|| format!("Invalid allowed host port {port:?}"))?;
138 let scheme = SchemeConfig::parse(scheme)
139 .with_context(|| format!("Invalid allowed host scheme {scheme:?}"))?;
140 let host =
141 HostConfig::parse(host).with_context(|| format!("Invalid allowed host {host:?}"))?;
142
143 Ok(Self {
144 scheme,
145 host,
146 port,
147 original,
148 })
149 }
150
151 pub fn scheme(&self) -> &SchemeConfig {
152 &self.scheme
153 }
154
155 pub fn host(&self) -> &HostConfig {
156 &self.host
157 }
158
159 pub fn port(&self) -> &PortConfig {
160 &self.port
161 }
162
163 pub fn is_for_service_chaining(&self) -> bool {
165 self.host.is_for_service_chaining()
166 }
167
168 fn allows(&self, url: &OutboundUrl) -> bool {
170 self.scheme.allows(&url.scheme)
171 && self.host.allows(&url.host)
172 && self.port.allows(url.port, &url.scheme)
173 }
174
175 fn allows_relative(&self, schemes: &[&str]) -> bool {
178 schemes.iter().any(|s| self.scheme.allows(s)) && self.host.allows_relative()
179 }
180}
181
182impl PartialEq for AllowedHostConfig {
183 fn eq(&self, other: &Self) -> bool {
184 self.scheme == other.scheme && self.host == other.host && self.port == other.port
185 }
186}
187
188impl std::fmt::Display for AllowedHostConfig {
189 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190 f.write_str(&self.original)
191 }
192}
193
194#[derive(PartialEq, Eq, Debug, Clone)]
196pub enum SchemeConfig {
197 Any,
199 List(Vec<String>),
201}
202
203impl SchemeConfig {
204 fn parse(scheme: &str) -> anyhow::Result<Self> {
206 if scheme == "*" {
207 return Ok(Self::Any);
208 }
209
210 if scheme.starts_with('{') {
211 anyhow::bail!("scheme lists are not supported")
212 }
213
214 if scheme.chars().any(|c| !c.is_alphabetic()) {
215 anyhow::bail!("only alphabetic characters are allowed");
216 }
217
218 Ok(Self::List(vec![scheme.into()]))
219 }
220
221 pub fn allows_any(&self) -> bool {
223 matches!(self, Self::Any)
224 }
225
226 fn allows(&self, scheme: &str) -> bool {
228 match self {
229 SchemeConfig::Any => true,
230 SchemeConfig::List(l) => l.iter().any(|s| s.as_str() == scheme),
231 }
232 }
233}
234
235#[derive(Debug, PartialEq, Eq, Clone)]
237pub enum HostConfig {
238 Any,
239 AnySubdomain(String),
240 ToSelf,
241 Literal(Host),
242 Cidr(ip_network::IpNetwork),
243}
244
245impl HostConfig {
246 fn parse(mut host: &str) -> anyhow::Result<Self> {
248 host = host.trim();
249 if host == "*" {
250 return Ok(Self::Any);
251 }
252
253 if host == "self" || host == "self.alt" {
254 return Ok(Self::ToSelf);
255 }
256
257 if host.starts_with('{') {
258 ensure!(host.ends_with('}'));
259 bail!("host lists are not yet supported")
260 }
261
262 if let Ok(net) = ip_network::IpNetwork::from_str_truncate(host) {
263 return Ok(Self::Cidr(net));
264 }
265
266 host = host.trim_end_matches('/');
267 if host.contains('/') {
268 bail!("must not include a path");
269 }
270
271 if let Some(domain) = host.strip_prefix("*.") {
272 if domain.contains('*') {
273 bail!("wildcards are allowed only as prefixes");
274 }
275 return Ok(Self::AnySubdomain(format!(".{domain}")));
276 }
277
278 if host.contains('*') {
279 bail!("wildcards are allowed only as subdomains");
280 }
281
282 Self::literal(host)
283 }
284
285 fn literal(host: &str) -> anyhow::Result<Self> {
287 Ok(Self::Literal(Host::parse(host)?))
288 }
289
290 fn allows(&self, host: &str) -> bool {
292 let host: Host = match Host::parse(host) {
293 Ok(host) => host,
294 Err(err) => {
295 tracing::warn!(?err, "invalid host in HostConfig::allows");
296 return false;
297 }
298 };
299 match (self, host) {
300 (HostConfig::Any, _) => true,
301 (HostConfig::AnySubdomain(suffix), Host::Domain(domain)) => domain.ends_with(suffix),
302 (HostConfig::Literal(literal), host) => host == *literal,
303 (HostConfig::Cidr(c), Host::Ipv4(ip)) => c.contains(ip),
304 (HostConfig::Cidr(c), Host::Ipv6(ip)) => c.contains(ip),
305 (HostConfig::AnySubdomain(_), _) => false,
307 (HostConfig::Cidr(_), Host::Domain(_)) => false,
309 (HostConfig::ToSelf, _) => false,
311 }
312 }
313
314 fn allows_relative(&self) -> bool {
316 matches!(self, Self::Any | Self::ToSelf)
317 }
318
319 fn is_for_service_chaining(&self) -> bool {
321 match self {
322 Self::Literal(Host::Domain(domain)) => domain.ends_with(SERVICE_CHAINING_DOMAIN_SUFFIX),
323 Self::AnySubdomain(suffix) => suffix == SERVICE_CHAINING_DOMAIN_SUFFIX,
324 _ => false,
325 }
326 }
327}
328
329#[derive(Debug, PartialEq, Eq, Clone)]
331pub enum PortConfig {
332 Any,
333 List(Vec<IndividualPortConfig>),
334}
335
336impl PortConfig {
337 fn parse(port: &str, scheme: &str) -> anyhow::Result<PortConfig> {
339 if port.is_empty() {
340 return well_known_port(scheme)
341 .map(|p| PortConfig::List(vec![IndividualPortConfig::Port(p)]))
342 .with_context(|| format!("no port was provided and the scheme {scheme:?} does not have a known default port number"));
343 }
344 if port == "*" {
345 return Ok(PortConfig::Any);
346 }
347
348 if port.starts_with('{') {
349 bail!("port lists are not yet supported")
351 }
352
353 let port = IndividualPortConfig::parse(port)?;
354
355 Ok(Self::List(vec![port]))
356 }
357
358 fn allows(&self, port: Option<u16>, scheme: &str) -> bool {
360 match self {
361 PortConfig::Any => true,
362 PortConfig::List(l) => {
363 let port = match port.or_else(|| well_known_port(scheme)) {
364 Some(p) => p,
365 None => return false,
366 };
367 l.iter().any(|p| p.allows(port))
368 }
369 }
370 }
371}
372
373#[derive(Debug, PartialEq, Eq, Clone)]
375pub enum IndividualPortConfig {
376 Port(u16),
377 Range(Range<u16>),
378}
379
380impl IndividualPortConfig {
381 fn parse(port: &str) -> anyhow::Result<Self> {
383 if let Some((start, end)) = port.split_once("..") {
384 let start = start
385 .parse()
386 .with_context(|| format!("port range {port:?} contains non-number"))?;
387 let end = end
388 .parse()
389 .with_context(|| format!("port range {port:?} contains non-number"))?;
390 return Ok(Self::Range(start..end));
391 }
392 Ok(Self::Port(port.parse().with_context(|| {
393 format!("port {port:?} is not a number")
394 })?))
395 }
396
397 fn allows(&self, port: u16) -> bool {
399 match self {
400 IndividualPortConfig::Port(p) => p == &port,
401 IndividualPortConfig::Range(r) => r.contains(&port),
402 }
403 }
404}
405
406fn well_known_port(scheme: &str) -> Option<u16> {
408 match scheme {
409 "postgres" => Some(5432),
410 "mysql" => Some(3306),
411 "redis" => Some(6379),
412 "mqtt" => Some(1883),
413 "http" => Some(80),
414 "https" => Some(443),
415 _ => None,
416 }
417}
418
419enum PartialAllowedHostConfig {
422 Exact(AllowedHostConfig),
423 Unresolved(spin_expressions::Template),
424}
425
426impl PartialAllowedHostConfig {
427 fn resolve(
429 self,
430 resolver: &spin_expressions::PreparedResolver,
431 ) -> anyhow::Result<AllowedHostConfig> {
432 match self {
433 Self::Exact(h) => Ok(h),
434 Self::Unresolved(t) => AllowedHostConfig::parse(resolver.resolve_template(&t)?),
435 }
436 }
437
438 fn validate(&self, resolver: &Resolver) -> anyhow::Result<()> {
441 if let Self::Unresolved(template) = self {
442 let Ok(resolved) = resolver.resolve_template(template) else {
443 return Ok(());
445 };
446 AllowedHostConfig::parse(&resolved).with_context(|| {
447 let template_str = template.to_string();
448 format!("using default variable value(s) with template {template_str:?} results in invalid config {resolved:?}")
449 })?;
450 }
451 Ok(())
452 }
453}
454
455#[derive(PartialEq, Eq, Debug, Clone)]
457pub enum AllowedHostsConfig {
458 All,
459 SpecificHosts(Vec<AllowedHostConfig>),
460}
461
462impl AllowedHostsConfig {
463 pub fn parse<S: AsRef<str>>(
466 hosts: &[S],
467 resolver: &spin_expressions::PreparedResolver,
468 component_ids: &[String],
469 ) -> anyhow::Result<AllowedHostsConfig> {
470 let partial = Self::parse_partial(hosts)?;
471 let allowed = partial
472 .into_iter()
473 .map(|p| p.resolve(resolver))
474 .collect::<anyhow::Result<Vec<_>>>()?;
475 let allowed = Self::expand_wildcard_service_chaining(allowed, component_ids);
476 Ok(Self::SpecificHosts(allowed))
477 }
478
479 pub fn validate<S: AsRef<str>>(hosts: &[S], resolver: &Resolver) -> anyhow::Result<()> {
481 for partial in Self::parse_partial(hosts)? {
482 partial.validate(resolver)?;
483 }
484 Ok(())
485 }
486
487 fn parse_partial<S: AsRef<str>>(hosts: &[S]) -> anyhow::Result<Vec<PartialAllowedHostConfig>> {
490 if hosts.len() == 1 && hosts[0].as_ref() == "insecure:allow-all" {
491 bail!("'insecure:allow-all' is not allowed - use '*://*:*' instead if you really want to allow all outbound traffic'")
492 }
493 let mut allowed = Vec::with_capacity(hosts.len());
494 for host in hosts {
495 let template = spin_expressions::Template::new(host.as_ref())?;
496 if template.is_literal() {
497 allowed.push(PartialAllowedHostConfig::Exact(AllowedHostConfig::parse(
498 host.as_ref(),
499 )?));
500 } else {
501 allowed.push(PartialAllowedHostConfig::Unresolved(template));
502 }
503 }
504 Ok(allowed)
505 }
506
507 fn expand_wildcard_service_chaining(
508 hosts: Vec<AllowedHostConfig>,
509 component_ids: &[String],
510 ) -> Vec<AllowedHostConfig> {
511 let expand_one = |host: AllowedHostConfig| match host.host() {
512 HostConfig::AnySubdomain(domain) if domain == SERVICE_CHAINING_DOMAIN_SUFFIX => {
513 let expanded_domains = component_ids
514 .iter()
515 .map(|c| format!("{c}{SERVICE_CHAINING_DOMAIN_SUFFIX}"));
516 let expanded_hosts = expanded_domains.map(|d| {
517 let mut hh = host.clone();
518 hh.host = HostConfig::Literal(url::Host::Domain(d));
519 hh
520 });
521 expanded_hosts.collect()
522 }
523 _ => vec![host],
524 };
525
526 hosts.into_iter().flat_map(expand_one).collect()
527 }
528
529 pub fn allows(&self, url: &OutboundUrl) -> bool {
531 match self {
532 AllowedHostsConfig::All => true,
533 AllowedHostsConfig::SpecificHosts(hosts) => hosts.iter().any(|h| h.allows(url)),
534 }
535 }
536
537 pub fn allows_relative_url(&self, schemes: &[&str]) -> bool {
540 match self {
541 AllowedHostsConfig::All => true,
542 AllowedHostsConfig::SpecificHosts(hosts) => {
543 hosts.iter().any(|h| h.allows_relative(schemes))
544 }
545 }
546 }
547}
548
549impl Default for AllowedHostsConfig {
550 fn default() -> Self {
551 Self::SpecificHosts(Vec::new())
552 }
553}
554
555#[derive(Debug, Clone)]
557pub struct OutboundUrl {
558 scheme: String,
559 host: String,
560 port: Option<u16>,
561 original: String,
562}
563
564impl OutboundUrl {
565 pub fn parse(url: impl Into<String>, scheme: &str) -> anyhow::Result<Self> {
569 let mut url = url.into();
570 let original = url.clone();
571
572 if let Some(at) = url.find('@') {
575 let scheme_end = url.find("://").map(|e| e + 3).unwrap_or(0);
576 let path_start = url[scheme_end..]
577 .find('/') .map(|e| e + scheme_end)
579 .unwrap_or(usize::MAX);
580
581 if at < path_start {
582 let userinfo = &url[scheme_end..at];
583
584 let encoded = urlencoding::encode(userinfo);
585 let prefix = &url[..scheme_end];
586 let suffix = &url[scheme_end + userinfo.len()..];
587 url = format!("{prefix}{encoded}{suffix}");
588 }
589 }
590
591 let parsed = match url::Url::parse(&url) {
592 Ok(url) if url.has_host() => Ok(url),
593 first_try => {
594 let second_try: anyhow::Result<url::Url> = format!("{scheme}://{url}")
595 .as_str()
596 .try_into()
597 .context("could not convert into a url");
598 match (second_try, first_try.map_err(|e| e.into())) {
599 (Ok(u), _) => Ok(u),
600 (_, Err(e)) | (Err(e), _) => Err(e),
602 }
603 }
604 }?;
605
606 Ok(Self {
607 scheme: parsed.scheme().to_owned(),
608 host: parsed
609 .host_str()
610 .with_context(|| format!("{url:?} does not have a host component"))?
611 .to_owned(),
612 port: parsed.port(),
613 original,
614 })
615 }
616
617 pub fn scheme(&self) -> &str {
618 &self.scheme
619 }
620
621 pub fn authority(&self) -> String {
622 if let Some(port) = self.port {
623 format!("{}:{port}", self.host)
624 } else {
625 self.host.clone()
626 }
627 }
628}
629
630impl std::fmt::Display for OutboundUrl {
631 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
632 f.write_str(&self.original)
633 }
634}
635
636pub fn is_service_chaining_host(host: &str) -> bool {
638 parse_service_chaining_host(host).is_some()
639}
640
641pub fn parse_service_chaining_target(url: &http::Uri) -> Option<String> {
643 let host = url.authority().map(|a| a.host().trim())?;
644 parse_service_chaining_host(host)
645}
646
647fn parse_service_chaining_host(host: &str) -> Option<String> {
648 let (host, _) = host.rsplit_once(':').unwrap_or((host, ""));
649
650 let (first, rest) = host.split_once('.')?;
651
652 if rest == SERVICE_CHAINING_DOMAIN {
653 Some(first.to_owned())
654 } else {
655 None
656 }
657}
658
659#[cfg(test)]
660mod test {
661 impl AllowedHostConfig {
662 fn new(scheme: SchemeConfig, host: HostConfig, port: PortConfig) -> Self {
663 Self {
664 scheme,
665 host,
666 port,
667 original: String::new(),
668 }
669 }
670 }
671
672 impl SchemeConfig {
673 fn new(scheme: &str) -> Self {
674 Self::List(vec![scheme.into()])
675 }
676 }
677
678 impl HostConfig {
679 fn subdomain(domain: &str) -> Self {
680 Self::AnySubdomain(format!(".{domain}"))
681 }
682 }
683
684 impl PortConfig {
685 fn new(port: u16) -> Self {
686 Self::List(vec![IndividualPortConfig::Port(port)])
687 }
688
689 fn range(port: Range<u16>) -> Self {
690 Self::List(vec![IndividualPortConfig::Range(port)])
691 }
692 }
693
694 fn dummy_resolver() -> spin_expressions::PreparedResolver {
695 spin_expressions::PreparedResolver::default()
696 }
697
698 use ip_network::{IpNetwork, Ipv4Network, Ipv6Network};
699
700 use super::*;
701 use std::net::{Ipv4Addr, Ipv6Addr};
702
703 #[test]
704 fn outbound_url_handles_at_in_paths() {
705 let url = "https://example.com/file@0.1.0.json";
706 let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
707 assert_eq!("example.com", url.host);
708
709 let url = "https://user:password@example.com/file@0.1.0.json";
710 let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
711 assert_eq!("example.com", url.host);
712
713 let url = "https://user:pass#word@example.com/file@0.1.0.json";
714 let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
715 assert_eq!("example.com", url.host);
716
717 let url = "https://user:password@example.com";
718 let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
719 assert_eq!("example.com", url.host);
720 }
721
722 #[test]
723 fn test_allowed_hosts_accepts_url_without_port() {
724 assert_eq!(
725 AllowedHostConfig::new(
726 SchemeConfig::new("http"),
727 HostConfig::literal("spin.fermyon.dev").unwrap(),
728 PortConfig::new(80)
729 ),
730 AllowedHostConfig::parse("http://spin.fermyon.dev").unwrap()
731 );
732
733 assert_eq!(
734 AllowedHostConfig::new(
735 SchemeConfig::new("http"),
736 HostConfig::literal("spin.fermyon.dev").unwrap(),
738 PortConfig::new(80)
739 ),
740 AllowedHostConfig::parse("http://spin.fermyon.dev/").unwrap()
741 );
742
743 assert_eq!(
744 AllowedHostConfig::new(
745 SchemeConfig::new("https"),
746 HostConfig::literal("spin.fermyon.dev").unwrap(),
747 PortConfig::new(443)
748 ),
749 AllowedHostConfig::parse("https://spin.fermyon.dev").unwrap()
750 );
751 }
752
753 #[test]
754 fn test_allowed_hosts_accepts_url_with_port() {
755 assert_eq!(
756 AllowedHostConfig::new(
757 SchemeConfig::new("http"),
758 HostConfig::literal("spin.fermyon.dev").unwrap(),
759 PortConfig::new(4444)
760 ),
761 AllowedHostConfig::parse("http://spin.fermyon.dev:4444").unwrap()
762 );
763 assert_eq!(
764 AllowedHostConfig::new(
765 SchemeConfig::new("http"),
766 HostConfig::literal("spin.fermyon.dev").unwrap(),
767 PortConfig::new(4444)
768 ),
769 AllowedHostConfig::parse("http://spin.fermyon.dev:4444/").unwrap()
770 );
771 assert_eq!(
772 AllowedHostConfig::new(
773 SchemeConfig::new("https"),
774 HostConfig::literal("spin.fermyon.dev").unwrap(),
775 PortConfig::new(5555)
776 ),
777 AllowedHostConfig::parse("https://spin.fermyon.dev:5555").unwrap()
778 );
779 }
780
781 #[test]
782 fn test_allowed_hosts_accepts_url_with_port_range() {
783 assert_eq!(
784 AllowedHostConfig::new(
785 SchemeConfig::new("http"),
786 HostConfig::literal("spin.fermyon.dev").unwrap(),
787 PortConfig::range(4444..5555)
788 ),
789 AllowedHostConfig::parse("http://spin.fermyon.dev:4444..5555").unwrap()
790 );
791 }
792
793 #[test]
794 fn test_allowed_hosts_does_not_accept_plain_host_without_port() {
795 assert!(AllowedHostConfig::parse("spin.fermyon.dev").is_err());
796 }
797
798 #[test]
799 fn test_allowed_hosts_does_not_accept_plain_host_without_scheme() {
800 assert!(AllowedHostConfig::parse("spin.fermyon.dev:80").is_err());
801 }
802
803 #[test]
804 fn test_allowed_hosts_accepts_host_with_glob_scheme() {
805 assert_eq!(
806 AllowedHostConfig::new(
807 SchemeConfig::Any,
808 HostConfig::literal("spin.fermyon.dev").unwrap(),
809 PortConfig::new(7777)
810 ),
811 AllowedHostConfig::parse("*://spin.fermyon.dev:7777").unwrap()
812 )
813 }
814
815 #[test]
816 fn test_allowed_hosts_accepts_self() {
817 assert_eq!(
818 AllowedHostConfig::new(
819 SchemeConfig::new("http"),
820 HostConfig::ToSelf,
821 PortConfig::new(80)
822 ),
823 AllowedHostConfig::parse("http://self").unwrap()
824 );
825 }
826
827 #[test]
828 fn test_allowed_hosts_accepts_localhost_addresses() {
829 assert!(AllowedHostConfig::parse("localhost").is_err());
830 assert_eq!(
831 AllowedHostConfig::new(
832 SchemeConfig::new("http"),
833 HostConfig::literal("localhost").unwrap(),
834 PortConfig::new(80)
835 ),
836 AllowedHostConfig::parse("http://localhost").unwrap()
837 );
838 assert!(AllowedHostConfig::parse("localhost:3001").is_err());
839 assert_eq!(
840 AllowedHostConfig::new(
841 SchemeConfig::new("http"),
842 HostConfig::literal("localhost").unwrap(),
843 PortConfig::new(3001)
844 ),
845 AllowedHostConfig::parse("http://localhost:3001").unwrap()
846 );
847 }
848
849 #[test]
850 fn test_allowed_hosts_accepts_subdomain_wildcards() {
851 assert_eq!(
852 AllowedHostConfig::new(
853 SchemeConfig::new("http"),
854 HostConfig::subdomain("example.com"),
855 PortConfig::new(80)
856 ),
857 AllowedHostConfig::parse("http://*.example.com").unwrap()
858 );
859 }
860
861 #[test]
862 fn test_allowed_hosts_accepts_ip_addresses() {
863 assert_eq!(
864 AllowedHostConfig::new(
865 SchemeConfig::new("http"),
866 HostConfig::literal("192.168.1.1").unwrap(),
867 PortConfig::new(80)
868 ),
869 AllowedHostConfig::parse("http://192.168.1.1").unwrap()
870 );
871 assert_eq!(
872 AllowedHostConfig::new(
873 SchemeConfig::new("http"),
874 HostConfig::literal("192.168.1.1").unwrap(),
875 PortConfig::new(3002)
876 ),
877 AllowedHostConfig::parse("http://192.168.1.1:3002").unwrap()
878 );
879 assert_eq!(
880 AllowedHostConfig::new(
881 SchemeConfig::new("http"),
882 HostConfig::literal("[::1]").unwrap(),
883 PortConfig::new(8001)
884 ),
885 AllowedHostConfig::parse("http://[::1]:8001").unwrap()
886 );
887
888 assert!(AllowedHostConfig::parse("http://[::1]").is_err())
889 }
890
891 #[test]
892 fn test_allowed_hosts_accepts_ip_cidr() {
893 assert_eq!(
894 AllowedHostConfig::new(
895 SchemeConfig::Any,
896 HostConfig::Cidr(IpNetwork::V4(
897 Ipv4Network::new(Ipv4Addr::new(127, 0, 0, 0), 24).unwrap()
898 )),
899 PortConfig::new(80)
900 ),
901 AllowedHostConfig::parse("*://127.0.0.0/24:80").unwrap()
902 );
903 assert!(AllowedHostConfig::parse("*://127.0.0.0/24").is_err());
904 assert_eq!(
905 AllowedHostConfig::new(
906 SchemeConfig::Any,
907 HostConfig::Cidr(IpNetwork::V6(
908 Ipv6Network::new(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8).unwrap()
909 )),
910 PortConfig::new(80)
911 ),
912 AllowedHostConfig::parse("*://ff00::/8:80").unwrap()
913 );
914 }
915
916 #[test]
917 fn test_allowed_hosts_rejects_path() {
918 assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/").is_ok());
920 assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/a").is_err());
922 assert!(AllowedHostConfig::parse("http://spin.fermyon.dev:6666/a/b").is_err());
923 assert!(AllowedHostConfig::parse("http://*.fermyon.dev/a").is_err());
924 }
925
926 #[test]
927 fn test_allowed_hosts_respects_allow_all() {
928 assert!(
929 AllowedHostsConfig::parse(&["insecure:allow-all"], &dummy_resolver(), &[]).is_err()
930 );
931 assert!(AllowedHostsConfig::parse(
932 &["spin.fermyon.dev", "insecure:allow-all"],
933 &dummy_resolver(),
934 &[]
935 )
936 .is_err());
937 }
938
939 #[test]
940 fn test_allowed_all_globs() {
941 assert_eq!(
942 AllowedHostConfig::new(SchemeConfig::Any, HostConfig::Any, PortConfig::Any),
943 AllowedHostConfig::parse("*://*:*").unwrap()
944 );
945 }
946
947 #[test]
948 fn test_missing_scheme() {
949 assert!(AllowedHostConfig::parse("example.com").is_err());
950 }
951
952 #[test]
953 fn test_allowed_hosts_can_be_specific() {
954 let allowed = AllowedHostsConfig::parse(
955 &["*://spin.fermyon.dev:443", "http://example.com:8383"],
956 &dummy_resolver(),
957 &[],
958 )
959 .unwrap();
960 assert!(
961 allowed.allows(&OutboundUrl::parse("http://example.com:8383/foo/bar", "http").unwrap())
962 );
963 assert!(allowed.allows(&OutboundUrl::parse("https://spin.fermyon.dev", "https").unwrap()));
965 assert!(allowed.allows(&OutboundUrl::parse("https://spin.fermyon.dev/", "https").unwrap()));
966 assert!(!allowed.allows(&OutboundUrl::parse("http://example.com/", "http").unwrap()));
967 assert!(!allowed.allows(&OutboundUrl::parse("http://google.com/", "http").unwrap()));
968 assert!(allowed.allows(&OutboundUrl::parse("spin.fermyon.dev:443", "https").unwrap()));
969 assert!(allowed.allows(&OutboundUrl::parse("example.com:8383", "http").unwrap()));
970 }
971
972 #[test]
973 fn test_allowed_hosts_with_trailing_slash() {
974 let allowed =
975 AllowedHostsConfig::parse(&["https://my.api.com/"], &dummy_resolver(), &[]).unwrap();
976 assert!(allowed.allows(&OutboundUrl::parse("https://my.api.com", "https").unwrap()));
977 assert!(allowed.allows(&OutboundUrl::parse("https://my.api.com/", "https").unwrap()));
978 }
979
980 #[test]
981 fn test_allowed_hosts_can_be_subdomain_wildcards() {
982 let allowed = AllowedHostsConfig::parse(
983 &["http://*.example.com", "http://*.example2.com:8383"],
984 &dummy_resolver(),
985 &[],
986 )
987 .unwrap();
988 assert!(
989 allowed.allows(&OutboundUrl::parse("http://a.example.com/foo/bar", "http").unwrap())
990 );
991 assert!(
992 allowed.allows(&OutboundUrl::parse("http://a.b.example.com/foo/bar", "http").unwrap())
993 );
994 assert!(allowed
995 .allows(&OutboundUrl::parse("http://a.b.example2.com:8383/foo/bar", "http").unwrap()));
996 assert!(!allowed
997 .allows(&OutboundUrl::parse("http://a.b.example2.com/foo/bar", "http").unwrap()));
998 assert!(!allowed.allows(&OutboundUrl::parse("http://example.com/foo/bar", "http").unwrap()));
999 assert!(!allowed
1000 .allows(&OutboundUrl::parse("http://example.com:8383/foo/bar", "http").unwrap()));
1001 assert!(
1002 !allowed.allows(&OutboundUrl::parse("http://myexample.com/foo/bar", "http").unwrap())
1003 );
1004 }
1005
1006 #[test]
1007 fn test_hash_char_in_db_password() {
1008 let allowed =
1009 AllowedHostsConfig::parse(&["mysql://xyz.com"], &dummy_resolver(), &[]).unwrap();
1010 assert!(
1011 allowed.allows(&OutboundUrl::parse("mysql://user:pass#word@xyz.com", "mysql").unwrap())
1012 );
1013 assert!(allowed
1014 .allows(&OutboundUrl::parse("mysql://user%3Apass%23word@xyz.com", "mysql").unwrap()));
1015 assert!(allowed.allows(&OutboundUrl::parse("user%3Apass%23word@xyz.com", "mysql").unwrap()));
1016 }
1017
1018 #[test]
1019 fn test_cidr() {
1020 let allowed =
1021 AllowedHostsConfig::parse(&["*://127.0.0.1/24:63551"], &dummy_resolver(), &[]).unwrap();
1022 assert!(allowed.allows(&OutboundUrl::parse("tcp://127.0.0.1:63551", "tcp").unwrap()));
1023 }
1024
1025 fn exact_host(ahc: &AllowedHostConfig) -> String {
1026 match ahc.host() {
1027 HostConfig::Literal(host) => host.to_string(),
1028 _ => panic!("expected host {:?} to be a literal", ahc.host()),
1029 }
1030 }
1031
1032 #[test]
1033 fn expand_wildcard_service_chaining_lists_all_components() {
1034 let component_ids = ["first", "second", "third"]
1035 .iter()
1036 .map(|s| s.to_string())
1037 .collect::<Vec<_>>();
1038 let allowed = AllowedHostsConfig::parse(
1039 &["http://*.spin.internal"],
1040 &dummy_resolver(),
1041 &component_ids,
1042 )
1043 .unwrap();
1044 let AllowedHostsConfig::SpecificHosts(allowed) = allowed else {
1045 panic!("expanded AllowedHostsConfig should be specific hosts");
1046 };
1047
1048 assert_eq!(3, allowed.len());
1049
1050 assert_eq!("first.spin.internal", exact_host(&allowed[0]));
1051 assert_eq!("second.spin.internal", exact_host(&allowed[1]));
1052 assert_eq!("third.spin.internal", exact_host(&allowed[2]));
1053 }
1054
1055 #[test]
1056 fn expand_wildcard_service_chaining_leaves_others_untouched() {
1057 let component_ids = ["first", "second", "third"]
1058 .iter()
1059 .map(|s| s.to_string())
1060 .collect::<Vec<_>>();
1061 let allowed = AllowedHostsConfig::parse(
1062 &[
1063 "pg://localhost:5656",
1064 "http://*.spin.internal",
1065 "https://spinframework.dev",
1066 ],
1067 &dummy_resolver(),
1068 &component_ids,
1069 )
1070 .unwrap();
1071 let AllowedHostsConfig::SpecificHosts(allowed) = allowed else {
1072 panic!("expanded AllowedHostsConfig should be specific hosts");
1073 };
1074
1075 assert_eq!(5, allowed.len());
1076
1077 assert_eq!("localhost", exact_host(&allowed[0]));
1078 assert_eq!("first.spin.internal", exact_host(&allowed[1]));
1079 assert_eq!("second.spin.internal", exact_host(&allowed[2]));
1080 assert_eq!("third.spin.internal", exact_host(&allowed[3]));
1081 assert_eq!("spinframework.dev", exact_host(&allowed[4]));
1082 }
1083}