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