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, port) = match rest.rsplit_once(':') {
130            None => (rest, ""),
131            Some((h, "")) => (h, ""),
132            Some((h, tail)) => {
133                if HostConfig::parse(rest).is_ok() {
134                    (rest, "")
135                } else {
136                    let port = match tail.split_once('/') {
137                        Some((port, path)) => {
138                            if !path.is_empty() {
139                                bail!("{url:?} has a path but is not allowed to");
140                            }
141                            port
142                        }
143                        None => tail,
144                    };
145                    (h, port)
146                }
147            }
148        };
149
150        let port = PortConfig::parse(port, scheme)
151            .with_context(|| format!("Invalid allowed host port {port:?}"))?;
152        let scheme = SchemeConfig::parse(scheme)
153            .with_context(|| format!("Invalid allowed host scheme {scheme:?}"))?;
154        let host =
155            HostConfig::parse(host).with_context(|| format!("Invalid allowed host {host:?}"))?;
156
157        Ok(Self {
158            scheme,
159            host,
160            port,
161            original,
162        })
163    }
164
165    pub fn scheme(&self) -> &SchemeConfig {
166        &self.scheme
167    }
168
169    pub fn host(&self) -> &HostConfig {
170        &self.host
171    }
172
173    pub fn port(&self) -> &PortConfig {
174        &self.port
175    }
176
177    /// Returns true if this config is for service chaining requests.
178    pub fn is_for_service_chaining(&self) -> bool {
179        self.host.is_for_service_chaining()
180    }
181
182    /// Returns true if the given URL is allowed.
183    fn allows(&self, url: &OutboundUrl) -> bool {
184        self.scheme.allows(&url.scheme)
185            && self.host.allows(&url.host)
186            && self.port.allows(url.port, &url.scheme)
187    }
188
189    /// Returns true if relative ("self") requests to any of the given schemes
190    /// are allowed.
191    fn allows_relative(&self, schemes: &[&str]) -> bool {
192        schemes.iter().any(|s| self.scheme.allows(s)) && self.host.allows_relative()
193    }
194}
195
196impl PartialEq for AllowedHostConfig {
197    fn eq(&self, other: &Self) -> bool {
198        self.scheme == other.scheme && self.host == other.host && self.port == other.port
199    }
200}
201
202impl std::fmt::Display for AllowedHostConfig {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        f.write_str(&self.original)
205    }
206}
207
208/// Represents the scheme part of an allowed_outbound_hosts item.
209#[derive(PartialEq, Eq, Debug, Clone)]
210pub enum SchemeConfig {
211    /// Any scheme is allowed: `*://`
212    Any,
213    /// Any scheme is allowed: `*://`
214    List(Vec<String>),
215}
216
217impl SchemeConfig {
218    /// Parses the scheme part of an allowed_outbound_hosts item.
219    fn parse(scheme: &str) -> anyhow::Result<Self> {
220        if scheme == "*" {
221            return Ok(Self::Any);
222        }
223
224        if scheme.starts_with('{') {
225            anyhow::bail!("scheme lists are not supported")
226        }
227
228        if scheme.chars().any(|c| !c.is_alphabetic()) {
229            anyhow::bail!("only alphabetic characters are allowed");
230        }
231
232        Ok(Self::List(vec![scheme.into()]))
233    }
234
235    /// Returns true if any scheme is allowed (i.e. `*://`).
236    pub fn allows_any(&self) -> bool {
237        matches!(self, Self::Any)
238    }
239
240    /// Returns true if the given scheme is allowed.
241    fn allows(&self, scheme: &str) -> bool {
242        match self {
243            SchemeConfig::Any => true,
244            SchemeConfig::List(l) => l.iter().any(|s| s.as_str() == scheme),
245        }
246    }
247}
248
249/// Represents the host part of an allowed_outbound_hosts item.
250#[derive(Debug, PartialEq, Eq, Clone)]
251pub enum HostConfig {
252    Any,
253    AnySubdomain(String),
254    ToSelf,
255    Literal(Host),
256    Cidr(ip_network::IpNetwork),
257}
258
259impl HostConfig {
260    /// Parses the host part of an allowed_outbound_hosts item.
261    fn parse(mut host: &str) -> anyhow::Result<Self> {
262        host = host.trim();
263        if host == "*" {
264            return Ok(Self::Any);
265        }
266
267        if host == "self" || host == "self.alt" {
268            return Ok(Self::ToSelf);
269        }
270
271        if host.starts_with('{') {
272            ensure!(host.ends_with('}'));
273            bail!("host lists are not yet supported")
274        }
275
276        if let Ok(net) = ip_network::IpNetwork::from_str_truncate(host) {
277            return Ok(Self::Cidr(net));
278        }
279
280        host = host.strip_suffix('/').unwrap_or(host);
281        if host.contains('/') {
282            bail!("must not include a path");
283        }
284
285        if let Some(domain) = host.strip_prefix("*.") {
286            if domain.contains('*') {
287                bail!("wildcards are allowed only as prefixes");
288            }
289            let domain = Host::parse(domain)
290                .with_context(|| format!("invalid wildcard host \"*.{domain}\""))?;
291            let Host::Domain(domain) = domain else {
292                bail!("wildcard suffix must be a domain");
293            };
294            return Ok(Self::AnySubdomain(format!(".{domain}")));
295        }
296
297        if host.contains('*') {
298            bail!("wildcards are allowed only as subdomains");
299        }
300
301        Self::literal(host)
302    }
303
304    /// Returns a HostConfig from the given literal host name.
305    fn literal(host: &str) -> anyhow::Result<Self> {
306        Ok(Self::Literal(Host::parse(host)?))
307    }
308
309    /// Returns true if the given host is allowed.
310    fn allows(&self, host: &str) -> bool {
311        let host: Host = match Host::parse(host) {
312            Ok(host) => host,
313            Err(err) => {
314                tracing::warn!(?err, "invalid host in HostConfig::allows");
315                return false;
316            }
317        };
318        match (self, host) {
319            (HostConfig::Any, _) => true,
320            (HostConfig::AnySubdomain(suffix), Host::Domain(domain)) => domain.ends_with(suffix),
321            (HostConfig::Literal(literal), host) => host == *literal,
322            (HostConfig::Cidr(c), Host::Ipv4(ip)) => c.contains(ip),
323            (HostConfig::Cidr(c), Host::Ipv6(ip)) => c.contains(ip),
324            // AnySubdomain only matches domains
325            (HostConfig::AnySubdomain(_), _) => false,
326            // Cidr doesn't match domains
327            (HostConfig::Cidr(_), Host::Domain(_)) => false,
328            // ToSelf is checked separately with allow_relative
329            (HostConfig::ToSelf, _) => false,
330        }
331    }
332
333    /// Returns true if relative ("self") requests are allowed.
334    fn allows_relative(&self) -> bool {
335        matches!(self, Self::Any | Self::ToSelf)
336    }
337
338    /// Returns true if this config is for service chaining requests.
339    fn is_for_service_chaining(&self) -> bool {
340        match self {
341            Self::Literal(Host::Domain(domain)) => domain.ends_with(SERVICE_CHAINING_DOMAIN_SUFFIX),
342            Self::AnySubdomain(suffix) => suffix == SERVICE_CHAINING_DOMAIN_SUFFIX,
343            _ => false,
344        }
345    }
346}
347
348/// Represents the port part of an allowed_outbound_hosts item.
349#[derive(Debug, PartialEq, Eq, Clone)]
350pub enum PortConfig {
351    Any,
352    List(Vec<IndividualPortConfig>),
353}
354
355impl PortConfig {
356    /// Parses the port part of an allowed_outbound_hosts item.
357    fn parse(port: &str, scheme: &str) -> anyhow::Result<PortConfig> {
358        if port.is_empty() {
359            return well_known_port(scheme)
360                .map(|p| PortConfig::List(vec![IndividualPortConfig::Port(p)]))
361                .with_context(|| format!("no port was provided and the scheme {scheme:?} does not have a known default port number"));
362        }
363        if port == "*" {
364            return Ok(PortConfig::Any);
365        }
366
367        if port.starts_with('{') {
368            // TODO:
369            bail!("port lists are not yet supported")
370        }
371
372        let port = IndividualPortConfig::parse(port)?;
373
374        Ok(Self::List(vec![port]))
375    }
376
377    /// Returns true if the given port (or scheme-default port) is allowed.
378    fn allows(&self, port: Option<u16>, scheme: &str) -> bool {
379        match self {
380            PortConfig::Any => true,
381            PortConfig::List(l) => {
382                let port = match port.or_else(|| well_known_port(scheme)) {
383                    Some(p) => p,
384                    None => return false,
385                };
386                l.iter().any(|p| p.allows(port))
387            }
388        }
389    }
390}
391
392/// Represents a single port specifier in an allowed_outbound_hosts item.
393#[derive(Debug, PartialEq, Eq, Clone)]
394pub enum IndividualPortConfig {
395    Port(u16),
396    Range(Range<u16>),
397}
398
399impl IndividualPortConfig {
400    /// Parses the a single port specifier in an allowed_outbound_hosts item.
401    fn parse(port: &str) -> anyhow::Result<Self> {
402        if let Some((start, end)) = port.split_once("..") {
403            let start = start
404                .parse()
405                .with_context(|| format!("port range {port:?} contains non-number"))?;
406            let end = end
407                .parse()
408                .with_context(|| format!("port range {port:?} contains non-number"))?;
409            return Ok(Self::Range(start..end));
410        }
411        Ok(Self::Port(port.parse().with_context(|| {
412            format!("port {port:?} is not a number")
413        })?))
414    }
415
416    /// Returns true if the given port is allowed.
417    fn allows(&self, port: u16) -> bool {
418        match self {
419            IndividualPortConfig::Port(p) => p == &port,
420            IndividualPortConfig::Range(r) => r.contains(&port),
421        }
422    }
423}
424
425/// Returns a well-known default port for the given URL scheme.
426fn well_known_port(scheme: &str) -> Option<u16> {
427    match scheme {
428        "postgres" => Some(5432),
429        "mysql" => Some(3306),
430        "redis" => Some(6379),
431        "mqtt" => Some(1883),
432        "http" => Some(80),
433        "https" => Some(443),
434        _ => None,
435    }
436}
437
438/// Holds a single allowed_outbound_hosts item, either parsed or as an
439/// unresolved template.
440enum PartialAllowedHostConfig {
441    Exact(AllowedHostConfig),
442    Unresolved(spin_expressions::Template),
443}
444
445impl PartialAllowedHostConfig {
446    /// Returns this config, resolving any template with the given resolver.
447    fn resolve(self, resolver: &impl SyncResolver) -> anyhow::Result<Option<AllowedHostConfig>> {
448        match self {
449            Self::Exact(h) => Ok(Some(h)),
450            Self::Unresolved(t) => {
451                let resolved = resolver.resolve_template(&t)?;
452                Self::parse_or_skip(&resolved)
453            }
454        }
455    }
456
457    /// Validates this config. Only templates that can be resolved with default
458    /// values from the given resolver will be fully validated.
459    fn validate(&self, resolver: &impl SyncResolver) -> anyhow::Result<()> {
460        if let Self::Unresolved(template) = self {
461            let Ok(resolved) = resolver.resolve_template(template) else {
462                // We're missing a default value so we can't validate further
463                return Ok(());
464            };
465
466            Self::parse_or_skip(&resolved).with_context(|| {
467                let template_str = template.to_string();
468                format!("using default variable value(s) with template {template_str:?} results in invalid config {resolved:?}")
469            })?;
470        }
471        Ok(())
472    }
473
474    /// This should be used ONLY for resolutions involved variable
475    /// substitution (which is why it is here rather than on AllowedHostConfig).
476    /// An empty literal remains an error: this allows us to be forgiving if
477    /// an empty variable results in an empty resolution.
478    fn parse_or_skip(resolved: &str) -> anyhow::Result<Option<AllowedHostConfig>> {
479        // An empty variable could result in an empty host. We
480        // ignore these, so as to support an "optional endpoint"
481        // scenario.
482        // TODO: consider if we want other schemes too?
483        if resolved.is_empty() || resolved == "http://" || resolved == "https://" {
484            Ok(None)
485        } else {
486            AllowedHostConfig::parse(resolved).map(Some)
487        }
488    }
489}
490
491/// Represents an allowed_outbound_hosts config.
492#[derive(PartialEq, Eq, Debug, Clone)]
493pub enum AllowedHostsConfig {
494    All,
495    SpecificHosts(Vec<AllowedHostConfig>),
496}
497
498impl AllowedHostsConfig {
499    /// Parses the given allowed_outbound_hosts values, resolving any templates
500    /// with the given resolver.
501    pub fn parse<S: AsRef<str>>(
502        hosts: &[S],
503        resolver: &impl SyncResolver,
504        component_ids: &[String],
505    ) -> anyhow::Result<AllowedHostsConfig> {
506        let partial = Self::parse_partial(hosts)?;
507        let allowed = partial
508            .into_iter()
509            .flat_map(|p| p.resolve(resolver).transpose())
510            .collect::<anyhow::Result<Vec<_>>>()?;
511        let allowed = Self::expand_wildcard_service_chaining(allowed, component_ids);
512        Ok(Self::SpecificHosts(allowed))
513    }
514
515    /// Validates the given allowed_outbound_hosts values with the given resolver.
516    pub fn validate<S: AsRef<str>>(
517        hosts: &[S],
518        resolver: &impl SyncResolver,
519    ) -> anyhow::Result<()> {
520        for partial in Self::parse_partial(hosts)? {
521            partial.validate(resolver)?;
522        }
523        Ok(())
524    }
525
526    /// Parse the given allowed_outbound_hosts values with deferred parsing of
527    /// templated values.
528    fn parse_partial<S: AsRef<str>>(hosts: &[S]) -> anyhow::Result<Vec<PartialAllowedHostConfig>> {
529        if hosts.len() == 1 && hosts[0].as_ref() == "insecure:allow-all" {
530            bail!(
531                "'insecure:allow-all' is not allowed - use '*://*:*' instead if you really want to allow all outbound traffic'"
532            )
533        }
534        let mut allowed = Vec::with_capacity(hosts.len());
535        for host in hosts {
536            let template = spin_expressions::Template::new(host.as_ref())?;
537            if template.is_literal() {
538                allowed.push(PartialAllowedHostConfig::Exact(AllowedHostConfig::parse(
539                    host.as_ref(),
540                )?));
541            } else {
542                allowed.push(PartialAllowedHostConfig::Unresolved(template));
543            }
544        }
545        Ok(allowed)
546    }
547
548    fn expand_wildcard_service_chaining(
549        hosts: Vec<AllowedHostConfig>,
550        component_ids: &[String],
551    ) -> Vec<AllowedHostConfig> {
552        let expand_one = |host: AllowedHostConfig| match host.host() {
553            HostConfig::AnySubdomain(domain) if domain == SERVICE_CHAINING_DOMAIN_SUFFIX => {
554                let expanded_domains = component_ids
555                    .iter()
556                    .map(|c| format!("{c}{SERVICE_CHAINING_DOMAIN_SUFFIX}"));
557                let expanded_hosts = expanded_domains.map(|d| {
558                    let mut hh = host.clone();
559                    hh.host = HostConfig::Literal(url::Host::Domain(d));
560                    hh
561                });
562                expanded_hosts.collect()
563            }
564            _ => vec![host],
565        };
566
567        hosts.into_iter().flat_map(expand_one).collect()
568    }
569
570    /// Returns true if the given url is allowed.
571    pub fn allows(&self, url: &OutboundUrl) -> bool {
572        match self {
573            AllowedHostsConfig::All => true,
574            AllowedHostsConfig::SpecificHosts(hosts) => hosts.iter().any(|h| h.allows(url)),
575        }
576    }
577
578    /// Returns true if relative ("self") requests to any of the given schemes
579    /// are allowed.
580    pub fn allows_relative_url(&self, schemes: &[&str]) -> bool {
581        match self {
582            AllowedHostsConfig::All => true,
583            AllowedHostsConfig::SpecificHosts(hosts) => {
584                hosts.iter().any(|h| h.allows_relative(schemes))
585            }
586        }
587    }
588}
589
590impl Default for AllowedHostsConfig {
591    fn default() -> Self {
592        Self::SpecificHosts(Vec::new())
593    }
594}
595
596/// A parsed URL used for outbound networking.
597#[derive(Debug, Clone)]
598pub struct OutboundUrl {
599    scheme: String,
600    host: String,
601    port: Option<u16>,
602    original: String,
603}
604
605impl OutboundUrl {
606    /// Parses a URL.
607    ///
608    /// If parsing `url` fails, `{scheme}://` is prepended to `url` and parsing is tried again.
609    pub fn parse(url: impl Into<String>, scheme: &str) -> anyhow::Result<Self> {
610        let mut url = url.into();
611        let original = url.clone();
612
613        // Ensure that the authority is url encoded. Since the authority is ignored after this,
614        // we can always url encode the authority even if it is already encoded.
615        if let Some(at) = url.find('@') {
616            let scheme_end = url.find("://").map(|e| e + 3).unwrap_or(0);
617            let path_start = url[scheme_end..]
618                .find('/') // This can calculate the wrong index if the username or password contains a '/'
619                .map(|e| e + scheme_end)
620                .unwrap_or(usize::MAX);
621
622            if at < path_start {
623                let userinfo = &url[scheme_end..at];
624
625                let encoded = urlencoding::encode(userinfo);
626                let prefix = &url[..scheme_end];
627                let suffix = &url[scheme_end + userinfo.len()..];
628                url = format!("{prefix}{encoded}{suffix}");
629            }
630        }
631
632        let parsed = match url::Url::parse(&url) {
633            Ok(url) if url.has_host() => Ok(url),
634            first_try => {
635                let second_try: anyhow::Result<url::Url> = format!("{scheme}://{url}")
636                    .as_str()
637                    .try_into()
638                    .context("could not convert into a url");
639                match (second_try, first_try.map_err(|e| e.into())) {
640                    (Ok(u), _) => Ok(u),
641                    // Return an error preferring the error from the first attempt if present
642                    (_, Err(e)) | (Err(e), _) => Err(e),
643                }
644            }
645        }?;
646
647        Ok(Self {
648            scheme: parsed.scheme().to_owned(),
649            host: parsed
650                .host_str()
651                .with_context(|| format!("{url:?} does not have a host component"))?
652                .to_owned(),
653            port: parsed.port(),
654            original,
655        })
656    }
657
658    pub fn scheme(&self) -> &str {
659        &self.scheme
660    }
661
662    pub fn authority(&self) -> String {
663        if let Some(port) = self.port {
664            format!("{}:{port}", self.host)
665        } else {
666            self.host.clone()
667        }
668    }
669}
670
671impl std::fmt::Display for OutboundUrl {
672    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
673        f.write_str(&self.original)
674    }
675}
676
677/// Checks if the host is a service chaining host.
678pub fn is_service_chaining_host(host: &str) -> bool {
679    parse_service_chaining_host(host).is_some()
680}
681
682/// Parses a service chaining target from a URL.
683pub fn parse_service_chaining_target(url: &http::Uri) -> Option<String> {
684    let host = url.authority().map(|a| a.host().trim())?;
685    parse_service_chaining_host(host)
686}
687
688fn parse_service_chaining_host(host: &str) -> Option<String> {
689    let (host, _) = host.rsplit_once(':').unwrap_or((host, ""));
690
691    let (first, rest) = host.split_once('.')?;
692
693    if rest == SERVICE_CHAINING_DOMAIN {
694        Some(first.to_owned())
695    } else {
696        None
697    }
698}
699
700#[cfg(test)]
701mod test {
702    impl AllowedHostConfig {
703        fn new(scheme: SchemeConfig, host: HostConfig, port: PortConfig) -> Self {
704            Self {
705                scheme,
706                host,
707                port,
708                original: String::new(),
709            }
710        }
711    }
712
713    impl SchemeConfig {
714        fn new(scheme: &str) -> Self {
715            Self::List(vec![scheme.into()])
716        }
717    }
718
719    impl HostConfig {
720        fn subdomain(domain: &str) -> Self {
721            Self::AnySubdomain(format!(".{domain}"))
722        }
723    }
724
725    impl PortConfig {
726        fn new(port: u16) -> Self {
727            Self::List(vec![IndividualPortConfig::Port(port)])
728        }
729
730        fn range(port: Range<u16>) -> Self {
731            Self::List(vec![IndividualPortConfig::Range(port)])
732        }
733    }
734
735    #[derive(Default)]
736    struct DummyResolver {
737        variables: std::collections::HashMap<String, String>,
738    }
739
740    impl SyncResolver for DummyResolver {
741        fn resolve_variable(&self, key: &str) -> spin_expressions::Result<String> {
742            self.variables
743                .get(key)
744                .cloned()
745                .ok_or(spin_expressions::Error::InvalidName(key.to_string()))
746        }
747    }
748
749    fn dummy_resolver() -> impl SyncResolver {
750        DummyResolver::default()
751    }
752
753    fn populated_resolver(values: &[(&str, &str)]) -> impl SyncResolver {
754        let variables = values
755            .iter()
756            .map(|(k, v)| (k.to_string(), v.to_string()))
757            .collect();
758
759        DummyResolver { variables }
760    }
761
762    fn empty_values_resolver() -> impl SyncResolver {
763        populated_resolver(&[("one", ""), ("two", "")])
764    }
765
766    use ip_network::{IpNetwork, Ipv4Network, Ipv6Network};
767
768    use super::*;
769    use std::net::{Ipv4Addr, Ipv6Addr};
770
771    #[test]
772    fn outbound_url_handles_at_in_paths() {
773        let url = "https://example.com/file@0.1.0.json";
774        let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
775        assert_eq!("example.com", url.host);
776
777        let url = "https://user:password@example.com/file@0.1.0.json";
778        let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
779        assert_eq!("example.com", url.host);
780
781        let url = "https://user:pass#word@example.com/file@0.1.0.json";
782        let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
783        assert_eq!("example.com", url.host);
784
785        let url = "https://user:password@example.com";
786        let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
787        assert_eq!("example.com", url.host);
788    }
789
790    #[test]
791    fn test_allowed_hosts_accepts_url_without_port() {
792        assert_eq!(
793            AllowedHostConfig::new(
794                SchemeConfig::new("http"),
795                HostConfig::literal("spin.fermyon.dev").unwrap(),
796                PortConfig::new(80)
797            ),
798            AllowedHostConfig::parse("http://spin.fermyon.dev").unwrap()
799        );
800
801        assert_eq!(
802            AllowedHostConfig::new(
803                SchemeConfig::new("http"),
804                // Trailing slash is removed
805                HostConfig::literal("spin.fermyon.dev").unwrap(),
806                PortConfig::new(80)
807            ),
808            AllowedHostConfig::parse("http://spin.fermyon.dev/").unwrap()
809        );
810
811        assert_eq!(
812            AllowedHostConfig::new(
813                SchemeConfig::new("https"),
814                HostConfig::literal("spin.fermyon.dev").unwrap(),
815                PortConfig::new(443)
816            ),
817            AllowedHostConfig::parse("https://spin.fermyon.dev").unwrap()
818        );
819    }
820
821    #[test]
822    fn test_allowed_hosts_accepts_url_with_port() {
823        assert_eq!(
824            AllowedHostConfig::new(
825                SchemeConfig::new("http"),
826                HostConfig::literal("spin.fermyon.dev").unwrap(),
827                PortConfig::new(4444)
828            ),
829            AllowedHostConfig::parse("http://spin.fermyon.dev:4444").unwrap()
830        );
831        assert_eq!(
832            AllowedHostConfig::new(
833                SchemeConfig::new("http"),
834                HostConfig::literal("spin.fermyon.dev").unwrap(),
835                PortConfig::new(4444)
836            ),
837            AllowedHostConfig::parse("http://spin.fermyon.dev:4444/").unwrap()
838        );
839        assert_eq!(
840            AllowedHostConfig::new(
841                SchemeConfig::new("https"),
842                HostConfig::literal("spin.fermyon.dev").unwrap(),
843                PortConfig::new(5555)
844            ),
845            AllowedHostConfig::parse("https://spin.fermyon.dev:5555").unwrap()
846        );
847    }
848
849    #[test]
850    fn test_allowed_hosts_accepts_url_with_port_range() {
851        assert_eq!(
852            AllowedHostConfig::new(
853                SchemeConfig::new("http"),
854                HostConfig::literal("spin.fermyon.dev").unwrap(),
855                PortConfig::range(4444..5555)
856            ),
857            AllowedHostConfig::parse("http://spin.fermyon.dev:4444..5555").unwrap()
858        );
859    }
860
861    #[test]
862    fn test_allowed_hosts_does_not_accept_plain_host_without_port() {
863        assert!(AllowedHostConfig::parse("spin.fermyon.dev").is_err());
864    }
865
866    #[test]
867    fn test_allowed_hosts_does_not_accept_plain_host_without_scheme() {
868        assert!(AllowedHostConfig::parse("spin.fermyon.dev:80").is_err());
869    }
870
871    #[test]
872    fn test_allowed_hosts_accepts_host_with_glob_scheme() {
873        assert_eq!(
874            AllowedHostConfig::new(
875                SchemeConfig::Any,
876                HostConfig::literal("spin.fermyon.dev").unwrap(),
877                PortConfig::new(7777)
878            ),
879            AllowedHostConfig::parse("*://spin.fermyon.dev:7777").unwrap()
880        )
881    }
882
883    #[test]
884    fn test_allowed_hosts_accepts_self() {
885        assert_eq!(
886            AllowedHostConfig::new(
887                SchemeConfig::new("http"),
888                HostConfig::ToSelf,
889                PortConfig::new(80)
890            ),
891            AllowedHostConfig::parse("http://self").unwrap()
892        );
893    }
894
895    #[test]
896    fn test_allowed_hosts_accepts_localhost_addresses() {
897        assert!(AllowedHostConfig::parse("localhost").is_err());
898        assert_eq!(
899            AllowedHostConfig::new(
900                SchemeConfig::new("http"),
901                HostConfig::literal("localhost").unwrap(),
902                PortConfig::new(80)
903            ),
904            AllowedHostConfig::parse("http://localhost").unwrap()
905        );
906        assert!(AllowedHostConfig::parse("localhost:3001").is_err());
907        assert_eq!(
908            AllowedHostConfig::new(
909                SchemeConfig::new("http"),
910                HostConfig::literal("localhost").unwrap(),
911                PortConfig::new(3001)
912            ),
913            AllowedHostConfig::parse("http://localhost:3001").unwrap()
914        );
915    }
916
917    #[test]
918    fn test_allowed_hosts_accepts_subdomain_wildcards() {
919        assert_eq!(
920            AllowedHostConfig::new(
921                SchemeConfig::new("http"),
922                HostConfig::subdomain("example.com"),
923                PortConfig::new(80)
924            ),
925            AllowedHostConfig::parse("http://*.example.com").unwrap()
926        );
927    }
928
929    #[test]
930    fn test_allowed_hosts_accepts_ip_addresses() {
931        assert_eq!(
932            AllowedHostConfig::new(
933                SchemeConfig::new("http"),
934                HostConfig::literal("192.168.1.1").unwrap(),
935                PortConfig::new(80)
936            ),
937            AllowedHostConfig::parse("http://192.168.1.1").unwrap()
938        );
939        assert_eq!(
940            AllowedHostConfig::new(
941                SchemeConfig::new("http"),
942                HostConfig::literal("192.168.1.1").unwrap(),
943                PortConfig::new(3002)
944            ),
945            AllowedHostConfig::parse("http://192.168.1.1:3002").unwrap()
946        );
947        assert_eq!(
948            AllowedHostConfig::new(
949                SchemeConfig::new("http"),
950                HostConfig::literal("[::1]").unwrap(),
951                PortConfig::new(8001)
952            ),
953            AllowedHostConfig::parse("http://[::1]:8001").unwrap()
954        );
955
956        assert_eq!(
957            AllowedHostConfig::new(
958                SchemeConfig::new("http"),
959                HostConfig::literal("[::1]").unwrap(),
960                PortConfig::new(80)
961            ),
962            AllowedHostConfig::parse("http://[::1]").unwrap()
963        );
964    }
965
966    #[test]
967    fn test_allowed_hosts_accepts_ip_cidr() {
968        assert_eq!(
969            AllowedHostConfig::new(
970                SchemeConfig::Any,
971                HostConfig::Cidr(IpNetwork::V4(
972                    Ipv4Network::new(Ipv4Addr::new(127, 0, 0, 0), 24).unwrap()
973                )),
974                PortConfig::new(80)
975            ),
976            AllowedHostConfig::parse("*://127.0.0.0/24:80").unwrap()
977        );
978        assert!(AllowedHostConfig::parse("*://127.0.0.0/24").is_err());
979        assert_eq!(
980            AllowedHostConfig::new(
981                SchemeConfig::Any,
982                HostConfig::Cidr(IpNetwork::V6(
983                    Ipv6Network::new(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8).unwrap()
984                )),
985                PortConfig::new(80)
986            ),
987            AllowedHostConfig::parse("*://ff00::/8:80").unwrap()
988        );
989    }
990
991    #[test]
992    fn test_allowed_hosts_rejects_path() {
993        // An empty path is allowed
994        assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/").is_ok());
995        // All other paths are not allowed
996        assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/a").is_err());
997        assert!(AllowedHostConfig::parse("http://spin.fermyon.dev:6666/a/b").is_err());
998        assert!(AllowedHostConfig::parse("http://*.fermyon.dev/a").is_err());
999    }
1000
1001    #[test]
1002    fn test_allowed_hosts_respects_allow_all() {
1003        assert!(
1004            AllowedHostsConfig::parse(&["insecure:allow-all"], &dummy_resolver(), &[]).is_err()
1005        );
1006        assert!(
1007            AllowedHostsConfig::parse(
1008                &["spin.fermyon.dev", "insecure:allow-all"],
1009                &dummy_resolver(),
1010                &[]
1011            )
1012            .is_err()
1013        );
1014    }
1015
1016    #[test]
1017    fn test_allowed_all_globs() {
1018        assert_eq!(
1019            AllowedHostConfig::new(SchemeConfig::Any, HostConfig::Any, PortConfig::Any),
1020            AllowedHostConfig::parse("*://*:*").unwrap()
1021        );
1022    }
1023
1024    #[test]
1025    fn test_missing_scheme() {
1026        assert!(AllowedHostConfig::parse("example.com").is_err());
1027    }
1028
1029    #[test]
1030    fn test_allowed_hosts_can_be_specific() {
1031        let allowed = AllowedHostsConfig::parse(
1032            &["*://spin.fermyon.dev:443", "http://example.com:8383"],
1033            &dummy_resolver(),
1034            &[],
1035        )
1036        .unwrap();
1037        assert!(
1038            allowed.allows(&OutboundUrl::parse("http://example.com:8383/foo/bar", "http").unwrap())
1039        );
1040        // Allow urls with and without a trailing slash
1041        assert!(allowed.allows(&OutboundUrl::parse("https://spin.fermyon.dev", "https").unwrap()));
1042        assert!(allowed.allows(&OutboundUrl::parse("https://spin.fermyon.dev/", "https").unwrap()));
1043        assert!(!allowed.allows(&OutboundUrl::parse("http://example.com/", "http").unwrap()));
1044        assert!(!allowed.allows(&OutboundUrl::parse("http://google.com/", "http").unwrap()));
1045        assert!(allowed.allows(&OutboundUrl::parse("spin.fermyon.dev:443", "https").unwrap()));
1046        assert!(allowed.allows(&OutboundUrl::parse("example.com:8383", "http").unwrap()));
1047    }
1048
1049    #[test]
1050    fn test_allowed_hosts_with_trailing_slash() {
1051        let allowed =
1052            AllowedHostsConfig::parse(&["https://my.api.com/"], &dummy_resolver(), &[]).unwrap();
1053        assert!(allowed.allows(&OutboundUrl::parse("https://my.api.com", "https").unwrap()));
1054        assert!(allowed.allows(&OutboundUrl::parse("https://my.api.com/", "https").unwrap()));
1055    }
1056
1057    #[test]
1058    fn test_allowed_hosts_can_be_subdomain_wildcards() {
1059        let allowed = AllowedHostsConfig::parse(
1060            &["http://*.example.com", "http://*.example2.com:8383"],
1061            &dummy_resolver(),
1062            &[],
1063        )
1064        .unwrap();
1065        assert!(
1066            allowed.allows(&OutboundUrl::parse("http://a.example.com/foo/bar", "http").unwrap())
1067        );
1068        assert!(
1069            allowed.allows(&OutboundUrl::parse("http://a.b.example.com/foo/bar", "http").unwrap())
1070        );
1071        assert!(
1072            allowed.allows(
1073                &OutboundUrl::parse("http://a.b.example2.com:8383/foo/bar", "http").unwrap()
1074            )
1075        );
1076        assert!(
1077            !allowed
1078                .allows(&OutboundUrl::parse("http://a.b.example2.com/foo/bar", "http").unwrap())
1079        );
1080        assert!(
1081            !allowed.allows(&OutboundUrl::parse("http://example.com/foo/bar", "http").unwrap())
1082        );
1083        assert!(
1084            !allowed
1085                .allows(&OutboundUrl::parse("http://example.com:8383/foo/bar", "http").unwrap())
1086        );
1087        assert!(
1088            !allowed.allows(&OutboundUrl::parse("http://myexample.com/foo/bar", "http").unwrap())
1089        );
1090    }
1091
1092    #[test]
1093    fn test_hash_char_in_db_password() {
1094        let allowed =
1095            AllowedHostsConfig::parse(&["mysql://xyz.com"], &dummy_resolver(), &[]).unwrap();
1096        assert!(
1097            allowed.allows(&OutboundUrl::parse("mysql://user:pass#word@xyz.com", "mysql").unwrap())
1098        );
1099        assert!(
1100            allowed.allows(
1101                &OutboundUrl::parse("mysql://user%3Apass%23word@xyz.com", "mysql").unwrap()
1102            )
1103        );
1104        assert!(
1105            allowed.allows(&OutboundUrl::parse("user%3Apass%23word@xyz.com", "mysql").unwrap())
1106        );
1107    }
1108
1109    #[test]
1110    fn test_cidr() {
1111        let allowed =
1112            AllowedHostsConfig::parse(&["*://127.0.0.1/24:63551"], &dummy_resolver(), &[]).unwrap();
1113        assert!(allowed.allows(&OutboundUrl::parse("tcp://127.0.0.1:63551", "tcp").unwrap()));
1114    }
1115
1116    fn exact_host(ahc: &AllowedHostConfig) -> String {
1117        match ahc.host() {
1118            HostConfig::Literal(host) => host.to_string(),
1119            _ => panic!("expected host {:?} to be a literal", ahc.host()),
1120        }
1121    }
1122
1123    #[test]
1124    fn expand_wildcard_service_chaining_lists_all_components() {
1125        let component_ids = ["first", "second", "third"]
1126            .iter()
1127            .map(|s| s.to_string())
1128            .collect::<Vec<_>>();
1129        let allowed = AllowedHostsConfig::parse(
1130            &["http://*.spin.internal"],
1131            &dummy_resolver(),
1132            &component_ids,
1133        )
1134        .unwrap();
1135        let AllowedHostsConfig::SpecificHosts(allowed) = allowed else {
1136            panic!("expanded AllowedHostsConfig should be specific hosts");
1137        };
1138
1139        assert_eq!(3, allowed.len());
1140
1141        assert_eq!("first.spin.internal", exact_host(&allowed[0]));
1142        assert_eq!("second.spin.internal", exact_host(&allowed[1]));
1143        assert_eq!("third.spin.internal", exact_host(&allowed[2]));
1144    }
1145
1146    #[test]
1147    fn expand_wildcard_service_chaining_leaves_others_untouched() {
1148        let component_ids = ["first", "second", "third"]
1149            .iter()
1150            .map(|s| s.to_string())
1151            .collect::<Vec<_>>();
1152        let allowed = AllowedHostsConfig::parse(
1153            &[
1154                "pg://localhost:5656",
1155                "http://*.spin.internal",
1156                "https://spinframework.dev",
1157            ],
1158            &dummy_resolver(),
1159            &component_ids,
1160        )
1161        .unwrap();
1162        let AllowedHostsConfig::SpecificHosts(allowed) = allowed else {
1163            panic!("expanded AllowedHostsConfig should be specific hosts");
1164        };
1165
1166        assert_eq!(5, allowed.len());
1167
1168        assert_eq!("localhost", exact_host(&allowed[0]));
1169        assert_eq!("first.spin.internal", exact_host(&allowed[1]));
1170        assert_eq!("second.spin.internal", exact_host(&allowed[2]));
1171        assert_eq!("third.spin.internal", exact_host(&allowed[3]));
1172        assert_eq!("spinframework.dev", exact_host(&allowed[4]));
1173    }
1174
1175    #[test]
1176    fn allowed_hosts_ignores_empty_due_to_empty_variables() {
1177        let resolver = empty_values_resolver();
1178        let hosts = &["https://{{ one }}", "{{ two }}", "https://three"];
1179
1180        let AllowedHostsConfig::SpecificHosts(allowed) =
1181            AllowedHostsConfig::parse(hosts, &resolver, &[]).expect("parse should have succeeded")
1182        else {
1183            panic!("expanded AllowedHostsConfig should be specific hosts");
1184        };
1185
1186        assert_eq!(1, allowed.len());
1187        assert_eq!("three", exact_host(&allowed[0]));
1188    }
1189
1190    #[test]
1191    fn valid_hosts_are_valid() {
1192        let resolver = dummy_resolver();
1193        let hosts = &["http://x.y", "*://my.db:*"];
1194        AllowedHostsConfig::validate(hosts, &resolver).expect("valid hosts should be valid");
1195    }
1196
1197    #[test]
1198    fn invalid_hosts_are_invalid() {
1199        let resolver = dummy_resolver();
1200        let hosts = &["http://x.y", "zootle! wurdl!", "}{ !!**"];
1201        AllowedHostsConfig::validate(hosts, &resolver)
1202            .expect_err("invalid hosts should be invalid");
1203    }
1204
1205    #[test]
1206    fn variables_make_hosts_valid() {
1207        let resolver = populated_resolver(&[("dbhost", "example.com"), ("dbport", "1234")]);
1208        let hosts = &["http://{{ dbhost }}", "http://{{ dbhost }}:{{ dbport }}"];
1209        AllowedHostsConfig::validate(hosts, &resolver).expect("happy variables make hosts valid");
1210    }
1211
1212    #[test]
1213    fn bad_variables_make_hosts_invalid() {
1214        let resolver = populated_resolver(&[("dbhost", "zoinks, Scooby!")]);
1215        let hosts = &["http://{{ dbhost }}"];
1216        AllowedHostsConfig::validate(hosts, &resolver)
1217            .expect_err("bad variables make hosts invalid");
1218    }
1219
1220    #[test]
1221    fn missing_variables_ignored_when_checking_validity() {
1222        let resolver = dummy_resolver();
1223        let hosts = &["http://{{ dbhost }}"];
1224        AllowedHostsConfig::validate(hosts, &resolver)
1225            .expect("missing variables errors should be deferred");
1226    }
1227
1228    #[test]
1229    fn empty_resolutions_ignored_when_checking_validity() {
1230        let resolver = empty_values_resolver();
1231        let hosts = &["https://{{ one }}", "{{ two }}", "https://three"];
1232        AllowedHostsConfig::validate(hosts, &resolver)
1233            .expect("empty resolutions should ignored as valid");
1234    }
1235
1236    #[test]
1237    fn ipv6_literal_without_port_uses_scheme_default() {
1238        for (input, host, port) in [
1239            ("http://[::1]", "[::1]", 80),
1240            ("https://[::1]", "[::1]", 443),
1241            ("http://[2001:db8::1]", "[2001:db8::1]", 80),
1242        ] {
1243            let scheme = input.split_once("://").unwrap().0;
1244            assert_eq!(
1245                AllowedHostConfig::new(
1246                    SchemeConfig::new(scheme),
1247                    HostConfig::literal(host).unwrap(),
1248                    PortConfig::new(port),
1249                ),
1250                AllowedHostConfig::parse(input).unwrap_or_else(|e| panic!("{input}: {e:?}")),
1251                "{input}"
1252            );
1253        }
1254    }
1255
1256    #[test]
1257    fn ipv6_literal_with_explicit_port_still_parses() {
1258        assert_eq!(
1259            AllowedHostConfig::new(
1260                SchemeConfig::new("http"),
1261                HostConfig::literal("[2001:db8::1]").unwrap(),
1262                PortConfig::new(443),
1263            ),
1264            AllowedHostConfig::parse("http://[2001:db8::1]:443").unwrap()
1265        );
1266        assert_eq!(
1267            AllowedHostConfig::new(
1268                SchemeConfig::new("http"),
1269                HostConfig::literal("[::1]").unwrap(),
1270                PortConfig::Any,
1271            ),
1272            AllowedHostConfig::parse("http://[::1]:*").unwrap()
1273        );
1274        assert_eq!(
1275            AllowedHostConfig::new(
1276                SchemeConfig::new("http"),
1277                HostConfig::literal("[::1]").unwrap(),
1278                PortConfig::range(8000..9000),
1279            ),
1280            AllowedHostConfig::parse("http://[::1]:8000..9000").unwrap()
1281        );
1282        assert_eq!(
1283            AllowedHostConfig::new(
1284                SchemeConfig::Any,
1285                HostConfig::literal("[::1]").unwrap(),
1286                PortConfig::new(80),
1287            ),
1288            AllowedHostConfig::parse("*://[::1]:80").unwrap()
1289        );
1290        assert_eq!(
1291            AllowedHostConfig::new(
1292                SchemeConfig::new("http"),
1293                HostConfig::literal("[::1]").unwrap(),
1294                PortConfig::new(8080),
1295            ),
1296            AllowedHostConfig::parse("http://[::1]:8080/").unwrap()
1297        );
1298    }
1299
1300    #[test]
1301    fn cidr_without_port_uses_scheme_default() {
1302        for (input, cidr, port) in [
1303            ("http://ff00::/8", "ff00::/8", 80),
1304            ("https://2001:db8::/32", "2001:db8::/32", 443),
1305            ("http://::1/128", "::1/128", 80),
1306            ("http://10.0.0.0/8", "10.0.0.0/8", 80),
1307        ] {
1308            let scheme = input.split_once("://").unwrap().0;
1309            assert_eq!(
1310                AllowedHostConfig::new(
1311                    SchemeConfig::new(scheme),
1312                    HostConfig::Cidr(IpNetwork::from_str_truncate(cidr).unwrap()),
1313                    PortConfig::new(port),
1314                ),
1315                AllowedHostConfig::parse(input).unwrap_or_else(|e| panic!("{input}: {e:?}")),
1316                "{input}"
1317            );
1318        }
1319    }
1320
1321    #[test]
1322    fn ipv6_cidr_with_explicit_port_still_parses() {
1323        assert_eq!(
1324            AllowedHostConfig::new(
1325                SchemeConfig::new("http"),
1326                HostConfig::Cidr(IpNetwork::from_str_truncate("2001:db8::/32").unwrap()),
1327                PortConfig::new(443),
1328            ),
1329            AllowedHostConfig::parse("http://2001:db8::/32:443").unwrap()
1330        );
1331    }
1332
1333    #[test]
1334    fn rejects_malformed_ipv6_allowed_hosts() {
1335        for bad in [
1336            "http://[::1]/foo",
1337            "http://ff00::/8/extra",
1338            "http://[::1",
1339            "http://[::1]junk",
1340            "http://[::1]::80",
1341            "http://[v1.]",
1342            "[::1]",
1343            "[::1]:8080",
1344        ] {
1345            assert!(
1346                AllowedHostConfig::parse(bad).is_err(),
1347                "expected {bad:?} to be rejected"
1348            );
1349        }
1350    }
1351
1352    #[test]
1353    fn ipv6_literal_allows_and_denies_requests() {
1354        let allowed = AllowedHostsConfig::parse(&["http://[::1]"], &dummy_resolver(), &[]).unwrap();
1355        assert!(allowed.allows(&OutboundUrl::parse("http://[::1]", "http").unwrap()));
1356        assert!(allowed.allows(&OutboundUrl::parse("http://[::1]/some/path", "http").unwrap()));
1357        assert!(allowed.allows(&OutboundUrl::parse("http://[::1]:80", "http").unwrap()));
1358        assert!(!allowed.allows(&OutboundUrl::parse("http://[::2]", "http").unwrap()));
1359        assert!(!allowed.allows(&OutboundUrl::parse("http://[::1]:81", "http").unwrap()));
1360    }
1361
1362    #[test]
1363    fn ipv6_cidr_allows_addresses_in_range() {
1364        let allowed =
1365            AllowedHostsConfig::parse(&["http://2001:db8::/32"], &dummy_resolver(), &[]).unwrap();
1366        assert!(allowed.allows(&OutboundUrl::parse("http://[2001:db8::1]", "http").unwrap()));
1367        assert!(
1368            allowed.allows(&OutboundUrl::parse("http://[2001:db8:abcd::1]/x", "http").unwrap())
1369        );
1370        assert!(!allowed.allows(&OutboundUrl::parse("http://[2001:db9::1]", "http").unwrap()));
1371    }
1372
1373    #[test]
1374    fn wildcard_subdomain_is_case_insensitive() {
1375        let allowed =
1376            AllowedHostsConfig::parse(&["http://*.Example.COM"], &dummy_resolver(), &[]).unwrap();
1377        assert!(allowed.allows(&OutboundUrl::parse("http://foo.example.com", "http").unwrap()));
1378        assert!(allowed.allows(&OutboundUrl::parse("http://a.b.example.com/x", "http").unwrap()));
1379        assert!(!allowed.allows(&OutboundUrl::parse("http://example.com", "http").unwrap()));
1380    }
1381
1382    #[test]
1383    fn rejects_invalid_allowed_host_entries() {
1384        for bad in [
1385            "http://example.com:notaport",
1386            "http://example.com:8080..70000",
1387            "http://self:notaport",
1388            "http://self.alt:notaport",
1389            "http://*:notaport",
1390            "http://127.0.0.1:notaport",
1391            "http://[::1]:notaport",
1392            "http://*.example.com:notaport",
1393            "http://*.example.com:8080..",
1394            "http://*.example.com:8O8O",
1395            "http://example.com:8080/p",
1396            "http://*.example.com:8080/p",
1397            "http://*.example.com/p",
1398            "http://127.0.0.0/24/p",
1399            "http://[::1junk",
1400            "http://*.",
1401            "http://*.:8080",
1402            "example.com:8080",
1403            "http://example%.com",
1404            "http://example%2.com",
1405            "http://example%GG.com",
1406            "http://exa[mple.com",
1407            r"http://exa\mple.com",
1408            "http://example.com:80:90",
1409            "http://[vG.example]",
1410        ] {
1411            assert!(
1412                AllowedHostConfig::parse(bad).is_err(),
1413                "expected {bad:?} to be rejected"
1414            );
1415        }
1416    }
1417
1418    #[test]
1419    fn invalid_explicit_port_reports_port_error() {
1420        for bad in [
1421            "http://example.com:99999",
1422            "http://[::1]:99999",
1423            "http://ff00::/8:99999",
1424            "http://*.example.com:99999",
1425        ] {
1426            let err = AllowedHostConfig::parse(bad).unwrap_err();
1427            assert_eq!(err.to_string(), "Invalid allowed host port \"99999\"");
1428        }
1429    }
1430
1431    #[test]
1432    fn rejects_double_slash_paths() {
1433        for bad in [
1434            "http://example.com//",
1435            "http://*.example.com//",
1436            "http://self//",
1437            "http://[::1]//",
1438        ] {
1439            assert!(
1440                AllowedHostConfig::parse(bad).is_err(),
1441                "expected {bad:?} to be rejected"
1442            );
1443        }
1444    }
1445
1446    #[test]
1447    fn rejects_wildcard_non_domain_suffixes() {
1448        for bad in [
1449            "http://*.127.0.0.1",
1450            "http://*.192.168.001.001",
1451            "http://*.[::1]",
1452            "http://*.[2001:db8::1]",
1453        ] {
1454            assert!(
1455                AllowedHostConfig::parse(bad).is_err(),
1456                "expected {bad:?} to be rejected"
1457            );
1458        }
1459    }
1460}