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
13pub 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
37pub 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 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#[derive(Eq, Debug, Clone)]
79pub struct AllowedHostConfig {
80 original: String,
81 scheme: SchemeConfig,
82 host: HostConfig,
83 port: PortConfig,
84}
85
86impl AllowedHostConfig {
87 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 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 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 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 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 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#[derive(Debug, Clone)]
430pub struct OutboundUrl {
431 scheme: String,
432 host: String,
433 port: Option<u16>,
434 original: String,
435}
436
437impl OutboundUrl {
438 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 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('/') .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 (_, 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 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 assert!(AllowedHostConfig::parse("http://spin.fermyon.dev/").is_ok());
794 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 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}