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