1use std::ops::Range;
2use std::sync::Arc;
3
4use anyhow::{Context as _, bail, ensure};
5use futures_util::future::{BoxFuture, Shared};
6use spin_expressions::SyncResolver;
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(self, resolver: &impl SyncResolver) -> anyhow::Result<Option<AllowedHostConfig>> {
433 match self {
434 Self::Exact(h) => Ok(Some(h)),
435 Self::Unresolved(t) => {
436 let resolved = resolver.resolve_template(&t)?;
437 Self::parse_or_skip(&resolved)
438 }
439 }
440 }
441
442 fn validate(&self, resolver: &impl SyncResolver) -> anyhow::Result<()> {
445 if let Self::Unresolved(template) = self {
446 let Ok(resolved) = resolver.resolve_template(template) else {
447 return Ok(());
449 };
450
451 Self::parse_or_skip(&resolved).with_context(|| {
452 let template_str = template.to_string();
453 format!("using default variable value(s) with template {template_str:?} results in invalid config {resolved:?}")
454 })?;
455 }
456 Ok(())
457 }
458
459 fn parse_or_skip(resolved: &str) -> anyhow::Result<Option<AllowedHostConfig>> {
464 if resolved.is_empty() || resolved == "http://" || resolved == "https://" {
469 Ok(None)
470 } else {
471 AllowedHostConfig::parse(resolved).map(Some)
472 }
473 }
474}
475
476#[derive(PartialEq, Eq, Debug, Clone)]
478pub enum AllowedHostsConfig {
479 All,
480 SpecificHosts(Vec<AllowedHostConfig>),
481}
482
483impl AllowedHostsConfig {
484 pub fn parse<S: AsRef<str>>(
487 hosts: &[S],
488 resolver: &impl SyncResolver,
489 component_ids: &[String],
490 ) -> anyhow::Result<AllowedHostsConfig> {
491 let partial = Self::parse_partial(hosts)?;
492 let allowed = partial
493 .into_iter()
494 .flat_map(|p| p.resolve(resolver).transpose())
495 .collect::<anyhow::Result<Vec<_>>>()?;
496 let allowed = Self::expand_wildcard_service_chaining(allowed, component_ids);
497 Ok(Self::SpecificHosts(allowed))
498 }
499
500 pub fn validate<S: AsRef<str>>(
502 hosts: &[S],
503 resolver: &impl SyncResolver,
504 ) -> anyhow::Result<()> {
505 for partial in Self::parse_partial(hosts)? {
506 partial.validate(resolver)?;
507 }
508 Ok(())
509 }
510
511 fn parse_partial<S: AsRef<str>>(hosts: &[S]) -> anyhow::Result<Vec<PartialAllowedHostConfig>> {
514 if hosts.len() == 1 && hosts[0].as_ref() == "insecure:allow-all" {
515 bail!(
516 "'insecure:allow-all' is not allowed - use '*://*:*' instead if you really want to allow all outbound traffic'"
517 )
518 }
519 let mut allowed = Vec::with_capacity(hosts.len());
520 for host in hosts {
521 let template = spin_expressions::Template::new(host.as_ref())?;
522 if template.is_literal() {
523 allowed.push(PartialAllowedHostConfig::Exact(AllowedHostConfig::parse(
524 host.as_ref(),
525 )?));
526 } else {
527 allowed.push(PartialAllowedHostConfig::Unresolved(template));
528 }
529 }
530 Ok(allowed)
531 }
532
533 fn expand_wildcard_service_chaining(
534 hosts: Vec<AllowedHostConfig>,
535 component_ids: &[String],
536 ) -> Vec<AllowedHostConfig> {
537 let expand_one = |host: AllowedHostConfig| match host.host() {
538 HostConfig::AnySubdomain(domain) if domain == SERVICE_CHAINING_DOMAIN_SUFFIX => {
539 let expanded_domains = component_ids
540 .iter()
541 .map(|c| format!("{c}{SERVICE_CHAINING_DOMAIN_SUFFIX}"));
542 let expanded_hosts = expanded_domains.map(|d| {
543 let mut hh = host.clone();
544 hh.host = HostConfig::Literal(url::Host::Domain(d));
545 hh
546 });
547 expanded_hosts.collect()
548 }
549 _ => vec![host],
550 };
551
552 hosts.into_iter().flat_map(expand_one).collect()
553 }
554
555 pub fn allows(&self, url: &OutboundUrl) -> bool {
557 match self {
558 AllowedHostsConfig::All => true,
559 AllowedHostsConfig::SpecificHosts(hosts) => hosts.iter().any(|h| h.allows(url)),
560 }
561 }
562
563 pub fn allows_relative_url(&self, schemes: &[&str]) -> bool {
566 match self {
567 AllowedHostsConfig::All => true,
568 AllowedHostsConfig::SpecificHosts(hosts) => {
569 hosts.iter().any(|h| h.allows_relative(schemes))
570 }
571 }
572 }
573}
574
575impl Default for AllowedHostsConfig {
576 fn default() -> Self {
577 Self::SpecificHosts(Vec::new())
578 }
579}
580
581#[derive(Debug, Clone)]
583pub struct OutboundUrl {
584 scheme: String,
585 host: String,
586 port: Option<u16>,
587 original: String,
588}
589
590impl OutboundUrl {
591 pub fn parse(url: impl Into<String>, scheme: &str) -> anyhow::Result<Self> {
595 let mut url = url.into();
596 let original = url.clone();
597
598 if let Some(at) = url.find('@') {
601 let scheme_end = url.find("://").map(|e| e + 3).unwrap_or(0);
602 let path_start = url[scheme_end..]
603 .find('/') .map(|e| e + scheme_end)
605 .unwrap_or(usize::MAX);
606
607 if at < path_start {
608 let userinfo = &url[scheme_end..at];
609
610 let encoded = urlencoding::encode(userinfo);
611 let prefix = &url[..scheme_end];
612 let suffix = &url[scheme_end + userinfo.len()..];
613 url = format!("{prefix}{encoded}{suffix}");
614 }
615 }
616
617 let parsed = match url::Url::parse(&url) {
618 Ok(url) if url.has_host() => Ok(url),
619 first_try => {
620 let second_try: anyhow::Result<url::Url> = format!("{scheme}://{url}")
621 .as_str()
622 .try_into()
623 .context("could not convert into a url");
624 match (second_try, first_try.map_err(|e| e.into())) {
625 (Ok(u), _) => Ok(u),
626 (_, Err(e)) | (Err(e), _) => Err(e),
628 }
629 }
630 }?;
631
632 Ok(Self {
633 scheme: parsed.scheme().to_owned(),
634 host: parsed
635 .host_str()
636 .with_context(|| format!("{url:?} does not have a host component"))?
637 .to_owned(),
638 port: parsed.port(),
639 original,
640 })
641 }
642
643 pub fn scheme(&self) -> &str {
644 &self.scheme
645 }
646
647 pub fn authority(&self) -> String {
648 if let Some(port) = self.port {
649 format!("{}:{port}", self.host)
650 } else {
651 self.host.clone()
652 }
653 }
654}
655
656impl std::fmt::Display for OutboundUrl {
657 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
658 f.write_str(&self.original)
659 }
660}
661
662pub fn is_service_chaining_host(host: &str) -> bool {
664 parse_service_chaining_host(host).is_some()
665}
666
667pub fn parse_service_chaining_target(url: &http::Uri) -> Option<String> {
669 let host = url.authority().map(|a| a.host().trim())?;
670 parse_service_chaining_host(host)
671}
672
673fn parse_service_chaining_host(host: &str) -> Option<String> {
674 let (host, _) = host.rsplit_once(':').unwrap_or((host, ""));
675
676 let (first, rest) = host.split_once('.')?;
677
678 if rest == SERVICE_CHAINING_DOMAIN {
679 Some(first.to_owned())
680 } else {
681 None
682 }
683}
684
685#[cfg(test)]
686mod test {
687 impl AllowedHostConfig {
688 fn new(scheme: SchemeConfig, host: HostConfig, port: PortConfig) -> Self {
689 Self {
690 scheme,
691 host,
692 port,
693 original: String::new(),
694 }
695 }
696 }
697
698 impl SchemeConfig {
699 fn new(scheme: &str) -> Self {
700 Self::List(vec![scheme.into()])
701 }
702 }
703
704 impl HostConfig {
705 fn subdomain(domain: &str) -> Self {
706 Self::AnySubdomain(format!(".{domain}"))
707 }
708 }
709
710 impl PortConfig {
711 fn new(port: u16) -> Self {
712 Self::List(vec![IndividualPortConfig::Port(port)])
713 }
714
715 fn range(port: Range<u16>) -> Self {
716 Self::List(vec![IndividualPortConfig::Range(port)])
717 }
718 }
719
720 #[derive(Default)]
721 struct DummyResolver {
722 variables: std::collections::HashMap<String, String>,
723 }
724
725 impl SyncResolver for DummyResolver {
726 fn resolve_variable(&self, key: &str) -> spin_expressions::Result<String> {
727 self.variables
728 .get(key)
729 .cloned()
730 .ok_or(spin_expressions::Error::InvalidName(key.to_string()))
731 }
732 }
733
734 fn dummy_resolver() -> impl SyncResolver {
735 DummyResolver::default()
736 }
737
738 fn populated_resolver(values: &[(&str, &str)]) -> impl SyncResolver {
739 let variables = values
740 .iter()
741 .map(|(k, v)| (k.to_string(), v.to_string()))
742 .collect();
743
744 DummyResolver { variables }
745 }
746
747 fn empty_values_resolver() -> impl SyncResolver {
748 populated_resolver(&[("one", ""), ("two", "")])
749 }
750
751 use ip_network::{IpNetwork, Ipv4Network, Ipv6Network};
752
753 use super::*;
754 use std::net::{Ipv4Addr, Ipv6Addr};
755
756 #[test]
757 fn outbound_url_handles_at_in_paths() {
758 let url = "https://example.com/file@0.1.0.json";
759 let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
760 assert_eq!("example.com", url.host);
761
762 let url = "https://user:password@example.com/file@0.1.0.json";
763 let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
764 assert_eq!("example.com", url.host);
765
766 let url = "https://user:pass#word@example.com/file@0.1.0.json";
767 let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
768 assert_eq!("example.com", url.host);
769
770 let url = "https://user:password@example.com";
771 let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
772 assert_eq!("example.com", url.host);
773 }
774
775 #[test]
776 fn test_allowed_hosts_accepts_url_without_port() {
777 assert_eq!(
778 AllowedHostConfig::new(
779 SchemeConfig::new("http"),
780 HostConfig::literal("spin.fermyon.dev").unwrap(),
781 PortConfig::new(80)
782 ),
783 AllowedHostConfig::parse("http://spin.fermyon.dev").unwrap()
784 );
785
786 assert_eq!(
787 AllowedHostConfig::new(
788 SchemeConfig::new("http"),
789 HostConfig::literal("spin.fermyon.dev").unwrap(),
791 PortConfig::new(80)
792 ),
793 AllowedHostConfig::parse("http://spin.fermyon.dev/").unwrap()
794 );
795
796 assert_eq!(
797 AllowedHostConfig::new(
798 SchemeConfig::new("https"),
799 HostConfig::literal("spin.fermyon.dev").unwrap(),
800 PortConfig::new(443)
801 ),
802 AllowedHostConfig::parse("https://spin.fermyon.dev").unwrap()
803 );
804 }
805
806 #[test]
807 fn test_allowed_hosts_accepts_url_with_port() {
808 assert_eq!(
809 AllowedHostConfig::new(
810 SchemeConfig::new("http"),
811 HostConfig::literal("spin.fermyon.dev").unwrap(),
812 PortConfig::new(4444)
813 ),
814 AllowedHostConfig::parse("http://spin.fermyon.dev:4444").unwrap()
815 );
816 assert_eq!(
817 AllowedHostConfig::new(
818 SchemeConfig::new("http"),
819 HostConfig::literal("spin.fermyon.dev").unwrap(),
820 PortConfig::new(4444)
821 ),
822 AllowedHostConfig::parse("http://spin.fermyon.dev:4444/").unwrap()
823 );
824 assert_eq!(
825 AllowedHostConfig::new(
826 SchemeConfig::new("https"),
827 HostConfig::literal("spin.fermyon.dev").unwrap(),
828 PortConfig::new(5555)
829 ),
830 AllowedHostConfig::parse("https://spin.fermyon.dev:5555").unwrap()
831 );
832 }
833
834 #[test]
835 fn test_allowed_hosts_accepts_url_with_port_range() {
836 assert_eq!(
837 AllowedHostConfig::new(
838 SchemeConfig::new("http"),
839 HostConfig::literal("spin.fermyon.dev").unwrap(),
840 PortConfig::range(4444..5555)
841 ),
842 AllowedHostConfig::parse("http://spin.fermyon.dev:4444..5555").unwrap()
843 );
844 }
845
846 #[test]
847 fn test_allowed_hosts_does_not_accept_plain_host_without_port() {
848 assert!(AllowedHostConfig::parse("spin.fermyon.dev").is_err());
849 }
850
851 #[test]
852 fn test_allowed_hosts_does_not_accept_plain_host_without_scheme() {
853 assert!(AllowedHostConfig::parse("spin.fermyon.dev:80").is_err());
854 }
855
856 #[test]
857 fn test_allowed_hosts_accepts_host_with_glob_scheme() {
858 assert_eq!(
859 AllowedHostConfig::new(
860 SchemeConfig::Any,
861 HostConfig::literal("spin.fermyon.dev").unwrap(),
862 PortConfig::new(7777)
863 ),
864 AllowedHostConfig::parse("*://spin.fermyon.dev:7777").unwrap()
865 )
866 }
867
868 #[test]
869 fn test_allowed_hosts_accepts_self() {
870 assert_eq!(
871 AllowedHostConfig::new(
872 SchemeConfig::new("http"),
873 HostConfig::ToSelf,
874 PortConfig::new(80)
875 ),
876 AllowedHostConfig::parse("http://self").unwrap()
877 );
878 }
879
880 #[test]
881 fn test_allowed_hosts_accepts_localhost_addresses() {
882 assert!(AllowedHostConfig::parse("localhost").is_err());
883 assert_eq!(
884 AllowedHostConfig::new(
885 SchemeConfig::new("http"),
886 HostConfig::literal("localhost").unwrap(),
887 PortConfig::new(80)
888 ),
889 AllowedHostConfig::parse("http://localhost").unwrap()
890 );
891 assert!(AllowedHostConfig::parse("localhost:3001").is_err());
892 assert_eq!(
893 AllowedHostConfig::new(
894 SchemeConfig::new("http"),
895 HostConfig::literal("localhost").unwrap(),
896 PortConfig::new(3001)
897 ),
898 AllowedHostConfig::parse("http://localhost:3001").unwrap()
899 );
900 }
901
902 #[test]
903 fn test_allowed_hosts_accepts_subdomain_wildcards() {
904 assert_eq!(
905 AllowedHostConfig::new(
906 SchemeConfig::new("http"),
907 HostConfig::subdomain("example.com"),
908 PortConfig::new(80)
909 ),
910 AllowedHostConfig::parse("http://*.example.com").unwrap()
911 );
912 }
913
914 #[test]
915 fn test_allowed_hosts_accepts_ip_addresses() {
916 assert_eq!(
917 AllowedHostConfig::new(
918 SchemeConfig::new("http"),
919 HostConfig::literal("192.168.1.1").unwrap(),
920 PortConfig::new(80)
921 ),
922 AllowedHostConfig::parse("http://192.168.1.1").unwrap()
923 );
924 assert_eq!(
925 AllowedHostConfig::new(
926 SchemeConfig::new("http"),
927 HostConfig::literal("192.168.1.1").unwrap(),
928 PortConfig::new(3002)
929 ),
930 AllowedHostConfig::parse("http://192.168.1.1:3002").unwrap()
931 );
932 assert_eq!(
933 AllowedHostConfig::new(
934 SchemeConfig::new("http"),
935 HostConfig::literal("[::1]").unwrap(),
936 PortConfig::new(8001)
937 ),
938 AllowedHostConfig::parse("http://[::1]:8001").unwrap()
939 );
940
941 assert!(AllowedHostConfig::parse("http://[::1]").is_err())
942 }
943
944 #[test]
945 fn test_allowed_hosts_accepts_ip_cidr() {
946 assert_eq!(
947 AllowedHostConfig::new(
948 SchemeConfig::Any,
949 HostConfig::Cidr(IpNetwork::V4(
950 Ipv4Network::new(Ipv4Addr::new(127, 0, 0, 0), 24).unwrap()
951 )),
952 PortConfig::new(80)
953 ),
954 AllowedHostConfig::parse("*://127.0.0.0/24:80").unwrap()
955 );
956 assert!(AllowedHostConfig::parse("*://127.0.0.0/24").is_err());
957 assert_eq!(
958 AllowedHostConfig::new(
959 SchemeConfig::Any,
960 HostConfig::Cidr(IpNetwork::V6(
961 Ipv6Network::new(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8).unwrap()
962 )),
963 PortConfig::new(80)
964 ),
965 AllowedHostConfig::parse("*://ff00::/8:80").unwrap()
966 );
967 }
968
969 #[test]
970 fn test_allowed_hosts_rejects_path() {
971 assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/").is_ok());
973 assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/a").is_err());
975 assert!(AllowedHostConfig::parse("http://spin.fermyon.dev:6666/a/b").is_err());
976 assert!(AllowedHostConfig::parse("http://*.fermyon.dev/a").is_err());
977 }
978
979 #[test]
980 fn test_allowed_hosts_respects_allow_all() {
981 assert!(
982 AllowedHostsConfig::parse(&["insecure:allow-all"], &dummy_resolver(), &[]).is_err()
983 );
984 assert!(
985 AllowedHostsConfig::parse(
986 &["spin.fermyon.dev", "insecure:allow-all"],
987 &dummy_resolver(),
988 &[]
989 )
990 .is_err()
991 );
992 }
993
994 #[test]
995 fn test_allowed_all_globs() {
996 assert_eq!(
997 AllowedHostConfig::new(SchemeConfig::Any, HostConfig::Any, PortConfig::Any),
998 AllowedHostConfig::parse("*://*:*").unwrap()
999 );
1000 }
1001
1002 #[test]
1003 fn test_missing_scheme() {
1004 assert!(AllowedHostConfig::parse("example.com").is_err());
1005 }
1006
1007 #[test]
1008 fn test_allowed_hosts_can_be_specific() {
1009 let allowed = AllowedHostsConfig::parse(
1010 &["*://spin.fermyon.dev:443", "http://example.com:8383"],
1011 &dummy_resolver(),
1012 &[],
1013 )
1014 .unwrap();
1015 assert!(
1016 allowed.allows(&OutboundUrl::parse("http://example.com:8383/foo/bar", "http").unwrap())
1017 );
1018 assert!(allowed.allows(&OutboundUrl::parse("https://spin.fermyon.dev", "https").unwrap()));
1020 assert!(allowed.allows(&OutboundUrl::parse("https://spin.fermyon.dev/", "https").unwrap()));
1021 assert!(!allowed.allows(&OutboundUrl::parse("http://example.com/", "http").unwrap()));
1022 assert!(!allowed.allows(&OutboundUrl::parse("http://google.com/", "http").unwrap()));
1023 assert!(allowed.allows(&OutboundUrl::parse("spin.fermyon.dev:443", "https").unwrap()));
1024 assert!(allowed.allows(&OutboundUrl::parse("example.com:8383", "http").unwrap()));
1025 }
1026
1027 #[test]
1028 fn test_allowed_hosts_with_trailing_slash() {
1029 let allowed =
1030 AllowedHostsConfig::parse(&["https://my.api.com/"], &dummy_resolver(), &[]).unwrap();
1031 assert!(allowed.allows(&OutboundUrl::parse("https://my.api.com", "https").unwrap()));
1032 assert!(allowed.allows(&OutboundUrl::parse("https://my.api.com/", "https").unwrap()));
1033 }
1034
1035 #[test]
1036 fn test_allowed_hosts_can_be_subdomain_wildcards() {
1037 let allowed = AllowedHostsConfig::parse(
1038 &["http://*.example.com", "http://*.example2.com:8383"],
1039 &dummy_resolver(),
1040 &[],
1041 )
1042 .unwrap();
1043 assert!(
1044 allowed.allows(&OutboundUrl::parse("http://a.example.com/foo/bar", "http").unwrap())
1045 );
1046 assert!(
1047 allowed.allows(&OutboundUrl::parse("http://a.b.example.com/foo/bar", "http").unwrap())
1048 );
1049 assert!(
1050 allowed.allows(
1051 &OutboundUrl::parse("http://a.b.example2.com:8383/foo/bar", "http").unwrap()
1052 )
1053 );
1054 assert!(
1055 !allowed
1056 .allows(&OutboundUrl::parse("http://a.b.example2.com/foo/bar", "http").unwrap())
1057 );
1058 assert!(
1059 !allowed.allows(&OutboundUrl::parse("http://example.com/foo/bar", "http").unwrap())
1060 );
1061 assert!(
1062 !allowed
1063 .allows(&OutboundUrl::parse("http://example.com:8383/foo/bar", "http").unwrap())
1064 );
1065 assert!(
1066 !allowed.allows(&OutboundUrl::parse("http://myexample.com/foo/bar", "http").unwrap())
1067 );
1068 }
1069
1070 #[test]
1071 fn test_hash_char_in_db_password() {
1072 let allowed =
1073 AllowedHostsConfig::parse(&["mysql://xyz.com"], &dummy_resolver(), &[]).unwrap();
1074 assert!(
1075 allowed.allows(&OutboundUrl::parse("mysql://user:pass#word@xyz.com", "mysql").unwrap())
1076 );
1077 assert!(
1078 allowed.allows(
1079 &OutboundUrl::parse("mysql://user%3Apass%23word@xyz.com", "mysql").unwrap()
1080 )
1081 );
1082 assert!(
1083 allowed.allows(&OutboundUrl::parse("user%3Apass%23word@xyz.com", "mysql").unwrap())
1084 );
1085 }
1086
1087 #[test]
1088 fn test_cidr() {
1089 let allowed =
1090 AllowedHostsConfig::parse(&["*://127.0.0.1/24:63551"], &dummy_resolver(), &[]).unwrap();
1091 assert!(allowed.allows(&OutboundUrl::parse("tcp://127.0.0.1:63551", "tcp").unwrap()));
1092 }
1093
1094 fn exact_host(ahc: &AllowedHostConfig) -> String {
1095 match ahc.host() {
1096 HostConfig::Literal(host) => host.to_string(),
1097 _ => panic!("expected host {:?} to be a literal", ahc.host()),
1098 }
1099 }
1100
1101 #[test]
1102 fn expand_wildcard_service_chaining_lists_all_components() {
1103 let component_ids = ["first", "second", "third"]
1104 .iter()
1105 .map(|s| s.to_string())
1106 .collect::<Vec<_>>();
1107 let allowed = AllowedHostsConfig::parse(
1108 &["http://*.spin.internal"],
1109 &dummy_resolver(),
1110 &component_ids,
1111 )
1112 .unwrap();
1113 let AllowedHostsConfig::SpecificHosts(allowed) = allowed else {
1114 panic!("expanded AllowedHostsConfig should be specific hosts");
1115 };
1116
1117 assert_eq!(3, allowed.len());
1118
1119 assert_eq!("first.spin.internal", exact_host(&allowed[0]));
1120 assert_eq!("second.spin.internal", exact_host(&allowed[1]));
1121 assert_eq!("third.spin.internal", exact_host(&allowed[2]));
1122 }
1123
1124 #[test]
1125 fn expand_wildcard_service_chaining_leaves_others_untouched() {
1126 let component_ids = ["first", "second", "third"]
1127 .iter()
1128 .map(|s| s.to_string())
1129 .collect::<Vec<_>>();
1130 let allowed = AllowedHostsConfig::parse(
1131 &[
1132 "pg://localhost:5656",
1133 "http://*.spin.internal",
1134 "https://spinframework.dev",
1135 ],
1136 &dummy_resolver(),
1137 &component_ids,
1138 )
1139 .unwrap();
1140 let AllowedHostsConfig::SpecificHosts(allowed) = allowed else {
1141 panic!("expanded AllowedHostsConfig should be specific hosts");
1142 };
1143
1144 assert_eq!(5, allowed.len());
1145
1146 assert_eq!("localhost", exact_host(&allowed[0]));
1147 assert_eq!("first.spin.internal", exact_host(&allowed[1]));
1148 assert_eq!("second.spin.internal", exact_host(&allowed[2]));
1149 assert_eq!("third.spin.internal", exact_host(&allowed[3]));
1150 assert_eq!("spinframework.dev", exact_host(&allowed[4]));
1151 }
1152
1153 #[test]
1154 fn allowed_hosts_ignores_empty_due_to_empty_variables() {
1155 let resolver = empty_values_resolver();
1156 let hosts = &["https://{{ one }}", "{{ two }}", "https://three"];
1157
1158 let AllowedHostsConfig::SpecificHosts(allowed) =
1159 AllowedHostsConfig::parse(hosts, &resolver, &[]).expect("parse should have succeeded")
1160 else {
1161 panic!("expanded AllowedHostsConfig should be specific hosts");
1162 };
1163
1164 assert_eq!(1, allowed.len());
1165 assert_eq!("three", exact_host(&allowed[0]));
1166 }
1167
1168 #[test]
1169 fn valid_hosts_are_valid() {
1170 let resolver = dummy_resolver();
1171 let hosts = &["http://x.y", "*://my.db:*"];
1172 AllowedHostsConfig::validate(hosts, &resolver).expect("valid hosts should be valid");
1173 }
1174
1175 #[test]
1176 fn invalid_hosts_are_invalid() {
1177 let resolver = dummy_resolver();
1178 let hosts = &["http://x.y", "zootle! wurdl!", "}{ !!**"];
1179 AllowedHostsConfig::validate(hosts, &resolver)
1180 .expect_err("invalid hosts should be invalid");
1181 }
1182
1183 #[test]
1184 fn variables_make_hosts_valid() {
1185 let resolver = populated_resolver(&[("dbhost", "example.com"), ("dbport", "1234")]);
1186 let hosts = &["http://{{ dbhost }}", "http://{{ dbhost }}:{{ dbport }}"];
1187 AllowedHostsConfig::validate(hosts, &resolver).expect("happy variables make hosts valid");
1188 }
1189
1190 #[test]
1191 fn bad_variables_make_hosts_invalid() {
1192 let resolver = populated_resolver(&[("dbhost", "zoinks, Scooby!")]);
1193 let hosts = &["http://{{ dbhost }}"];
1194 AllowedHostsConfig::validate(hosts, &resolver)
1195 .expect_err("bad variables make hosts invalid");
1196 }
1197
1198 #[test]
1199 fn missing_variables_ignored_when_checking_validity() {
1200 let resolver = dummy_resolver();
1201 let hosts = &["http://{{ dbhost }}"];
1202 AllowedHostsConfig::validate(hosts, &resolver)
1203 .expect("missing variables errors should be deferred");
1204 }
1205
1206 #[test]
1207 fn empty_resolutions_ignored_when_checking_validity() {
1208 let resolver = empty_values_resolver();
1209 let hosts = &["https://{{ one }}", "{{ two }}", "https://three"];
1210 AllowedHostsConfig::validate(hosts, &resolver)
1211 .expect("empty resolutions should ignored as valid");
1212 }
1213}