spin_factor_outbound_networking/
allowed_hosts.rs

1use std::ops::Range;
2
3use anyhow::{bail, ensure, Context};
4use spin_factors::{App, AppComponent};
5use spin_locked_app::MetadataKey;
6
7const ALLOWED_HOSTS_KEY: MetadataKey<Vec<String>> = MetadataKey::new("allowed_outbound_hosts");
8const ALLOWED_HTTP_KEY: MetadataKey<Vec<String>> = MetadataKey::new("allowed_http_hosts");
9
10pub const SERVICE_CHAINING_DOMAIN: &str = "spin.internal";
11pub const SERVICE_CHAINING_DOMAIN_SUFFIX: &str = ".spin.internal";
12
13/// Get the raw values of the `allowed_outbound_hosts` locked app metadata key.
14///
15/// This has support for converting the old `allowed_http_hosts` key to the new `allowed_outbound_hosts` key.
16pub fn allowed_outbound_hosts(component: &AppComponent) -> anyhow::Result<Vec<String>> {
17    let mut allowed_hosts = component
18        .get_metadata(ALLOWED_HOSTS_KEY)
19        .with_context(|| {
20            format!(
21                "locked app metadata was malformed for key {}",
22                ALLOWED_HOSTS_KEY.as_ref()
23            )
24        })?
25        .unwrap_or_default();
26    let allowed_http = component
27        .get_metadata(ALLOWED_HTTP_KEY)
28        .map(|h| h.unwrap_or_default())
29        .unwrap_or_default();
30    let converted =
31        spin_manifest::compat::convert_allowed_http_to_allowed_hosts(&allowed_http, false)
32            .unwrap_or_default();
33    allowed_hosts.extend(converted);
34    Ok(allowed_hosts)
35}
36
37/// Validates that all service chaining of an app will be satisfied by the
38/// supplied subset of components.
39///
40/// This does a best effort look up of components that are
41/// allowed to be accessed through service chaining and will error early if a
42/// component is configured to to chain to another component that is not
43/// retained. All wildcard service chaining is disallowed and all templated URLs
44/// are ignored.
45pub fn validate_service_chaining_for_components(
46    app: &App,
47    retained_components: &[&str],
48) -> anyhow::Result<()> {
49    app
50        .triggers().try_for_each(|t| {
51            let Ok(component) = t.component() else  { return Ok(()) };
52            if retained_components.contains(&component.id()) {
53            let allowed_hosts = allowed_outbound_hosts(&component).context("failed to get allowed hosts")?;
54            for host in allowed_hosts {
55                // Templated URLs are not yet resolved at this point, so ignore unresolvable URIs
56                if let Ok(uri) = host.parse::<http::Uri>() {
57                    if let Some(chaining_target) = parse_service_chaining_target(&uri) {
58                        if !retained_components.contains(&chaining_target.as_ref()) {
59                            if chaining_target == "*" {
60                                return  Err(anyhow::anyhow!("Selected component '{}' cannot use wildcard service chaining: allowed_outbound_hosts = [\"http://*.spin.internal\"]", component.id()));
61                            }
62                            return  Err(anyhow::anyhow!(
63                                "Selected component '{}' cannot use service chaining to unselected component: allowed_outbound_hosts = [\"http://{}.spin.internal\"]",
64                                component.id(), chaining_target
65                            ));
66                        }
67                    }
68                }
69            }
70        }
71        anyhow::Ok(())
72    })?;
73
74    Ok(())
75}
76
77/// An address is a url-like string that contains a host, a port, and an optional scheme
78#[derive(Eq, Debug, Clone)]
79pub struct AllowedHostConfig {
80    original: String,
81    scheme: SchemeConfig,
82    host: HostConfig,
83    port: PortConfig,
84}
85
86impl AllowedHostConfig {
87    /// Try to parse the address.
88    ///
89    /// If the parsing fails, the address is prepended with the scheme and parsing
90    /// is tried again.
91    pub fn parse(url: impl Into<String>) -> anyhow::Result<Self> {
92        let original = url.into();
93        let url = original.trim();
94        let Some((scheme, rest)) = url.split_once("://") else {
95            match url {
96                "*" | ":" | "" | "?" => bail!("{url:?} is not an allowed outbound host format.\nHosts must be in the form <scheme>://<host>[:<port>], with '*' wildcards allowed for each.\nIf you intended to allow all outbound networking, you can use '*://*:*' - this will obviate all network sandboxing.\nLearn more: https://spinframework.dev/v3/http-outbound#granting-http-permissions-to-components"),
97                _ => bail!("{url:?} does not contain a scheme (e.g., 'http://' or '*://')\nLearn more: https://spinframework.dev/v3/http-outbound#granting-http-permissions-to-components"),
98            }
99        };
100        let (host, rest) = rest.rsplit_once(':').unwrap_or((rest, ""));
101        let port = match rest.split_once('/') {
102            Some((port, path)) => {
103                if !path.is_empty() {
104                    bail!("{url:?} has a path but is not allowed to");
105                }
106                port
107            }
108            None => rest,
109        };
110
111        Ok(Self {
112            scheme: SchemeConfig::parse(scheme)?,
113            host: HostConfig::parse(host)?,
114            port: PortConfig::parse(port, scheme)?,
115            original,
116        })
117    }
118
119    pub fn scheme(&self) -> &SchemeConfig {
120        &self.scheme
121    }
122
123    pub fn host(&self) -> &HostConfig {
124        &self.host
125    }
126
127    pub fn port(&self) -> &PortConfig {
128        &self.port
129    }
130
131    fn allows(&self, url: &OutboundUrl) -> bool {
132        self.scheme.allows(&url.scheme)
133            && self.host.allows(&url.host)
134            && self.port.allows(url.port, &url.scheme)
135    }
136
137    fn allows_relative(&self, schemes: &[&str]) -> bool {
138        schemes.iter().any(|s| self.scheme.allows(s)) && self.host.allows_relative()
139    }
140}
141
142impl PartialEq for AllowedHostConfig {
143    fn eq(&self, other: &Self) -> bool {
144        self.scheme == other.scheme && self.host == other.host && self.port == other.port
145    }
146}
147
148impl std::fmt::Display for AllowedHostConfig {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.write_str(&self.original)
151    }
152}
153
154#[derive(PartialEq, Eq, Debug, Clone)]
155pub enum SchemeConfig {
156    Any,
157    List(Vec<String>),
158}
159
160impl SchemeConfig {
161    fn parse(scheme: &str) -> anyhow::Result<Self> {
162        if scheme == "*" {
163            return Ok(Self::Any);
164        }
165
166        if scheme.starts_with('{') {
167            // TODO:
168            bail!("scheme lists are not yet supported")
169        }
170
171        if scheme.chars().any(|c| !c.is_alphabetic()) {
172            anyhow::bail!(" scheme {scheme:?} contains non alphabetic character");
173        }
174
175        Ok(Self::List(vec![scheme.into()]))
176    }
177
178    pub fn allows_any(&self) -> bool {
179        matches!(self, Self::Any)
180    }
181
182    fn allows(&self, scheme: &str) -> bool {
183        match self {
184            SchemeConfig::Any => true,
185            SchemeConfig::List(l) => l.iter().any(|s| s.as_str() == scheme),
186        }
187    }
188}
189
190#[derive(Debug, PartialEq, Eq, Clone)]
191pub enum HostConfig {
192    Any,
193    AnySubdomain(String),
194    ToSelf,
195    List(Vec<String>),
196    Cidr(ip_network::IpNetwork),
197}
198
199impl HostConfig {
200    fn parse(mut host: &str) -> anyhow::Result<Self> {
201        host = host.trim();
202        if host == "*" {
203            return Ok(Self::Any);
204        }
205
206        if host == "self" || host == "self.alt" {
207            return Ok(Self::ToSelf);
208        }
209
210        if host.starts_with('{') {
211            ensure!(host.ends_with('}'));
212            bail!("host lists are not yet supported")
213        }
214
215        if let Ok(net) = ip_network::IpNetwork::from_str_truncate(host) {
216            return Ok(Self::Cidr(net));
217        }
218
219        if matches!(host.split('/').nth(1), Some(path) if !path.is_empty()) {
220            bail!("hosts must not contain paths");
221        }
222
223        if let Some(domain) = host.strip_prefix("*.") {
224            if domain.contains('*') {
225                bail!("Invalid allowed host {host}: wildcards are allowed only as prefixes");
226            }
227            return Ok(Self::AnySubdomain(format!(".{domain}")));
228        }
229
230        if host.contains('*') {
231            bail!("Invalid allowed host {host}: wildcards are allowed only as subdomains");
232        }
233
234        // Remove trailing slashes
235        host = host.trim_end_matches('/');
236
237        Ok(Self::List(vec![host.into()]))
238    }
239
240    fn allows(&self, host: &str) -> bool {
241        match self {
242            HostConfig::Any => true,
243            HostConfig::AnySubdomain(suffix) => host.ends_with(suffix),
244            HostConfig::List(l) => l.iter().any(|h| h.as_str() == host),
245            HostConfig::ToSelf => false,
246            HostConfig::Cidr(c) => {
247                let Ok(ip) = host.parse::<std::net::IpAddr>() else {
248                    return false;
249                };
250                c.contains(ip)
251            }
252        }
253    }
254
255    fn allows_relative(&self) -> bool {
256        matches!(self, Self::Any | Self::ToSelf)
257    }
258}
259
260#[derive(Debug, PartialEq, Eq, Clone)]
261pub enum PortConfig {
262    Any,
263    List(Vec<IndividualPortConfig>),
264}
265
266impl PortConfig {
267    fn parse(port: &str, scheme: &str) -> anyhow::Result<PortConfig> {
268        if port.is_empty() {
269            return well_known_port(scheme)
270                .map(|p| PortConfig::List(vec![IndividualPortConfig::Port(p)]))
271                .with_context(|| format!("no port was provided and the scheme {scheme:?} does not have a known default port number"));
272        }
273        if port == "*" {
274            return Ok(PortConfig::Any);
275        }
276
277        if port.starts_with('{') {
278            // TODO:
279            bail!("port lists are not yet supported")
280        }
281
282        let port = IndividualPortConfig::parse(port)?;
283
284        Ok(Self::List(vec![port]))
285    }
286
287    fn allows(&self, port: Option<u16>, scheme: &str) -> bool {
288        match self {
289            PortConfig::Any => true,
290            PortConfig::List(l) => {
291                let port = match port.or_else(|| well_known_port(scheme)) {
292                    Some(p) => p,
293                    None => return false,
294                };
295                l.iter().any(|p| p.allows(port))
296            }
297        }
298    }
299}
300
301#[derive(Debug, PartialEq, Eq, Clone)]
302pub enum IndividualPortConfig {
303    Port(u16),
304    Range(Range<u16>),
305}
306
307impl IndividualPortConfig {
308    fn parse(port: &str) -> anyhow::Result<Self> {
309        if let Some((start, end)) = port.split_once("..") {
310            let start = start
311                .parse()
312                .with_context(|| format!("port range {port:?} contains non-number"))?;
313            let end = end
314                .parse()
315                .with_context(|| format!("port range {port:?} contains non-number"))?;
316            return Ok(Self::Range(start..end));
317        }
318        Ok(Self::Port(port.parse().with_context(|| {
319            format!("port {port:?} is not a number")
320        })?))
321    }
322
323    fn allows(&self, port: u16) -> bool {
324        match self {
325            IndividualPortConfig::Port(p) => p == &port,
326            IndividualPortConfig::Range(r) => r.contains(&port),
327        }
328    }
329}
330
331fn well_known_port(scheme: &str) -> Option<u16> {
332    match scheme {
333        "postgres" => Some(5432),
334        "mysql" => Some(3306),
335        "redis" => Some(6379),
336        "mqtt" => Some(1883),
337        "http" => Some(80),
338        "https" => Some(443),
339        _ => None,
340    }
341}
342
343#[derive(PartialEq, Eq, Debug, Clone)]
344pub enum AllowedHostsConfig {
345    All,
346    SpecificHosts(Vec<AllowedHostConfig>),
347}
348
349enum PartialAllowedHostConfig {
350    Exact(AllowedHostConfig),
351    Unresolved(spin_expressions::Template),
352}
353
354impl PartialAllowedHostConfig {
355    fn resolve(
356        self,
357        resolver: &spin_expressions::PreparedResolver,
358    ) -> anyhow::Result<AllowedHostConfig> {
359        match self {
360            Self::Exact(h) => Ok(h),
361            Self::Unresolved(t) => AllowedHostConfig::parse(resolver.resolve_template(&t)?),
362        }
363    }
364}
365
366impl AllowedHostsConfig {
367    pub fn parse<S: AsRef<str>>(
368        hosts: &[S],
369        resolver: &spin_expressions::PreparedResolver,
370    ) -> anyhow::Result<AllowedHostsConfig> {
371        let partial = Self::parse_partial(hosts)?;
372        let allowed = partial
373            .into_iter()
374            .map(|p| p.resolve(resolver))
375            .collect::<anyhow::Result<Vec<_>>>()?;
376        Ok(Self::SpecificHosts(allowed))
377    }
378
379    pub fn validate<S: AsRef<str>>(hosts: &[S]) -> anyhow::Result<()> {
380        _ = Self::parse_partial(hosts)?;
381        Ok(())
382    }
383
384    // Parses literals but defers templates. This is pulled out so that `validate`
385    // doesn't have to deal with resolving templates.
386    fn parse_partial<S: AsRef<str>>(hosts: &[S]) -> anyhow::Result<Vec<PartialAllowedHostConfig>> {
387        if hosts.len() == 1 && hosts[0].as_ref() == "insecure:allow-all" {
388            bail!("'insecure:allow-all' is not allowed - use '*://*:*' instead if you really want to allow all outbound traffic'")
389        }
390        let mut allowed = Vec::with_capacity(hosts.len());
391        for host in hosts {
392            let template = spin_expressions::Template::new(host.as_ref())?;
393            if template.is_literal() {
394                allowed.push(PartialAllowedHostConfig::Exact(AllowedHostConfig::parse(
395                    host.as_ref(),
396                )?));
397            } else {
398                allowed.push(PartialAllowedHostConfig::Unresolved(template));
399            }
400        }
401        Ok(allowed)
402    }
403
404    /// Determine if the supplied url is allowed
405    pub fn allows(&self, url: &OutboundUrl) -> bool {
406        match self {
407            AllowedHostsConfig::All => true,
408            AllowedHostsConfig::SpecificHosts(hosts) => hosts.iter().any(|h| h.allows(url)),
409        }
410    }
411
412    pub fn allows_relative_url(&self, schemes: &[&str]) -> bool {
413        match self {
414            AllowedHostsConfig::All => true,
415            AllowedHostsConfig::SpecificHosts(hosts) => {
416                hosts.iter().any(|h| h.allows_relative(schemes))
417            }
418        }
419    }
420}
421
422impl Default for AllowedHostsConfig {
423    fn default() -> Self {
424        Self::SpecificHosts(Vec::new())
425    }
426}
427
428/// A parsed URL used for outbound networking.
429#[derive(Debug, Clone)]
430pub struct OutboundUrl {
431    scheme: String,
432    host: String,
433    port: Option<u16>,
434    original: String,
435}
436
437impl OutboundUrl {
438    /// Parse a URL.
439    ///
440    /// If parsing `url` fails, `{scheme}://` is prepended to `url` and parsing is tried again.
441    pub fn parse(url: impl Into<String>, scheme: &str) -> anyhow::Result<Self> {
442        let mut url = url.into();
443        let original = url.clone();
444
445        // Ensure that the authority is url encoded. Since the authority is ignored after this,
446        // we can always url encode the authority even if it is already encoded.
447        if let Some(at) = url.find('@') {
448            let scheme_end = url.find("://").map(|e| e + 3).unwrap_or(0);
449            let path_start = url[scheme_end..]
450                .find('/') // This can calculate the wrong index if the username or password contains a '/'
451                .map(|e| e + scheme_end)
452                .unwrap_or(usize::MAX);
453
454            if at < path_start {
455                let userinfo = &url[scheme_end..at];
456
457                let encoded = urlencoding::encode(userinfo);
458                let prefix = &url[..scheme_end];
459                let suffix = &url[scheme_end + userinfo.len()..];
460                url = format!("{prefix}{encoded}{suffix}");
461            }
462        }
463
464        let parsed = match url::Url::parse(&url) {
465            Ok(url) if url.has_host() => Ok(url),
466            first_try => {
467                let second_try: anyhow::Result<url::Url> = format!("{scheme}://{url}")
468                    .as_str()
469                    .try_into()
470                    .context("could not convert into a url");
471                match (second_try, first_try.map_err(|e| e.into())) {
472                    (Ok(u), _) => Ok(u),
473                    // Return an error preferring the error from the first attempt if present
474                    (_, Err(e)) | (Err(e), _) => Err(e),
475                }
476            }
477        }?;
478
479        Ok(Self {
480            scheme: parsed.scheme().to_owned(),
481            host: parsed
482                .host_str()
483                .with_context(|| format!("{url:?} does not have a host component"))?
484                .to_owned(),
485            port: parsed.port(),
486            original,
487        })
488    }
489
490    pub fn scheme(&self) -> &str {
491        &self.scheme
492    }
493
494    pub fn authority(&self) -> String {
495        if let Some(port) = self.port {
496            format!("{}:{port}", self.host)
497        } else {
498            self.host.clone()
499        }
500    }
501}
502
503impl std::fmt::Display for OutboundUrl {
504    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
505        f.write_str(&self.original)
506    }
507}
508
509pub fn is_service_chaining_host(host: &str) -> bool {
510    parse_service_chaining_host(host).is_some()
511}
512
513pub fn parse_service_chaining_target(url: &http::Uri) -> Option<String> {
514    let host = url.authority().map(|a| a.host().trim())?;
515    parse_service_chaining_host(host)
516}
517
518fn parse_service_chaining_host(host: &str) -> Option<String> {
519    let (host, _) = host.rsplit_once(':').unwrap_or((host, ""));
520
521    let (first, rest) = host.split_once('.')?;
522
523    if rest == SERVICE_CHAINING_DOMAIN {
524        Some(first.to_owned())
525    } else {
526        None
527    }
528}
529
530#[cfg(test)]
531mod test {
532    impl AllowedHostConfig {
533        fn new(scheme: SchemeConfig, host: HostConfig, port: PortConfig) -> Self {
534            Self {
535                scheme,
536                host,
537                port,
538                original: String::new(),
539            }
540        }
541    }
542
543    impl SchemeConfig {
544        fn new(scheme: &str) -> Self {
545            Self::List(vec![scheme.into()])
546        }
547    }
548
549    impl HostConfig {
550        fn new(host: &str) -> Self {
551            Self::List(vec![host.into()])
552        }
553        fn subdomain(domain: &str) -> Self {
554            Self::AnySubdomain(format!(".{domain}"))
555        }
556    }
557
558    impl PortConfig {
559        fn new(port: u16) -> Self {
560            Self::List(vec![IndividualPortConfig::Port(port)])
561        }
562
563        fn range(port: Range<u16>) -> Self {
564            Self::List(vec![IndividualPortConfig::Range(port)])
565        }
566    }
567
568    fn dummy_resolver() -> spin_expressions::PreparedResolver {
569        spin_expressions::PreparedResolver::default()
570    }
571
572    use ip_network::{IpNetwork, Ipv4Network, Ipv6Network};
573
574    use super::*;
575    use std::net::{Ipv4Addr, Ipv6Addr};
576
577    #[test]
578    fn outbound_url_handles_at_in_paths() {
579        let url = "https://example.com/file@0.1.0.json";
580        let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
581        assert_eq!("example.com", url.host);
582
583        let url = "https://user:password@example.com/file@0.1.0.json";
584        let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
585        assert_eq!("example.com", url.host);
586
587        let url = "https://user:pass#word@example.com/file@0.1.0.json";
588        let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
589        assert_eq!("example.com", url.host);
590
591        let url = "https://user:password@example.com";
592        let url = OutboundUrl::parse(url, "https").expect("should have parsed url");
593        assert_eq!("example.com", url.host);
594    }
595
596    #[test]
597    fn test_allowed_hosts_accepts_url_without_port() {
598        assert_eq!(
599            AllowedHostConfig::new(
600                SchemeConfig::new("http"),
601                HostConfig::new("spin.fermyon.dev"),
602                PortConfig::new(80)
603            ),
604            AllowedHostConfig::parse("http://spin.fermyon.dev").unwrap()
605        );
606
607        assert_eq!(
608            AllowedHostConfig::new(
609                SchemeConfig::new("http"),
610                // Trailing slash is removed
611                HostConfig::new("spin.fermyon.dev"),
612                PortConfig::new(80)
613            ),
614            AllowedHostConfig::parse("http://spin.fermyon.dev/").unwrap()
615        );
616
617        assert_eq!(
618            AllowedHostConfig::new(
619                SchemeConfig::new("https"),
620                HostConfig::new("spin.fermyon.dev"),
621                PortConfig::new(443)
622            ),
623            AllowedHostConfig::parse("https://spin.fermyon.dev").unwrap()
624        );
625    }
626
627    #[test]
628    fn test_allowed_hosts_accepts_url_with_port() {
629        assert_eq!(
630            AllowedHostConfig::new(
631                SchemeConfig::new("http"),
632                HostConfig::new("spin.fermyon.dev"),
633                PortConfig::new(4444)
634            ),
635            AllowedHostConfig::parse("http://spin.fermyon.dev:4444").unwrap()
636        );
637        assert_eq!(
638            AllowedHostConfig::new(
639                SchemeConfig::new("http"),
640                HostConfig::new("spin.fermyon.dev"),
641                PortConfig::new(4444)
642            ),
643            AllowedHostConfig::parse("http://spin.fermyon.dev:4444/").unwrap()
644        );
645        assert_eq!(
646            AllowedHostConfig::new(
647                SchemeConfig::new("https"),
648                HostConfig::new("spin.fermyon.dev"),
649                PortConfig::new(5555)
650            ),
651            AllowedHostConfig::parse("https://spin.fermyon.dev:5555").unwrap()
652        );
653    }
654
655    #[test]
656    fn test_allowed_hosts_accepts_url_with_port_range() {
657        assert_eq!(
658            AllowedHostConfig::new(
659                SchemeConfig::new("http"),
660                HostConfig::new("spin.fermyon.dev"),
661                PortConfig::range(4444..5555)
662            ),
663            AllowedHostConfig::parse("http://spin.fermyon.dev:4444..5555").unwrap()
664        );
665    }
666
667    #[test]
668    fn test_allowed_hosts_does_not_accept_plain_host_without_port() {
669        assert!(AllowedHostConfig::parse("spin.fermyon.dev").is_err());
670    }
671
672    #[test]
673    fn test_allowed_hosts_does_not_accept_plain_host_without_scheme() {
674        assert!(AllowedHostConfig::parse("spin.fermyon.dev:80").is_err());
675    }
676
677    #[test]
678    fn test_allowed_hosts_accepts_host_with_glob_scheme() {
679        assert_eq!(
680            AllowedHostConfig::new(
681                SchemeConfig::Any,
682                HostConfig::new("spin.fermyon.dev"),
683                PortConfig::new(7777)
684            ),
685            AllowedHostConfig::parse("*://spin.fermyon.dev:7777").unwrap()
686        )
687    }
688
689    #[test]
690    fn test_allowed_hosts_accepts_self() {
691        assert_eq!(
692            AllowedHostConfig::new(
693                SchemeConfig::new("http"),
694                HostConfig::ToSelf,
695                PortConfig::new(80)
696            ),
697            AllowedHostConfig::parse("http://self").unwrap()
698        );
699    }
700
701    #[test]
702    fn test_allowed_hosts_accepts_localhost_addresses() {
703        assert!(AllowedHostConfig::parse("localhost").is_err());
704        assert_eq!(
705            AllowedHostConfig::new(
706                SchemeConfig::new("http"),
707                HostConfig::new("localhost"),
708                PortConfig::new(80)
709            ),
710            AllowedHostConfig::parse("http://localhost").unwrap()
711        );
712        assert!(AllowedHostConfig::parse("localhost:3001").is_err());
713        assert_eq!(
714            AllowedHostConfig::new(
715                SchemeConfig::new("http"),
716                HostConfig::new("localhost"),
717                PortConfig::new(3001)
718            ),
719            AllowedHostConfig::parse("http://localhost:3001").unwrap()
720        );
721    }
722
723    #[test]
724    fn test_allowed_hosts_accepts_subdomain_wildcards() {
725        assert_eq!(
726            AllowedHostConfig::new(
727                SchemeConfig::new("http"),
728                HostConfig::subdomain("example.com"),
729                PortConfig::new(80)
730            ),
731            AllowedHostConfig::parse("http://*.example.com").unwrap()
732        );
733    }
734
735    #[test]
736    fn test_allowed_hosts_accepts_ip_addresses() {
737        assert_eq!(
738            AllowedHostConfig::new(
739                SchemeConfig::new("http"),
740                HostConfig::new("192.168.1.1"),
741                PortConfig::new(80)
742            ),
743            AllowedHostConfig::parse("http://192.168.1.1").unwrap()
744        );
745        assert_eq!(
746            AllowedHostConfig::new(
747                SchemeConfig::new("http"),
748                HostConfig::new("192.168.1.1"),
749                PortConfig::new(3002)
750            ),
751            AllowedHostConfig::parse("http://192.168.1.1:3002").unwrap()
752        );
753        assert_eq!(
754            AllowedHostConfig::new(
755                SchemeConfig::new("http"),
756                HostConfig::new("[::1]"),
757                PortConfig::new(8001)
758            ),
759            AllowedHostConfig::parse("http://[::1]:8001").unwrap()
760        );
761
762        assert!(AllowedHostConfig::parse("http://[::1]").is_err())
763    }
764
765    #[test]
766    fn test_allowed_hosts_accepts_ip_cidr() {
767        assert_eq!(
768            AllowedHostConfig::new(
769                SchemeConfig::Any,
770                HostConfig::Cidr(IpNetwork::V4(
771                    Ipv4Network::new(Ipv4Addr::new(127, 0, 0, 0), 24).unwrap()
772                )),
773                PortConfig::new(80)
774            ),
775            AllowedHostConfig::parse("*://127.0.0.0/24:80").unwrap()
776        );
777        assert!(AllowedHostConfig::parse("*://127.0.0.0/24").is_err());
778        assert_eq!(
779            AllowedHostConfig::new(
780                SchemeConfig::Any,
781                HostConfig::Cidr(IpNetwork::V6(
782                    Ipv6Network::new(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8).unwrap()
783                )),
784                PortConfig::new(80)
785            ),
786            AllowedHostConfig::parse("*://ff00::/8:80").unwrap()
787        );
788    }
789
790    #[test]
791    fn test_allowed_hosts_rejects_path() {
792        // An empty path is allowed
793        assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/").is_ok());
794        // All other paths are not allowed
795        assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/a").is_err());
796        assert!(AllowedHostConfig::parse("http://spin.fermyon.dev:6666/a/b").is_err());
797        assert!(AllowedHostConfig::parse("http://*.fermyon.dev/a").is_err());
798    }
799
800    #[test]
801    fn test_allowed_hosts_respects_allow_all() {
802        assert!(AllowedHostsConfig::parse(&["insecure:allow-all"], &dummy_resolver()).is_err());
803        assert!(AllowedHostsConfig::parse(
804            &["spin.fermyon.dev", "insecure:allow-all"],
805            &dummy_resolver()
806        )
807        .is_err());
808    }
809
810    #[test]
811    fn test_allowed_all_globs() {
812        assert_eq!(
813            AllowedHostConfig::new(SchemeConfig::Any, HostConfig::Any, PortConfig::Any),
814            AllowedHostConfig::parse("*://*:*").unwrap()
815        );
816    }
817
818    #[test]
819    fn test_missing_scheme() {
820        assert!(AllowedHostConfig::parse("example.com").is_err());
821    }
822
823    #[test]
824    fn test_allowed_hosts_can_be_specific() {
825        let allowed = AllowedHostsConfig::parse(
826            &["*://spin.fermyon.dev:443", "http://example.com:8383"],
827            &dummy_resolver(),
828        )
829        .unwrap();
830        assert!(
831            allowed.allows(&OutboundUrl::parse("http://example.com:8383/foo/bar", "http").unwrap())
832        );
833        // Allow urls with and without a trailing slash
834        assert!(allowed.allows(&OutboundUrl::parse("https://spin.fermyon.dev", "https").unwrap()));
835        assert!(allowed.allows(&OutboundUrl::parse("https://spin.fermyon.dev/", "https").unwrap()));
836        assert!(!allowed.allows(&OutboundUrl::parse("http://example.com/", "http").unwrap()));
837        assert!(!allowed.allows(&OutboundUrl::parse("http://google.com/", "http").unwrap()));
838        assert!(allowed.allows(&OutboundUrl::parse("spin.fermyon.dev:443", "https").unwrap()));
839        assert!(allowed.allows(&OutboundUrl::parse("example.com:8383", "http").unwrap()));
840    }
841
842    #[test]
843    fn test_allowed_hosts_with_trailing_slash() {
844        let allowed =
845            AllowedHostsConfig::parse(&["https://my.api.com/"], &dummy_resolver()).unwrap();
846        assert!(allowed.allows(&OutboundUrl::parse("https://my.api.com", "https").unwrap()));
847        assert!(allowed.allows(&OutboundUrl::parse("https://my.api.com/", "https").unwrap()));
848    }
849
850    #[test]
851    fn test_allowed_hosts_can_be_subdomain_wildcards() {
852        let allowed = AllowedHostsConfig::parse(
853            &["http://*.example.com", "http://*.example2.com:8383"],
854            &dummy_resolver(),
855        )
856        .unwrap();
857        assert!(
858            allowed.allows(&OutboundUrl::parse("http://a.example.com/foo/bar", "http").unwrap())
859        );
860        assert!(
861            allowed.allows(&OutboundUrl::parse("http://a.b.example.com/foo/bar", "http").unwrap())
862        );
863        assert!(allowed
864            .allows(&OutboundUrl::parse("http://a.b.example2.com:8383/foo/bar", "http").unwrap()));
865        assert!(!allowed
866            .allows(&OutboundUrl::parse("http://a.b.example2.com/foo/bar", "http").unwrap()));
867        assert!(!allowed.allows(&OutboundUrl::parse("http://example.com/foo/bar", "http").unwrap()));
868        assert!(!allowed
869            .allows(&OutboundUrl::parse("http://example.com:8383/foo/bar", "http").unwrap()));
870        assert!(
871            !allowed.allows(&OutboundUrl::parse("http://myexample.com/foo/bar", "http").unwrap())
872        );
873    }
874
875    #[test]
876    fn test_hash_char_in_db_password() {
877        let allowed = AllowedHostsConfig::parse(&["mysql://xyz.com"], &dummy_resolver()).unwrap();
878        assert!(
879            allowed.allows(&OutboundUrl::parse("mysql://user:pass#word@xyz.com", "mysql").unwrap())
880        );
881        assert!(allowed
882            .allows(&OutboundUrl::parse("mysql://user%3Apass%23word@xyz.com", "mysql").unwrap()));
883        assert!(allowed.allows(&OutboundUrl::parse("user%3Apass%23word@xyz.com", "mysql").unwrap()));
884    }
885
886    #[test]
887    fn test_cidr() {
888        let allowed =
889            AllowedHostsConfig::parse(&["*://127.0.0.1/24:63551"], &dummy_resolver()).unwrap();
890        assert!(allowed.allows(&OutboundUrl::parse("tcp://127.0.0.1:63551", "tcp").unwrap()));
891    }
892
893    #[tokio::test]
894    async fn validate_service_chaining_for_components_fails() {
895        let manifest = toml::toml! {
896            spin_manifest_version = 2
897
898            [application]
899            name = "test-app"
900
901            [[trigger.test-trigger]]
902            component = "empty"
903
904            [component.empty]
905            source = "does-not-exist.wasm"
906            allowed_outbound_hosts = ["http://another.spin.internal"]
907
908            [[trigger.another-trigger]]
909            component = "another"
910
911            [component.another]
912            source = "does-not-exist.wasm"
913
914            [[trigger.third-trigger]]
915            component = "third"
916
917            [component.third]
918            source = "does-not-exist.wasm"
919            allowed_outbound_hosts = ["http://*.spin.internal"]
920        };
921        let locked_app = spin_factors_test::build_locked_app(&manifest)
922            .await
923            .expect("could not build locked app");
924        let app = App::new("unused", locked_app);
925        let Err(e) = validate_service_chaining_for_components(&app, &["empty"]) else {
926            panic!("Expected service chaining to non-retained component error");
927        };
928        assert_eq!(
929            e.to_string(),
930            "Selected component 'empty' cannot use service chaining to unselected component: allowed_outbound_hosts = [\"http://another.spin.internal\"]"
931        );
932        let Err(e) = validate_service_chaining_for_components(&app, &["third", "another"]) else {
933            panic!("Expected wildcard service chaining error");
934        };
935        assert_eq!(
936            e.to_string(),
937            "Selected component 'third' cannot use wildcard service chaining: allowed_outbound_hosts = [\"http://*.spin.internal\"]"
938        );
939        assert!(validate_service_chaining_for_components(&app, &["another"]).is_ok());
940    }
941
942    #[tokio::test]
943    async fn validate_service_chaining_for_components_with_templated_host_passes() {
944        let manifest = toml::toml! {
945            spin_manifest_version = 2
946
947            [application]
948            name = "test-app"
949
950            [variables]
951            host = { default = "test" }
952
953            [[trigger.test-trigger]]
954            component = "empty"
955
956            [component.empty]
957            source = "does-not-exist.wasm"
958
959            [[trigger.another-trigger]]
960            component = "another"
961
962            [component.another]
963            source = "does-not-exist.wasm"
964
965            [[trigger.third-trigger]]
966            component = "third"
967
968            [component.third]
969            source = "does-not-exist.wasm"
970            allowed_outbound_hosts = ["http://{{ host }}.spin.internal"]
971        };
972        let locked_app = spin_factors_test::build_locked_app(&manifest)
973            .await
974            .expect("could not build locked app");
975        let app = App::new("unused", locked_app);
976        assert!(validate_service_chaining_for_components(&app, &["empty", "third"]).is_ok());
977    }
978}