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    ) -> 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    /// Validates the given allowed_outbound_hosts values with the given resolver.
478    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    /// Parse the given allowed_outbound_hosts values with deferred parsing of
486    /// templated values.
487    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    /// Returns true if the given url is allowed.
506    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    /// Returns true if relative ("self") requests to any of the given schemes
514    /// are allowed.
515    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/// A parsed URL used for outbound networking.
532#[derive(Debug, Clone)]
533pub struct OutboundUrl {
534    scheme: String,
535    host: String,
536    port: Option<u16>,
537    original: String,
538}
539
540impl OutboundUrl {
541    /// Parses a URL.
542    ///
543    /// If parsing `url` fails, `{scheme}://` is prepended to `url` and parsing is tried again.
544    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        // Ensure that the authority is url encoded. Since the authority is ignored after this,
549        // we can always url encode the authority even if it is already encoded.
550        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('/') // This can calculate the wrong index if the username or password contains a '/'
554                .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                    // Return an error preferring the error from the first attempt if present
577                    (_, 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
612/// Checks if the host is a service chaining host.
613pub fn is_service_chaining_host(host: &str) -> bool {
614    parse_service_chaining_host(host).is_some()
615}
616
617/// Parses a service chaining target from a URL.
618pub 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                // Trailing slash is removed
713                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        // An empty path is allowed
895        assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/").is_ok());
896        // All other paths are not allowed
897        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        // Allow urls with and without a trailing slash
936        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}