spin_outbound_networking_config/
allowed_hosts.rs

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
9/// The domain used for service chaining.
10pub const SERVICE_CHAINING_DOMAIN: &str = "spin.internal";
11/// The domain suffix used for service chaining.
12pub const SERVICE_CHAINING_DOMAIN_SUFFIX: &str = ".spin.internal";
13
14/// An easily cloneable, shared, boxed future of result
15pub type SharedFutureResult<T> = Shared<BoxFuture<'static, Result<Arc<T>, Arc<anyhow::Error>>>>;
16
17/// A check for whether a URL is allowed by the outbound networking configuration.
18#[derive(Clone)]
19pub struct OutboundAllowedHosts {
20    allowed_hosts_future: SharedFutureResult<AllowedHostsConfig>,
21    disallowed_host_handler: Option<Arc<dyn DisallowedHostHandler>>,
22}
23
24impl OutboundAllowedHosts {
25    /// Creates a new `OutboundAllowedHosts` instance.
26    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    /// Checks address against allowed hosts
37    ///
38    /// Calls the [`DisallowedHostHandler`] if set and URL is disallowed.
39    /// If `url` cannot be parsed, `{scheme}://` is prepended to `url` and retried.
40    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    /// Checks if allowed hosts permit relative requests
62    ///
63    /// Calls the [`DisallowedHostHandler`] if set and relative requests are
64    /// disallowed.
65    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
93/// A trait for handling disallowed hosts
94pub trait DisallowedHostHandler: Send + Sync {
95    /// Called when a host is disallowed
96    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/// Represents a single `allowed_outbound_hosts` item.
106#[derive(Eq, Debug, Clone)]
107pub struct AllowedHostConfig {
108    original: String,
109    scheme: SchemeConfig,
110    host: HostConfig,
111    port: PortConfig,
112}
113
114impl AllowedHostConfig {
115    /// Parses the given string as an `allowed_hosts_config` item.
116    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    /// Returns true if this config is for service chaining requests.
164    pub fn is_for_service_chaining(&self) -> bool {
165        self.host.is_for_service_chaining()
166    }
167
168    /// Returns true if the given URL is allowed.
169    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    /// Returns true if relative ("self") requests to any of the given schemes
176    /// are allowed.
177    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/// Represents the scheme part of an allowed_outbound_hosts item.
195#[derive(PartialEq, Eq, Debug, Clone)]
196pub enum SchemeConfig {
197    /// Any scheme is allowed: `*://`
198    Any,
199    /// Any scheme is allowed: `*://`
200    List(Vec<String>),
201}
202
203impl SchemeConfig {
204    /// Parses the scheme part of an allowed_outbound_hosts item.
205    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    /// Returns true if any scheme is allowed (i.e. `*://`).
222    pub fn allows_any(&self) -> bool {
223        matches!(self, Self::Any)
224    }
225
226    /// Returns true if the given scheme is allowed.
227    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/// Represents the host part of an allowed_outbound_hosts item.
236#[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    /// Parses the host part of an allowed_outbound_hosts item.
247    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    /// Returns a HostConfig from the given literal host name.
286    fn literal(host: &str) -> anyhow::Result<Self> {
287        Ok(Self::Literal(Host::parse(host)?))
288    }
289
290    /// Returns true if the given host is allowed.
291    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            // AnySubdomain only matches domains
306            (HostConfig::AnySubdomain(_), _) => false,
307            // Cidr doesn't match domains
308            (HostConfig::Cidr(_), Host::Domain(_)) => false,
309            // ToSelf is checked separately with allow_relative
310            (HostConfig::ToSelf, _) => false,
311        }
312    }
313
314    /// Returns true if relative ("self") requests are allowed.
315    fn allows_relative(&self) -> bool {
316        matches!(self, Self::Any | Self::ToSelf)
317    }
318
319    /// Returns true if this config is for service chaining requests.
320    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/// Represents the port part of an allowed_outbound_hosts item.
330#[derive(Debug, PartialEq, Eq, Clone)]
331pub enum PortConfig {
332    Any,
333    List(Vec<IndividualPortConfig>),
334}
335
336impl PortConfig {
337    /// Parses the port part of an allowed_outbound_hosts item.
338    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            // TODO:
350            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    /// Returns true if the given port (or scheme-default port) is allowed.
359    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/// Represents a single port specifier in an allowed_outbound_hosts item.
374#[derive(Debug, PartialEq, Eq, Clone)]
375pub enum IndividualPortConfig {
376    Port(u16),
377    Range(Range<u16>),
378}
379
380impl IndividualPortConfig {
381    /// Parses the a single port specifier in an allowed_outbound_hosts item.
382    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    /// Returns true if the given port is allowed.
398    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
406/// Returns a well-known default port for the given URL scheme.
407fn 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
419/// Holds a single allowed_outbound_hosts item, either parsed or as an
420/// unresolved template.
421enum PartialAllowedHostConfig {
422    Exact(AllowedHostConfig),
423    Unresolved(spin_expressions::Template),
424}
425
426impl PartialAllowedHostConfig {
427    /// Returns this config, resolving any template with the given resolver.
428    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    /// Validates this config. Only templates that can be resolved with default
439    /// values from the given resolver will be fully validated.
440    fn validate(&self, resolver: &Resolver) -> anyhow::Result<()> {
441        if let Self::Unresolved(template) = self {
442            let Ok(resolved) = resolver.resolve_template(template) else {
443                // We're missing a default value so we can't validate further
444                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/// Represents an allowed_outbound_hosts config.
456#[derive(PartialEq, Eq, Debug, Clone)]
457pub enum AllowedHostsConfig {
458    All,
459    SpecificHosts(Vec<AllowedHostConfig>),
460}
461
462impl AllowedHostsConfig {
463    /// Parses the given allowed_outbound_hosts values, resolving any templates
464    /// with the given resolver.
465    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    /// Validates the given allowed_outbound_hosts values with the given resolver.
480    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    /// Parse the given allowed_outbound_hosts values with deferred parsing of
488    /// templated values.
489    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    /// Returns true if the given url is allowed.
530    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    /// Returns true if relative ("self") requests to any of the given schemes
538    /// are allowed.
539    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/// A parsed URL used for outbound networking.
556#[derive(Debug, Clone)]
557pub struct OutboundUrl {
558    scheme: String,
559    host: String,
560    port: Option<u16>,
561    original: String,
562}
563
564impl OutboundUrl {
565    /// Parses a URL.
566    ///
567    /// If parsing `url` fails, `{scheme}://` is prepended to `url` and parsing is tried again.
568    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        // Ensure that the authority is url encoded. Since the authority is ignored after this,
573        // we can always url encode the authority even if it is already encoded.
574        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('/') // This can calculate the wrong index if the username or password contains a '/'
578                .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                    // Return an error preferring the error from the first attempt if present
601                    (_, 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
636/// Checks if the host is a service chaining host.
637pub fn is_service_chaining_host(host: &str) -> bool {
638    parse_service_chaining_host(host).is_some()
639}
640
641/// Parses a service chaining target from a URL.
642pub 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                // Trailing slash is removed
737                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        // An empty path is allowed
919        assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/").is_ok());
920        // All other paths are not allowed
921        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        // Allow urls with and without a trailing slash
964        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}