Skip to main content

spin_manifest/compat/
allowed_http_hosts.rs

1use anyhow::{Result, anyhow};
2use itertools::Itertools;
3use url::Url;
4
5const ALLOW_ALL_HOSTS: &str = "insecure:allow-all";
6
7/// An HTTP host allow-list.
8#[derive(Clone, Debug, Eq, PartialEq)]
9pub enum AllowedHttpHosts {
10    /// All HTTP hosts are allowed (the "insecure:allow-all" value was present in the list)
11    AllowAll,
12    /// Only the specified hosts are allowed.
13    AllowSpecific(Vec<AllowedHttpHost>),
14}
15
16impl Default for AllowedHttpHosts {
17    fn default() -> Self {
18        Self::AllowSpecific(vec![])
19    }
20}
21
22/// An HTTP host allow-list entry.
23#[derive(Clone, Debug, Default, Eq, PartialEq)]
24pub struct AllowedHttpHost {
25    pub(crate) domain: String,
26    pub(crate) port: Option<u16>,
27}
28
29impl AllowedHttpHost {
30    /// Creates a new allow-list entry.
31    pub fn new(name: impl Into<String>, port: Option<u16>) -> Self {
32        Self {
33            domain: name.into(),
34            port,
35        }
36    }
37}
38
39/// Parses a list of allowed HTTP hosts
40pub fn parse_allowed_http_hosts(list: &[impl AsRef<str>]) -> Result<AllowedHttpHosts> {
41    if list.iter().any(|domain| domain.as_ref() == ALLOW_ALL_HOSTS) {
42        Ok(AllowedHttpHosts::AllowAll)
43    } else {
44        let (hosts, errors): (Vec<_>, Vec<_>) = list
45            .iter()
46            .map(|h| parse_allowed_http_host(h.as_ref()))
47            .partition_result();
48
49        if errors.is_empty() {
50            Ok(AllowedHttpHosts::AllowSpecific(hosts))
51        } else {
52            Err(anyhow!(
53                "One or more allowed_http_hosts entries was invalid:\n{}",
54                errors.join("\n")
55            ))
56        }
57    }
58}
59
60fn parse_allowed_http_host(text: &str) -> Result<AllowedHttpHost, String> {
61    // If you call Url::parse, it accepts things like `localhost:3001`, inferring
62    // `localhost` as a scheme. That's unhelpful for us, so we do a crude check
63    // before trying to treat the string as a URL.
64    if text.contains("//") {
65        parse_allowed_http_host_from_schemed(text)
66    } else {
67        parse_allowed_http_host_from_unschemed(text)
68    }
69}
70
71fn parse_allowed_http_host_from_unschemed(text: &str) -> Result<AllowedHttpHost, String> {
72    // Host name parsing is quite hairy (thanks, IPv6), so punt it off to the
73    // Url type which gets paid big bucks to do it properly. (But preserve the
74    // original un-URL-ified string for use in error messages.)
75    let urlised = format!("http://{text}");
76    let fake_url = Url::parse(&urlised)
77        .map_err(|_| format!("{text} isn't a valid host or host:port string"))?;
78    parse_allowed_http_host_from_http_url(&fake_url, text)
79}
80
81fn parse_allowed_http_host_from_schemed(text: &str) -> Result<AllowedHttpHost, String> {
82    let url = Url::parse(text).map_err(|e| format!("{text} isn't a valid HTTP host URL: {e}"))?;
83
84    if !matches!(url.scheme(), "http" | "https") {
85        return Err(format!("{text} isn't a valid host or host:port string"));
86    }
87
88    parse_allowed_http_host_from_http_url(&url, text)
89}
90
91fn parse_allowed_http_host_from_http_url(url: &Url, text: &str) -> Result<AllowedHttpHost, String> {
92    let host = url
93        .host_str()
94        .ok_or_else(|| format!("{text} doesn't contain a host name"))?;
95
96    let has_path = url.path().len() > 1; // allow "/"
97    if has_path {
98        return Err(format!(
99            "{text} contains a path, should be host and optional port only"
100        ));
101    }
102
103    Ok(AllowedHttpHost::new(host, url.port()))
104}
105
106#[cfg(test)]
107mod test {
108    use super::*;
109
110    impl AllowedHttpHost {
111        /// An allow-list entry that specifies a host and allows the default port.
112        fn host(name: impl Into<String>) -> Self {
113            Self {
114                domain: name.into(),
115                port: None,
116            }
117        }
118
119        /// An allow-list entry that specifies a host and port.
120        fn host_and_port(name: impl Into<String>, port: u16) -> Self {
121            Self {
122                domain: name.into(),
123                port: Some(port),
124            }
125        }
126    }
127
128    #[test]
129    fn test_allowed_hosts_accepts_http_url() {
130        assert_eq!(
131            AllowedHttpHost::host("spin.fermyon.dev"),
132            parse_allowed_http_host("http://spin.fermyon.dev").unwrap()
133        );
134        assert_eq!(
135            AllowedHttpHost::host("spin.fermyon.dev"),
136            parse_allowed_http_host("http://spin.fermyon.dev/").unwrap()
137        );
138        assert_eq!(
139            AllowedHttpHost::host("spin.fermyon.dev"),
140            parse_allowed_http_host("https://spin.fermyon.dev").unwrap()
141        );
142    }
143
144    #[test]
145    fn test_allowed_hosts_accepts_http_url_with_port() {
146        assert_eq!(
147            AllowedHttpHost::host_and_port("spin.fermyon.dev", 4444),
148            parse_allowed_http_host("http://spin.fermyon.dev:4444").unwrap()
149        );
150        assert_eq!(
151            AllowedHttpHost::host_and_port("spin.fermyon.dev", 4444),
152            parse_allowed_http_host("http://spin.fermyon.dev:4444/").unwrap()
153        );
154        assert_eq!(
155            AllowedHttpHost::host_and_port("spin.fermyon.dev", 5555),
156            parse_allowed_http_host("https://spin.fermyon.dev:5555").unwrap()
157        );
158    }
159
160    #[test]
161    fn test_allowed_hosts_accepts_plain_host() {
162        assert_eq!(
163            AllowedHttpHost::host("spin.fermyon.dev"),
164            parse_allowed_http_host("spin.fermyon.dev").unwrap()
165        );
166    }
167
168    #[test]
169    fn test_allowed_hosts_accepts_plain_host_with_port() {
170        assert_eq!(
171            AllowedHttpHost::host_and_port("spin.fermyon.dev", 7777),
172            parse_allowed_http_host("spin.fermyon.dev:7777").unwrap()
173        );
174    }
175
176    #[test]
177    fn test_allowed_hosts_accepts_self() {
178        assert_eq!(
179            AllowedHttpHost::host("self"),
180            parse_allowed_http_host("self").unwrap()
181        );
182    }
183
184    #[test]
185    fn test_allowed_hosts_accepts_localhost_addresses() {
186        assert_eq!(
187            AllowedHttpHost::host("localhost"),
188            parse_allowed_http_host("localhost").unwrap()
189        );
190        assert_eq!(
191            AllowedHttpHost::host("localhost"),
192            parse_allowed_http_host("http://localhost").unwrap()
193        );
194        assert_eq!(
195            AllowedHttpHost::host_and_port("localhost", 3001),
196            parse_allowed_http_host("localhost:3001").unwrap()
197        );
198        assert_eq!(
199            AllowedHttpHost::host_and_port("localhost", 3001),
200            parse_allowed_http_host("http://localhost:3001").unwrap()
201        );
202    }
203
204    #[test]
205    fn test_allowed_hosts_accepts_ip_addresses() {
206        assert_eq!(
207            AllowedHttpHost::host("192.168.1.1"),
208            parse_allowed_http_host("192.168.1.1").unwrap()
209        );
210        assert_eq!(
211            AllowedHttpHost::host("192.168.1.1"),
212            parse_allowed_http_host("http://192.168.1.1").unwrap()
213        );
214        assert_eq!(
215            AllowedHttpHost::host_and_port("192.168.1.1", 3002),
216            parse_allowed_http_host("192.168.1.1:3002").unwrap()
217        );
218        assert_eq!(
219            AllowedHttpHost::host_and_port("192.168.1.1", 3002),
220            parse_allowed_http_host("http://192.168.1.1:3002").unwrap()
221        );
222        assert_eq!(
223            AllowedHttpHost::host("[::1]"),
224            parse_allowed_http_host("[::1]").unwrap()
225        );
226        assert_eq!(
227            AllowedHttpHost::host_and_port("[::1]", 8001),
228            parse_allowed_http_host("http://[::1]:8001").unwrap()
229        );
230    }
231
232    #[test]
233    fn test_allowed_hosts_rejects_path() {
234        assert!(parse_allowed_http_host("http://spin.fermyon.dev/a").is_err());
235        assert!(parse_allowed_http_host("http://spin.fermyon.dev:6666/a/b").is_err());
236    }
237
238    #[test]
239    fn test_allowed_hosts_rejects_ftp_url() {
240        assert!(parse_allowed_http_host("ftp://spin.fermyon.dev").is_err());
241        assert!(parse_allowed_http_host("ftp://spin.fermyon.dev:6666").is_err());
242    }
243
244    #[test]
245    fn test_allowed_hosts_respects_allow_all() {
246        assert_eq!(
247            AllowedHttpHosts::AllowAll,
248            parse_allowed_http_hosts(&["insecure:allow-all"]).unwrap()
249        );
250        assert_eq!(
251            AllowedHttpHosts::AllowAll,
252            parse_allowed_http_hosts(&["spin.fermyon.dev", "insecure:allow-all"]).unwrap()
253        );
254    }
255}