Skip to main content

spin_outbound_networking_config/
allowed_hosts.rs

1use std::ops::Range;
2use std::sync::Arc;
3
4use anyhow::{Context as _, bail, ensure};
5use futures_util::future::{BoxFuture, Shared};
6use spin_expressions::Resolver;
7use url::Host;
8
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!(
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    /// Returns true if this config is for service chaining requests.
168    pub fn is_for_service_chaining(&self) -> bool {
169        self.host.is_for_service_chaining()
170    }
171
172    /// Returns true if the given URL is allowed.
173    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    /// Returns true if relative ("self") requests to any of the given schemes
180    /// are allowed.
181    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/// Represents the scheme part of an allowed_outbound_hosts item.
199#[derive(PartialEq, Eq, Debug, Clone)]
200pub enum SchemeConfig {
201    /// Any scheme is allowed: `*://`
202    Any,
203    /// Any scheme is allowed: `*://`
204    List(Vec<String>),
205}
206
207impl SchemeConfig {
208    /// Parses the scheme part of an allowed_outbound_hosts item.
209    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    /// Returns true if any scheme is allowed (i.e. `*://`).
226    pub fn allows_any(&self) -> bool {
227        matches!(self, Self::Any)
228    }
229
230    /// Returns true if the given scheme is allowed.
231    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/// Represents the host part of an allowed_outbound_hosts item.
240#[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    /// Parses the host part of an allowed_outbound_hosts item.
251    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    /// Returns a HostConfig from the given literal host name.
290    fn literal(host: &str) -> anyhow::Result<Self> {
291        Ok(Self::Literal(Host::parse(host)?))
292    }
293
294    /// Returns true if the given host is allowed.
295    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            // AnySubdomain only matches domains
310            (HostConfig::AnySubdomain(_), _) => false,
311            // Cidr doesn't match domains
312            (HostConfig::Cidr(_), Host::Domain(_)) => false,
313            // ToSelf is checked separately with allow_relative
314            (HostConfig::ToSelf, _) => false,
315        }
316    }
317
318    /// Returns true if relative ("self") requests are allowed.
319    fn allows_relative(&self) -> bool {
320        matches!(self, Self::Any | Self::ToSelf)
321    }
322
323    /// Returns true if this config is for service chaining requests.
324    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/// Represents the port part of an allowed_outbound_hosts item.
334#[derive(Debug, PartialEq, Eq, Clone)]
335pub enum PortConfig {
336    Any,
337    List(Vec<IndividualPortConfig>),
338}
339
340impl PortConfig {
341    /// Parses the port part of an allowed_outbound_hosts item.
342    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            // TODO:
354            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    /// Returns true if the given port (or scheme-default port) is allowed.
363    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/// Represents a single port specifier in an allowed_outbound_hosts item.
378#[derive(Debug, PartialEq, Eq, Clone)]
379pub enum IndividualPortConfig {
380    Port(u16),
381    Range(Range<u16>),
382}
383
384impl IndividualPortConfig {
385    /// Parses the a single port specifier in an allowed_outbound_hosts item.
386    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    /// Returns true if the given port is allowed.
402    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
410/// Returns a well-known default port for the given URL scheme.
411fn 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
423/// Holds a single allowed_outbound_hosts item, either parsed or as an
424/// unresolved template.
425enum PartialAllowedHostConfig {
426    Exact(AllowedHostConfig),
427    Unresolved(spin_expressions::Template),
428}
429
430impl PartialAllowedHostConfig {
431    /// Returns this config, resolving any template with the given resolver.
432    fn resolve(
433        self,
434        resolver: &spin_expressions::PreparedResolver,
435    ) -> anyhow::Result<AllowedHostConfig> {
436        match self {
437            Self::Exact(h) => Ok(h),
438            Self::Unresolved(t) => AllowedHostConfig::parse(resolver.resolve_template(&t)?),
439        }
440    }
441
442    /// Validates this config. Only templates that can be resolved with default
443    /// values from the given resolver will be fully validated.
444    fn validate(&self, resolver: &Resolver) -> anyhow::Result<()> {
445        if let Self::Unresolved(template) = self {
446            let Ok(resolved) = resolver.resolve_template(template) else {
447                // We're missing a default value so we can't validate further
448                return Ok(());
449            };
450            AllowedHostConfig::parse(&resolved).with_context(|| {
451                let template_str = template.to_string();
452                format!("using default variable value(s) with template {template_str:?} results in invalid config {resolved:?}")
453            })?;
454        }
455        Ok(())
456    }
457}
458
459/// Represents an allowed_outbound_hosts config.
460#[derive(PartialEq, Eq, Debug, Clone)]
461pub enum AllowedHostsConfig {
462    All,
463    SpecificHosts(Vec<AllowedHostConfig>),
464}
465
466impl AllowedHostsConfig {
467    /// Parses the given allowed_outbound_hosts values, resolving any templates
468    /// with the given resolver.
469    pub fn parse<S: AsRef<str>>(
470        hosts: &[S],
471        resolver: &spin_expressions::PreparedResolver,
472        component_ids: &[String],
473    ) -> anyhow::Result<AllowedHostsConfig> {
474        let partial = Self::parse_partial(hosts)?;
475        let allowed = partial
476            .into_iter()
477            .map(|p| p.resolve(resolver))
478            .collect::<anyhow::Result<Vec<_>>>()?;
479        let allowed = Self::expand_wildcard_service_chaining(allowed, component_ids);
480        Ok(Self::SpecificHosts(allowed))
481    }
482
483    /// Validates the given allowed_outbound_hosts values with the given resolver.
484    pub fn validate<S: AsRef<str>>(hosts: &[S], resolver: &Resolver) -> anyhow::Result<()> {
485        for partial in Self::parse_partial(hosts)? {
486            partial.validate(resolver)?;
487        }
488        Ok(())
489    }
490
491    /// Parse the given allowed_outbound_hosts values with deferred parsing of
492    /// templated values.
493    fn parse_partial<S: AsRef<str>>(hosts: &[S]) -> anyhow::Result<Vec<PartialAllowedHostConfig>> {
494        if hosts.len() == 1 && hosts[0].as_ref() == "insecure:allow-all" {
495            bail!(
496                "'insecure:allow-all' is not allowed - use '*://*:*' instead if you really want to allow all outbound traffic'"
497            )
498        }
499        let mut allowed = Vec::with_capacity(hosts.len());
500        for host in hosts {
501            let template = spin_expressions::Template::new(host.as_ref())?;
502            if template.is_literal() {
503                allowed.push(PartialAllowedHostConfig::Exact(AllowedHostConfig::parse(
504                    host.as_ref(),
505                )?));
506            } else {
507                allowed.push(PartialAllowedHostConfig::Unresolved(template));
508            }
509        }
510        Ok(allowed)
511    }
512
513    fn expand_wildcard_service_chaining(
514        hosts: Vec<AllowedHostConfig>,
515        component_ids: &[String],
516    ) -> Vec<AllowedHostConfig> {
517        let expand_one = |host: AllowedHostConfig| match host.host() {
518            HostConfig::AnySubdomain(domain) if domain == SERVICE_CHAINING_DOMAIN_SUFFIX => {
519                let expanded_domains = component_ids
520                    .iter()
521                    .map(|c| format!("{c}{SERVICE_CHAINING_DOMAIN_SUFFIX}"));
522                let expanded_hosts = expanded_domains.map(|d| {
523                    let mut hh = host.clone();
524                    hh.host = HostConfig::Literal(url::Host::Domain(d));
525                    hh
526                });
527                expanded_hosts.collect()
528            }
529            _ => vec![host],
530        };
531
532        hosts.into_iter().flat_map(expand_one).collect()
533    }
534
535    /// Returns true if the given url is allowed.
536    pub fn allows(&self, url: &OutboundUrl) -> bool {
537        match self {
538            AllowedHostsConfig::All => true,
539            AllowedHostsConfig::SpecificHosts(hosts) => hosts.iter().any(|h| h.allows(url)),
540        }
541    }
542
543    /// Returns true if relative ("self") requests to any of the given schemes
544    /// are allowed.
545    pub fn allows_relative_url(&self, schemes: &[&str]) -> bool {
546        match self {
547            AllowedHostsConfig::All => true,
548            AllowedHostsConfig::SpecificHosts(hosts) => {
549                hosts.iter().any(|h| h.allows_relative(schemes))
550            }
551        }
552    }
553}
554
555impl Default for AllowedHostsConfig {
556    fn default() -> Self {
557        Self::SpecificHosts(Vec::new())
558    }
559}
560
561/// A parsed URL used for outbound networking.
562#[derive(Debug, Clone)]
563pub struct OutboundUrl {
564    scheme: String,
565    host: String,
566    port: Option<u16>,
567    original: String,
568}
569
570impl OutboundUrl {
571    /// Parses a URL.
572    ///
573    /// If parsing `url` fails, `{scheme}://` is prepended to `url` and parsing is tried again.
574    pub fn parse(url: impl Into<String>, scheme: &str) -> anyhow::Result<Self> {
575        let mut url = url.into();
576        let original = url.clone();
577
578        // Ensure that the authority is url encoded. Since the authority is ignored after this,
579        // we can always url encode the authority even if it is already encoded.
580        if let Some(at) = url.find('@') {
581            let scheme_end = url.find("://").map(|e| e + 3).unwrap_or(0);
582            let path_start = url[scheme_end..]
583                .find('/') // This can calculate the wrong index if the username or password contains a '/'
584                .map(|e| e + scheme_end)
585                .unwrap_or(usize::MAX);
586
587            if at < path_start {
588                let userinfo = &url[scheme_end..at];
589
590                let encoded = urlencoding::encode(userinfo);
591                let prefix = &url[..scheme_end];
592                let suffix = &url[scheme_end + userinfo.len()..];
593                url = format!("{prefix}{encoded}{suffix}");
594            }
595        }
596
597        let parsed = match url::Url::parse(&url) {
598            Ok(url) if url.has_host() => Ok(url),
599            first_try => {
600                let second_try: anyhow::Result<url::Url> = format!("{scheme}://{url}")
601                    .as_str()
602                    .try_into()
603                    .context("could not convert into a url");
604                match (second_try, first_try.map_err(|e| e.into())) {
605                    (Ok(u), _) => Ok(u),
606                    // Return an error preferring the error from the first attempt if present
607                    (_, Err(e)) | (Err(e), _) => Err(e),
608                }
609            }
610        }?;
611
612        Ok(Self {
613            scheme: parsed.scheme().to_owned(),
614            host: parsed
615                .host_str()
616                .with_context(|| format!("{url:?} does not have a host component"))?
617                .to_owned(),
618            port: parsed.port(),
619            original,
620        })
621    }
622
623    pub fn scheme(&self) -> &str {
624        &self.scheme
625    }
626
627    pub fn authority(&self) -> String {
628        if let Some(port) = self.port {
629            format!("{}:{port}", self.host)
630        } else {
631            self.host.clone()
632        }
633    }
634}
635
636impl std::fmt::Display for OutboundUrl {
637    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
638        f.write_str(&self.original)
639    }
640}
641
642/// Checks if the host is a service chaining host.
643pub fn is_service_chaining_host(host: &str) -> bool {
644    parse_service_chaining_host(host).is_some()
645}
646
647/// Parses a service chaining target from a URL.
648pub fn parse_service_chaining_target(url: &http::Uri) -> Option<String> {
649    let host = url.authority().map(|a| a.host().trim())?;
650    parse_service_chaining_host(host)
651}
652
653fn parse_service_chaining_host(host: &str) -> Option<String> {
654    let (host, _) = host.rsplit_once(':').unwrap_or((host, ""));
655
656    let (first, rest) = host.split_once('.')?;
657
658    if rest == SERVICE_CHAINING_DOMAIN {
659        Some(first.to_owned())
660    } else {
661        None
662    }
663}
664
665#[cfg(test)]
666mod test {
667    impl AllowedHostConfig {
668        fn new(scheme: SchemeConfig, host: HostConfig, port: PortConfig) -> Self {
669            Self {
670                scheme,
671                host,
672                port,
673                original: String::new(),
674            }
675        }
676    }
677
678    impl SchemeConfig {
679        fn new(scheme: &str) -> Self {
680            Self::List(vec![scheme.into()])
681        }
682    }
683
684    impl HostConfig {
685        fn subdomain(domain: &str) -> Self {
686            Self::AnySubdomain(format!(".{domain}"))
687        }
688    }
689
690    impl PortConfig {
691        fn new(port: u16) -> Self {
692            Self::List(vec![IndividualPortConfig::Port(port)])
693        }
694
695        fn range(port: Range<u16>) -> Self {
696            Self::List(vec![IndividualPortConfig::Range(port)])
697        }
698    }
699
700    fn dummy_resolver() -> spin_expressions::PreparedResolver {
701        spin_expressions::PreparedResolver::default()
702    }
703
704    use ip_network::{IpNetwork, Ipv4Network, Ipv6Network};
705
706    use super::*;
707    use std::net::{Ipv4Addr, Ipv6Addr};
708
709    #[test]
710    fn outbound_url_handles_at_in_paths() {
711        let url = "https://example.com/file@0.1.0.json";
712        let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
713        assert_eq!("example.com", url.host);
714
715        let url = "https://user:password@example.com/file@0.1.0.json";
716        let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
717        assert_eq!("example.com", url.host);
718
719        let url = "https://user:pass#word@example.com/file@0.1.0.json";
720        let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
721        assert_eq!("example.com", url.host);
722
723        let url = "https://user:password@example.com";
724        let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
725        assert_eq!("example.com", url.host);
726    }
727
728    #[test]
729    fn test_allowed_hosts_accepts_url_without_port() {
730        assert_eq!(
731            AllowedHostConfig::new(
732                SchemeConfig::new("http"),
733                HostConfig::literal("spin.fermyon.dev").unwrap(),
734                PortConfig::new(80)
735            ),
736            AllowedHostConfig::parse("http://spin.fermyon.dev").unwrap()
737        );
738
739        assert_eq!(
740            AllowedHostConfig::new(
741                SchemeConfig::new("http"),
742                // Trailing slash is removed
743                HostConfig::literal("spin.fermyon.dev").unwrap(),
744                PortConfig::new(80)
745            ),
746            AllowedHostConfig::parse("http://spin.fermyon.dev/").unwrap()
747        );
748
749        assert_eq!(
750            AllowedHostConfig::new(
751                SchemeConfig::new("https"),
752                HostConfig::literal("spin.fermyon.dev").unwrap(),
753                PortConfig::new(443)
754            ),
755            AllowedHostConfig::parse("https://spin.fermyon.dev").unwrap()
756        );
757    }
758
759    #[test]
760    fn test_allowed_hosts_accepts_url_with_port() {
761        assert_eq!(
762            AllowedHostConfig::new(
763                SchemeConfig::new("http"),
764                HostConfig::literal("spin.fermyon.dev").unwrap(),
765                PortConfig::new(4444)
766            ),
767            AllowedHostConfig::parse("http://spin.fermyon.dev:4444").unwrap()
768        );
769        assert_eq!(
770            AllowedHostConfig::new(
771                SchemeConfig::new("http"),
772                HostConfig::literal("spin.fermyon.dev").unwrap(),
773                PortConfig::new(4444)
774            ),
775            AllowedHostConfig::parse("http://spin.fermyon.dev:4444/").unwrap()
776        );
777        assert_eq!(
778            AllowedHostConfig::new(
779                SchemeConfig::new("https"),
780                HostConfig::literal("spin.fermyon.dev").unwrap(),
781                PortConfig::new(5555)
782            ),
783            AllowedHostConfig::parse("https://spin.fermyon.dev:5555").unwrap()
784        );
785    }
786
787    #[test]
788    fn test_allowed_hosts_accepts_url_with_port_range() {
789        assert_eq!(
790            AllowedHostConfig::new(
791                SchemeConfig::new("http"),
792                HostConfig::literal("spin.fermyon.dev").unwrap(),
793                PortConfig::range(4444..5555)
794            ),
795            AllowedHostConfig::parse("http://spin.fermyon.dev:4444..5555").unwrap()
796        );
797    }
798
799    #[test]
800    fn test_allowed_hosts_does_not_accept_plain_host_without_port() {
801        assert!(AllowedHostConfig::parse("spin.fermyon.dev").is_err());
802    }
803
804    #[test]
805    fn test_allowed_hosts_does_not_accept_plain_host_without_scheme() {
806        assert!(AllowedHostConfig::parse("spin.fermyon.dev:80").is_err());
807    }
808
809    #[test]
810    fn test_allowed_hosts_accepts_host_with_glob_scheme() {
811        assert_eq!(
812            AllowedHostConfig::new(
813                SchemeConfig::Any,
814                HostConfig::literal("spin.fermyon.dev").unwrap(),
815                PortConfig::new(7777)
816            ),
817            AllowedHostConfig::parse("*://spin.fermyon.dev:7777").unwrap()
818        )
819    }
820
821    #[test]
822    fn test_allowed_hosts_accepts_self() {
823        assert_eq!(
824            AllowedHostConfig::new(
825                SchemeConfig::new("http"),
826                HostConfig::ToSelf,
827                PortConfig::new(80)
828            ),
829            AllowedHostConfig::parse("http://self").unwrap()
830        );
831    }
832
833    #[test]
834    fn test_allowed_hosts_accepts_localhost_addresses() {
835        assert!(AllowedHostConfig::parse("localhost").is_err());
836        assert_eq!(
837            AllowedHostConfig::new(
838                SchemeConfig::new("http"),
839                HostConfig::literal("localhost").unwrap(),
840                PortConfig::new(80)
841            ),
842            AllowedHostConfig::parse("http://localhost").unwrap()
843        );
844        assert!(AllowedHostConfig::parse("localhost:3001").is_err());
845        assert_eq!(
846            AllowedHostConfig::new(
847                SchemeConfig::new("http"),
848                HostConfig::literal("localhost").unwrap(),
849                PortConfig::new(3001)
850            ),
851            AllowedHostConfig::parse("http://localhost:3001").unwrap()
852        );
853    }
854
855    #[test]
856    fn test_allowed_hosts_accepts_subdomain_wildcards() {
857        assert_eq!(
858            AllowedHostConfig::new(
859                SchemeConfig::new("http"),
860                HostConfig::subdomain("example.com"),
861                PortConfig::new(80)
862            ),
863            AllowedHostConfig::parse("http://*.example.com").unwrap()
864        );
865    }
866
867    #[test]
868    fn test_allowed_hosts_accepts_ip_addresses() {
869        assert_eq!(
870            AllowedHostConfig::new(
871                SchemeConfig::new("http"),
872                HostConfig::literal("192.168.1.1").unwrap(),
873                PortConfig::new(80)
874            ),
875            AllowedHostConfig::parse("http://192.168.1.1").unwrap()
876        );
877        assert_eq!(
878            AllowedHostConfig::new(
879                SchemeConfig::new("http"),
880                HostConfig::literal("192.168.1.1").unwrap(),
881                PortConfig::new(3002)
882            ),
883            AllowedHostConfig::parse("http://192.168.1.1:3002").unwrap()
884        );
885        assert_eq!(
886            AllowedHostConfig::new(
887                SchemeConfig::new("http"),
888                HostConfig::literal("[::1]").unwrap(),
889                PortConfig::new(8001)
890            ),
891            AllowedHostConfig::parse("http://[::1]:8001").unwrap()
892        );
893
894        assert!(AllowedHostConfig::parse("http://[::1]").is_err())
895    }
896
897    #[test]
898    fn test_allowed_hosts_accepts_ip_cidr() {
899        assert_eq!(
900            AllowedHostConfig::new(
901                SchemeConfig::Any,
902                HostConfig::Cidr(IpNetwork::V4(
903                    Ipv4Network::new(Ipv4Addr::new(127, 0, 0, 0), 24).unwrap()
904                )),
905                PortConfig::new(80)
906            ),
907            AllowedHostConfig::parse("*://127.0.0.0/24:80").unwrap()
908        );
909        assert!(AllowedHostConfig::parse("*://127.0.0.0/24").is_err());
910        assert_eq!(
911            AllowedHostConfig::new(
912                SchemeConfig::Any,
913                HostConfig::Cidr(IpNetwork::V6(
914                    Ipv6Network::new(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8).unwrap()
915                )),
916                PortConfig::new(80)
917            ),
918            AllowedHostConfig::parse("*://ff00::/8:80").unwrap()
919        );
920    }
921
922    #[test]
923    fn test_allowed_hosts_rejects_path() {
924        // An empty path is allowed
925        assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/").is_ok());
926        // All other paths are not allowed
927        assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/a").is_err());
928        assert!(AllowedHostConfig::parse("http://spin.fermyon.dev:6666/a/b").is_err());
929        assert!(AllowedHostConfig::parse("http://*.fermyon.dev/a").is_err());
930    }
931
932    #[test]
933    fn test_allowed_hosts_respects_allow_all() {
934        assert!(
935            AllowedHostsConfig::parse(&["insecure:allow-all"], &dummy_resolver(), &[]).is_err()
936        );
937        assert!(
938            AllowedHostsConfig::parse(
939                &["spin.fermyon.dev", "insecure:allow-all"],
940                &dummy_resolver(),
941                &[]
942            )
943            .is_err()
944        );
945    }
946
947    #[test]
948    fn test_allowed_all_globs() {
949        assert_eq!(
950            AllowedHostConfig::new(SchemeConfig::Any, HostConfig::Any, PortConfig::Any),
951            AllowedHostConfig::parse("*://*:*").unwrap()
952        );
953    }
954
955    #[test]
956    fn test_missing_scheme() {
957        assert!(AllowedHostConfig::parse("example.com").is_err());
958    }
959
960    #[test]
961    fn test_allowed_hosts_can_be_specific() {
962        let allowed = AllowedHostsConfig::parse(
963            &["*://spin.fermyon.dev:443", "http://example.com:8383"],
964            &dummy_resolver(),
965            &[],
966        )
967        .unwrap();
968        assert!(
969            allowed.allows(&OutboundUrl::parse("http://example.com:8383/foo/bar", "http").unwrap())
970        );
971        // Allow urls with and without a trailing slash
972        assert!(allowed.allows(&OutboundUrl::parse("https://spin.fermyon.dev", "https").unwrap()));
973        assert!(allowed.allows(&OutboundUrl::parse("https://spin.fermyon.dev/", "https").unwrap()));
974        assert!(!allowed.allows(&OutboundUrl::parse("http://example.com/", "http").unwrap()));
975        assert!(!allowed.allows(&OutboundUrl::parse("http://google.com/", "http").unwrap()));
976        assert!(allowed.allows(&OutboundUrl::parse("spin.fermyon.dev:443", "https").unwrap()));
977        assert!(allowed.allows(&OutboundUrl::parse("example.com:8383", "http").unwrap()));
978    }
979
980    #[test]
981    fn test_allowed_hosts_with_trailing_slash() {
982        let allowed =
983            AllowedHostsConfig::parse(&["https://my.api.com/"], &dummy_resolver(), &[]).unwrap();
984        assert!(allowed.allows(&OutboundUrl::parse("https://my.api.com", "https").unwrap()));
985        assert!(allowed.allows(&OutboundUrl::parse("https://my.api.com/", "https").unwrap()));
986    }
987
988    #[test]
989    fn test_allowed_hosts_can_be_subdomain_wildcards() {
990        let allowed = AllowedHostsConfig::parse(
991            &["http://*.example.com", "http://*.example2.com:8383"],
992            &dummy_resolver(),
993            &[],
994        )
995        .unwrap();
996        assert!(
997            allowed.allows(&OutboundUrl::parse("http://a.example.com/foo/bar", "http").unwrap())
998        );
999        assert!(
1000            allowed.allows(&OutboundUrl::parse("http://a.b.example.com/foo/bar", "http").unwrap())
1001        );
1002        assert!(
1003            allowed.allows(
1004                &OutboundUrl::parse("http://a.b.example2.com:8383/foo/bar", "http").unwrap()
1005            )
1006        );
1007        assert!(
1008            !allowed
1009                .allows(&OutboundUrl::parse("http://a.b.example2.com/foo/bar", "http").unwrap())
1010        );
1011        assert!(
1012            !allowed.allows(&OutboundUrl::parse("http://example.com/foo/bar", "http").unwrap())
1013        );
1014        assert!(
1015            !allowed
1016                .allows(&OutboundUrl::parse("http://example.com:8383/foo/bar", "http").unwrap())
1017        );
1018        assert!(
1019            !allowed.allows(&OutboundUrl::parse("http://myexample.com/foo/bar", "http").unwrap())
1020        );
1021    }
1022
1023    #[test]
1024    fn test_hash_char_in_db_password() {
1025        let allowed =
1026            AllowedHostsConfig::parse(&["mysql://xyz.com"], &dummy_resolver(), &[]).unwrap();
1027        assert!(
1028            allowed.allows(&OutboundUrl::parse("mysql://user:pass#word@xyz.com", "mysql").unwrap())
1029        );
1030        assert!(
1031            allowed.allows(
1032                &OutboundUrl::parse("mysql://user%3Apass%23word@xyz.com", "mysql").unwrap()
1033            )
1034        );
1035        assert!(
1036            allowed.allows(&OutboundUrl::parse("user%3Apass%23word@xyz.com", "mysql").unwrap())
1037        );
1038    }
1039
1040    #[test]
1041    fn test_cidr() {
1042        let allowed =
1043            AllowedHostsConfig::parse(&["*://127.0.0.1/24:63551"], &dummy_resolver(), &[]).unwrap();
1044        assert!(allowed.allows(&OutboundUrl::parse("tcp://127.0.0.1:63551", "tcp").unwrap()));
1045    }
1046
1047    fn exact_host(ahc: &AllowedHostConfig) -> String {
1048        match ahc.host() {
1049            HostConfig::Literal(host) => host.to_string(),
1050            _ => panic!("expected host {:?} to be a literal", ahc.host()),
1051        }
1052    }
1053
1054    #[test]
1055    fn expand_wildcard_service_chaining_lists_all_components() {
1056        let component_ids = ["first", "second", "third"]
1057            .iter()
1058            .map(|s| s.to_string())
1059            .collect::<Vec<_>>();
1060        let allowed = AllowedHostsConfig::parse(
1061            &["http://*.spin.internal"],
1062            &dummy_resolver(),
1063            &component_ids,
1064        )
1065        .unwrap();
1066        let AllowedHostsConfig::SpecificHosts(allowed) = allowed else {
1067            panic!("expanded AllowedHostsConfig should be specific hosts");
1068        };
1069
1070        assert_eq!(3, allowed.len());
1071
1072        assert_eq!("first.spin.internal", exact_host(&allowed[0]));
1073        assert_eq!("second.spin.internal", exact_host(&allowed[1]));
1074        assert_eq!("third.spin.internal", exact_host(&allowed[2]));
1075    }
1076
1077    #[test]
1078    fn expand_wildcard_service_chaining_leaves_others_untouched() {
1079        let component_ids = ["first", "second", "third"]
1080            .iter()
1081            .map(|s| s.to_string())
1082            .collect::<Vec<_>>();
1083        let allowed = AllowedHostsConfig::parse(
1084            &[
1085                "pg://localhost:5656",
1086                "http://*.spin.internal",
1087                "https://spinframework.dev",
1088            ],
1089            &dummy_resolver(),
1090            &component_ids,
1091        )
1092        .unwrap();
1093        let AllowedHostsConfig::SpecificHosts(allowed) = allowed else {
1094            panic!("expanded AllowedHostsConfig should be specific hosts");
1095        };
1096
1097        assert_eq!(5, allowed.len());
1098
1099        assert_eq!("localhost", exact_host(&allowed[0]));
1100        assert_eq!("first.spin.internal", exact_host(&allowed[1]));
1101        assert_eq!("second.spin.internal", exact_host(&allowed[2]));
1102        assert_eq!("third.spin.internal", exact_host(&allowed[3]));
1103        assert_eq!("spinframework.dev", exact_host(&allowed[4]));
1104    }
1105}