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