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 ) -> anyhow::Result<AllowedHostsConfig> {
469 let partial = Self::parse_partial(hosts)?;
470 let allowed = partial
471 .into_iter()
472 .map(|p| p.resolve(resolver))
473 .collect::<anyhow::Result<Vec<_>>>()?;
474 Ok(Self::SpecificHosts(allowed))
475 }
476
477 pub fn validate<S: AsRef<str>>(hosts: &[S], resolver: &Resolver) -> anyhow::Result<()> {
479 for partial in Self::parse_partial(hosts)? {
480 partial.validate(resolver)?;
481 }
482 Ok(())
483 }
484
485 fn parse_partial<S: AsRef<str>>(hosts: &[S]) -> anyhow::Result<Vec<PartialAllowedHostConfig>> {
488 if hosts.len() == 1 && hosts[0].as_ref() == "insecure:allow-all" {
489 bail!("'insecure:allow-all' is not allowed - use '*://*:*' instead if you really want to allow all outbound traffic'")
490 }
491 let mut allowed = Vec::with_capacity(hosts.len());
492 for host in hosts {
493 let template = spin_expressions::Template::new(host.as_ref())?;
494 if template.is_literal() {
495 allowed.push(PartialAllowedHostConfig::Exact(AllowedHostConfig::parse(
496 host.as_ref(),
497 )?));
498 } else {
499 allowed.push(PartialAllowedHostConfig::Unresolved(template));
500 }
501 }
502 Ok(allowed)
503 }
504
505 pub fn allows(&self, url: &OutboundUrl) -> bool {
507 match self {
508 AllowedHostsConfig::All => true,
509 AllowedHostsConfig::SpecificHosts(hosts) => hosts.iter().any(|h| h.allows(url)),
510 }
511 }
512
513 pub fn allows_relative_url(&self, schemes: &[&str]) -> bool {
516 match self {
517 AllowedHostsConfig::All => true,
518 AllowedHostsConfig::SpecificHosts(hosts) => {
519 hosts.iter().any(|h| h.allows_relative(schemes))
520 }
521 }
522 }
523}
524
525impl Default for AllowedHostsConfig {
526 fn default() -> Self {
527 Self::SpecificHosts(Vec::new())
528 }
529}
530
531#[derive(Debug, Clone)]
533pub struct OutboundUrl {
534 scheme: String,
535 host: String,
536 port: Option<u16>,
537 original: String,
538}
539
540impl OutboundUrl {
541 pub fn parse(url: impl Into<String>, scheme: &str) -> anyhow::Result<Self> {
545 let mut url = url.into();
546 let original = url.clone();
547
548 if let Some(at) = url.find('@') {
551 let scheme_end = url.find("://").map(|e| e + 3).unwrap_or(0);
552 let path_start = url[scheme_end..]
553 .find('/') .map(|e| e + scheme_end)
555 .unwrap_or(usize::MAX);
556
557 if at < path_start {
558 let userinfo = &url[scheme_end..at];
559
560 let encoded = urlencoding::encode(userinfo);
561 let prefix = &url[..scheme_end];
562 let suffix = &url[scheme_end + userinfo.len()..];
563 url = format!("{prefix}{encoded}{suffix}");
564 }
565 }
566
567 let parsed = match url::Url::parse(&url) {
568 Ok(url) if url.has_host() => Ok(url),
569 first_try => {
570 let second_try: anyhow::Result<url::Url> = format!("{scheme}://{url}")
571 .as_str()
572 .try_into()
573 .context("could not convert into a url");
574 match (second_try, first_try.map_err(|e| e.into())) {
575 (Ok(u), _) => Ok(u),
576 (_, Err(e)) | (Err(e), _) => Err(e),
578 }
579 }
580 }?;
581
582 Ok(Self {
583 scheme: parsed.scheme().to_owned(),
584 host: parsed
585 .host_str()
586 .with_context(|| format!("{url:?} does not have a host component"))?
587 .to_owned(),
588 port: parsed.port(),
589 original,
590 })
591 }
592
593 pub fn scheme(&self) -> &str {
594 &self.scheme
595 }
596
597 pub fn authority(&self) -> String {
598 if let Some(port) = self.port {
599 format!("{}:{port}", self.host)
600 } else {
601 self.host.clone()
602 }
603 }
604}
605
606impl std::fmt::Display for OutboundUrl {
607 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
608 f.write_str(&self.original)
609 }
610}
611
612pub fn is_service_chaining_host(host: &str) -> bool {
614 parse_service_chaining_host(host).is_some()
615}
616
617pub fn parse_service_chaining_target(url: &http::Uri) -> Option<String> {
619 let host = url.authority().map(|a| a.host().trim())?;
620 parse_service_chaining_host(host)
621}
622
623fn parse_service_chaining_host(host: &str) -> Option<String> {
624 let (host, _) = host.rsplit_once(':').unwrap_or((host, ""));
625
626 let (first, rest) = host.split_once('.')?;
627
628 if rest == SERVICE_CHAINING_DOMAIN {
629 Some(first.to_owned())
630 } else {
631 None
632 }
633}
634
635#[cfg(test)]
636mod test {
637 impl AllowedHostConfig {
638 fn new(scheme: SchemeConfig, host: HostConfig, port: PortConfig) -> Self {
639 Self {
640 scheme,
641 host,
642 port,
643 original: String::new(),
644 }
645 }
646 }
647
648 impl SchemeConfig {
649 fn new(scheme: &str) -> Self {
650 Self::List(vec![scheme.into()])
651 }
652 }
653
654 impl HostConfig {
655 fn subdomain(domain: &str) -> Self {
656 Self::AnySubdomain(format!(".{domain}"))
657 }
658 }
659
660 impl PortConfig {
661 fn new(port: u16) -> Self {
662 Self::List(vec![IndividualPortConfig::Port(port)])
663 }
664
665 fn range(port: Range<u16>) -> Self {
666 Self::List(vec![IndividualPortConfig::Range(port)])
667 }
668 }
669
670 fn dummy_resolver() -> spin_expressions::PreparedResolver {
671 spin_expressions::PreparedResolver::default()
672 }
673
674 use ip_network::{IpNetwork, Ipv4Network, Ipv6Network};
675
676 use super::*;
677 use std::net::{Ipv4Addr, Ipv6Addr};
678
679 #[test]
680 fn outbound_url_handles_at_in_paths() {
681 let url = "https://example.com/file@0.1.0.json";
682 let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
683 assert_eq!("example.com", url.host);
684
685 let url = "https://user:password@example.com/file@0.1.0.json";
686 let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
687 assert_eq!("example.com", url.host);
688
689 let url = "https://user:pass#word@example.com/file@0.1.0.json";
690 let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
691 assert_eq!("example.com", url.host);
692
693 let url = "https://user:password@example.com";
694 let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
695 assert_eq!("example.com", url.host);
696 }
697
698 #[test]
699 fn test_allowed_hosts_accepts_url_without_port() {
700 assert_eq!(
701 AllowedHostConfig::new(
702 SchemeConfig::new("http"),
703 HostConfig::literal("spin.fermyon.dev").unwrap(),
704 PortConfig::new(80)
705 ),
706 AllowedHostConfig::parse("http://spin.fermyon.dev").unwrap()
707 );
708
709 assert_eq!(
710 AllowedHostConfig::new(
711 SchemeConfig::new("http"),
712 HostConfig::literal("spin.fermyon.dev").unwrap(),
714 PortConfig::new(80)
715 ),
716 AllowedHostConfig::parse("http://spin.fermyon.dev/").unwrap()
717 );
718
719 assert_eq!(
720 AllowedHostConfig::new(
721 SchemeConfig::new("https"),
722 HostConfig::literal("spin.fermyon.dev").unwrap(),
723 PortConfig::new(443)
724 ),
725 AllowedHostConfig::parse("https://spin.fermyon.dev").unwrap()
726 );
727 }
728
729 #[test]
730 fn test_allowed_hosts_accepts_url_with_port() {
731 assert_eq!(
732 AllowedHostConfig::new(
733 SchemeConfig::new("http"),
734 HostConfig::literal("spin.fermyon.dev").unwrap(),
735 PortConfig::new(4444)
736 ),
737 AllowedHostConfig::parse("http://spin.fermyon.dev:4444").unwrap()
738 );
739 assert_eq!(
740 AllowedHostConfig::new(
741 SchemeConfig::new("http"),
742 HostConfig::literal("spin.fermyon.dev").unwrap(),
743 PortConfig::new(4444)
744 ),
745 AllowedHostConfig::parse("http://spin.fermyon.dev:4444/").unwrap()
746 );
747 assert_eq!(
748 AllowedHostConfig::new(
749 SchemeConfig::new("https"),
750 HostConfig::literal("spin.fermyon.dev").unwrap(),
751 PortConfig::new(5555)
752 ),
753 AllowedHostConfig::parse("https://spin.fermyon.dev:5555").unwrap()
754 );
755 }
756
757 #[test]
758 fn test_allowed_hosts_accepts_url_with_port_range() {
759 assert_eq!(
760 AllowedHostConfig::new(
761 SchemeConfig::new("http"),
762 HostConfig::literal("spin.fermyon.dev").unwrap(),
763 PortConfig::range(4444..5555)
764 ),
765 AllowedHostConfig::parse("http://spin.fermyon.dev:4444..5555").unwrap()
766 );
767 }
768
769 #[test]
770 fn test_allowed_hosts_does_not_accept_plain_host_without_port() {
771 assert!(AllowedHostConfig::parse("spin.fermyon.dev").is_err());
772 }
773
774 #[test]
775 fn test_allowed_hosts_does_not_accept_plain_host_without_scheme() {
776 assert!(AllowedHostConfig::parse("spin.fermyon.dev:80").is_err());
777 }
778
779 #[test]
780 fn test_allowed_hosts_accepts_host_with_glob_scheme() {
781 assert_eq!(
782 AllowedHostConfig::new(
783 SchemeConfig::Any,
784 HostConfig::literal("spin.fermyon.dev").unwrap(),
785 PortConfig::new(7777)
786 ),
787 AllowedHostConfig::parse("*://spin.fermyon.dev:7777").unwrap()
788 )
789 }
790
791 #[test]
792 fn test_allowed_hosts_accepts_self() {
793 assert_eq!(
794 AllowedHostConfig::new(
795 SchemeConfig::new("http"),
796 HostConfig::ToSelf,
797 PortConfig::new(80)
798 ),
799 AllowedHostConfig::parse("http://self").unwrap()
800 );
801 }
802
803 #[test]
804 fn test_allowed_hosts_accepts_localhost_addresses() {
805 assert!(AllowedHostConfig::parse("localhost").is_err());
806 assert_eq!(
807 AllowedHostConfig::new(
808 SchemeConfig::new("http"),
809 HostConfig::literal("localhost").unwrap(),
810 PortConfig::new(80)
811 ),
812 AllowedHostConfig::parse("http://localhost").unwrap()
813 );
814 assert!(AllowedHostConfig::parse("localhost:3001").is_err());
815 assert_eq!(
816 AllowedHostConfig::new(
817 SchemeConfig::new("http"),
818 HostConfig::literal("localhost").unwrap(),
819 PortConfig::new(3001)
820 ),
821 AllowedHostConfig::parse("http://localhost:3001").unwrap()
822 );
823 }
824
825 #[test]
826 fn test_allowed_hosts_accepts_subdomain_wildcards() {
827 assert_eq!(
828 AllowedHostConfig::new(
829 SchemeConfig::new("http"),
830 HostConfig::subdomain("example.com"),
831 PortConfig::new(80)
832 ),
833 AllowedHostConfig::parse("http://*.example.com").unwrap()
834 );
835 }
836
837 #[test]
838 fn test_allowed_hosts_accepts_ip_addresses() {
839 assert_eq!(
840 AllowedHostConfig::new(
841 SchemeConfig::new("http"),
842 HostConfig::literal("192.168.1.1").unwrap(),
843 PortConfig::new(80)
844 ),
845 AllowedHostConfig::parse("http://192.168.1.1").unwrap()
846 );
847 assert_eq!(
848 AllowedHostConfig::new(
849 SchemeConfig::new("http"),
850 HostConfig::literal("192.168.1.1").unwrap(),
851 PortConfig::new(3002)
852 ),
853 AllowedHostConfig::parse("http://192.168.1.1:3002").unwrap()
854 );
855 assert_eq!(
856 AllowedHostConfig::new(
857 SchemeConfig::new("http"),
858 HostConfig::literal("[::1]").unwrap(),
859 PortConfig::new(8001)
860 ),
861 AllowedHostConfig::parse("http://[::1]:8001").unwrap()
862 );
863
864 assert!(AllowedHostConfig::parse("http://[::1]").is_err())
865 }
866
867 #[test]
868 fn test_allowed_hosts_accepts_ip_cidr() {
869 assert_eq!(
870 AllowedHostConfig::new(
871 SchemeConfig::Any,
872 HostConfig::Cidr(IpNetwork::V4(
873 Ipv4Network::new(Ipv4Addr::new(127, 0, 0, 0), 24).unwrap()
874 )),
875 PortConfig::new(80)
876 ),
877 AllowedHostConfig::parse("*://127.0.0.0/24:80").unwrap()
878 );
879 assert!(AllowedHostConfig::parse("*://127.0.0.0/24").is_err());
880 assert_eq!(
881 AllowedHostConfig::new(
882 SchemeConfig::Any,
883 HostConfig::Cidr(IpNetwork::V6(
884 Ipv6Network::new(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8).unwrap()
885 )),
886 PortConfig::new(80)
887 ),
888 AllowedHostConfig::parse("*://ff00::/8:80").unwrap()
889 );
890 }
891
892 #[test]
893 fn test_allowed_hosts_rejects_path() {
894 assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/").is_ok());
896 assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/a").is_err());
898 assert!(AllowedHostConfig::parse("http://spin.fermyon.dev:6666/a/b").is_err());
899 assert!(AllowedHostConfig::parse("http://*.fermyon.dev/a").is_err());
900 }
901
902 #[test]
903 fn test_allowed_hosts_respects_allow_all() {
904 assert!(AllowedHostsConfig::parse(&["insecure:allow-all"], &dummy_resolver()).is_err());
905 assert!(AllowedHostsConfig::parse(
906 &["spin.fermyon.dev", "insecure:allow-all"],
907 &dummy_resolver()
908 )
909 .is_err());
910 }
911
912 #[test]
913 fn test_allowed_all_globs() {
914 assert_eq!(
915 AllowedHostConfig::new(SchemeConfig::Any, HostConfig::Any, PortConfig::Any),
916 AllowedHostConfig::parse("*://*:*").unwrap()
917 );
918 }
919
920 #[test]
921 fn test_missing_scheme() {
922 assert!(AllowedHostConfig::parse("example.com").is_err());
923 }
924
925 #[test]
926 fn test_allowed_hosts_can_be_specific() {
927 let allowed = AllowedHostsConfig::parse(
928 &["*://spin.fermyon.dev:443", "http://example.com:8383"],
929 &dummy_resolver(),
930 )
931 .unwrap();
932 assert!(
933 allowed.allows(&OutboundUrl::parse("http://example.com:8383/foo/bar", "http").unwrap())
934 );
935 assert!(allowed.allows(&OutboundUrl::parse("https://spin.fermyon.dev", "https").unwrap()));
937 assert!(allowed.allows(&OutboundUrl::parse("https://spin.fermyon.dev/", "https").unwrap()));
938 assert!(!allowed.allows(&OutboundUrl::parse("http://example.com/", "http").unwrap()));
939 assert!(!allowed.allows(&OutboundUrl::parse("http://google.com/", "http").unwrap()));
940 assert!(allowed.allows(&OutboundUrl::parse("spin.fermyon.dev:443", "https").unwrap()));
941 assert!(allowed.allows(&OutboundUrl::parse("example.com:8383", "http").unwrap()));
942 }
943
944 #[test]
945 fn test_allowed_hosts_with_trailing_slash() {
946 let allowed =
947 AllowedHostsConfig::parse(&["https://my.api.com/"], &dummy_resolver()).unwrap();
948 assert!(allowed.allows(&OutboundUrl::parse("https://my.api.com", "https").unwrap()));
949 assert!(allowed.allows(&OutboundUrl::parse("https://my.api.com/", "https").unwrap()));
950 }
951
952 #[test]
953 fn test_allowed_hosts_can_be_subdomain_wildcards() {
954 let allowed = AllowedHostsConfig::parse(
955 &["http://*.example.com", "http://*.example2.com:8383"],
956 &dummy_resolver(),
957 )
958 .unwrap();
959 assert!(
960 allowed.allows(&OutboundUrl::parse("http://a.example.com/foo/bar", "http").unwrap())
961 );
962 assert!(
963 allowed.allows(&OutboundUrl::parse("http://a.b.example.com/foo/bar", "http").unwrap())
964 );
965 assert!(allowed
966 .allows(&OutboundUrl::parse("http://a.b.example2.com:8383/foo/bar", "http").unwrap()));
967 assert!(!allowed
968 .allows(&OutboundUrl::parse("http://a.b.example2.com/foo/bar", "http").unwrap()));
969 assert!(!allowed.allows(&OutboundUrl::parse("http://example.com/foo/bar", "http").unwrap()));
970 assert!(!allowed
971 .allows(&OutboundUrl::parse("http://example.com:8383/foo/bar", "http").unwrap()));
972 assert!(
973 !allowed.allows(&OutboundUrl::parse("http://myexample.com/foo/bar", "http").unwrap())
974 );
975 }
976
977 #[test]
978 fn test_hash_char_in_db_password() {
979 let allowed = AllowedHostsConfig::parse(&["mysql://xyz.com"], &dummy_resolver()).unwrap();
980 assert!(
981 allowed.allows(&OutboundUrl::parse("mysql://user:pass#word@xyz.com", "mysql").unwrap())
982 );
983 assert!(allowed
984 .allows(&OutboundUrl::parse("mysql://user%3Apass%23word@xyz.com", "mysql").unwrap()));
985 assert!(allowed.allows(&OutboundUrl::parse("user%3Apass%23word@xyz.com", "mysql").unwrap()));
986 }
987
988 #[test]
989 fn test_cidr() {
990 let allowed =
991 AllowedHostsConfig::parse(&["*://127.0.0.1/24:63551"], &dummy_resolver()).unwrap();
992 assert!(allowed.allows(&OutboundUrl::parse("tcp://127.0.0.1:63551", "tcp").unwrap()));
993 }
994}