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