spin_manifest/compat/
allowed_http_hosts.rs

1use anyhow::{anyhow, Result};
2use url::Url;
3
4const ALLOW_ALL_HOSTS: &str = "insecure:allow-all";
5
6/// An HTTP host allow-list.
7#[derive(Clone, Debug, Eq, PartialEq)]
8pub enum AllowedHttpHosts {
9    /// All HTTP hosts are allowed (the "insecure:allow-all" value was present in the list)
10    AllowAll,
11    /// Only the specified hosts are allowed.
12    AllowSpecific(Vec<AllowedHttpHost>),
13}
14
15impl Default for AllowedHttpHosts {
16    fn default() -> Self {
17        Self::AllowSpecific(vec![])
18    }
19}
20
21/// An HTTP host allow-list entry.
22#[derive(Clone, Debug, Default, Eq, PartialEq)]
23pub struct AllowedHttpHost {
24    pub(crate) domain: String,
25    pub(crate) port: Option<u16>,
26}
27
28impl AllowedHttpHost {
29    /// Creates a new allow-list entry.
30    pub fn new(name: impl Into<String>, port: Option<u16>) -> Self {
31        Self {
32            domain: name.into(),
33            port,
34        }
35    }
36}
37
38/// Parses a list of allowed HTTP hosts
39pub fn parse_allowed_http_hosts(list: &[impl AsRef<str>]) -> Result<AllowedHttpHosts> {
40    if list.iter().any(|domain| domain.as_ref() == ALLOW_ALL_HOSTS) {
41        Ok(AllowedHttpHosts::AllowAll)
42    } else {
43        let parse_results = list
44            .iter()
45            .map(|h| parse_allowed_http_host(h.as_ref()))
46            .collect::<Vec<_>>();
47        let (hosts, errors) = partition_results(parse_results);
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!("{} isn't a valid host or host:port string", text))?;
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 =
83        Url::parse(text).map_err(|e| format!("{} isn't a valid HTTP host URL: {}", text, e))?;
84
85    if !matches!(url.scheme(), "http" | "https") {
86        return Err(format!("{} isn't a valid host or host:port string", text));
87    }
88
89    parse_allowed_http_host_from_http_url(&url, text)
90}
91
92fn parse_allowed_http_host_from_http_url(url: &Url, text: &str) -> Result<AllowedHttpHost, String> {
93    let host = url
94        .host_str()
95        .ok_or_else(|| format!("{} doesn't contain a host name", text))?;
96
97    let has_path = url.path().len() > 1; // allow "/"
98    if has_path {
99        return Err(format!(
100            "{} contains a path, should be host and optional port only",
101            text
102        ));
103    }
104
105    Ok(AllowedHttpHost::new(host, url.port()))
106}
107
108fn partition_results<T, E>(results: Vec<Result<T, E>>) -> (Vec<T>, Vec<E>) {
109    // We are going to to be OPTIMISTIC do you hear me
110    let mut oks = Vec::with_capacity(results.len());
111    let mut errs = vec![];
112
113    for result in results {
114        match result {
115            Ok(t) => oks.push(t),
116            Err(e) => errs.push(e),
117        }
118    }
119
120    (oks, errs)
121}
122
123#[cfg(test)]
124mod test {
125    use super::*;
126
127    impl AllowedHttpHost {
128        /// An allow-list entry that specifies a host and allows the default port.
129        fn host(name: impl Into<String>) -> Self {
130            Self {
131                domain: name.into(),
132                port: None,
133            }
134        }
135
136        /// An allow-list entry that specifies a host and port.
137        fn host_and_port(name: impl Into<String>, port: u16) -> Self {
138            Self {
139                domain: name.into(),
140                port: Some(port),
141            }
142        }
143    }
144
145    #[test]
146    fn test_allowed_hosts_accepts_http_url() {
147        assert_eq!(
148            AllowedHttpHost::host("spin.fermyon.dev"),
149            parse_allowed_http_host("http://spin.fermyon.dev").unwrap()
150        );
151        assert_eq!(
152            AllowedHttpHost::host("spin.fermyon.dev"),
153            parse_allowed_http_host("http://spin.fermyon.dev/").unwrap()
154        );
155        assert_eq!(
156            AllowedHttpHost::host("spin.fermyon.dev"),
157            parse_allowed_http_host("https://spin.fermyon.dev").unwrap()
158        );
159    }
160
161    #[test]
162    fn test_allowed_hosts_accepts_http_url_with_port() {
163        assert_eq!(
164            AllowedHttpHost::host_and_port("spin.fermyon.dev", 4444),
165            parse_allowed_http_host("http://spin.fermyon.dev:4444").unwrap()
166        );
167        assert_eq!(
168            AllowedHttpHost::host_and_port("spin.fermyon.dev", 4444),
169            parse_allowed_http_host("http://spin.fermyon.dev:4444/").unwrap()
170        );
171        assert_eq!(
172            AllowedHttpHost::host_and_port("spin.fermyon.dev", 5555),
173            parse_allowed_http_host("https://spin.fermyon.dev:5555").unwrap()
174        );
175    }
176
177    #[test]
178    fn test_allowed_hosts_accepts_plain_host() {
179        assert_eq!(
180            AllowedHttpHost::host("spin.fermyon.dev"),
181            parse_allowed_http_host("spin.fermyon.dev").unwrap()
182        );
183    }
184
185    #[test]
186    fn test_allowed_hosts_accepts_plain_host_with_port() {
187        assert_eq!(
188            AllowedHttpHost::host_and_port("spin.fermyon.dev", 7777),
189            parse_allowed_http_host("spin.fermyon.dev:7777").unwrap()
190        );
191    }
192
193    #[test]
194    fn test_allowed_hosts_accepts_self() {
195        assert_eq!(
196            AllowedHttpHost::host("self"),
197            parse_allowed_http_host("self").unwrap()
198        );
199    }
200
201    #[test]
202    fn test_allowed_hosts_accepts_localhost_addresses() {
203        assert_eq!(
204            AllowedHttpHost::host("localhost"),
205            parse_allowed_http_host("localhost").unwrap()
206        );
207        assert_eq!(
208            AllowedHttpHost::host("localhost"),
209            parse_allowed_http_host("http://localhost").unwrap()
210        );
211        assert_eq!(
212            AllowedHttpHost::host_and_port("localhost", 3001),
213            parse_allowed_http_host("localhost:3001").unwrap()
214        );
215        assert_eq!(
216            AllowedHttpHost::host_and_port("localhost", 3001),
217            parse_allowed_http_host("http://localhost:3001").unwrap()
218        );
219    }
220
221    #[test]
222    fn test_allowed_hosts_accepts_ip_addresses() {
223        assert_eq!(
224            AllowedHttpHost::host("192.168.1.1"),
225            parse_allowed_http_host("192.168.1.1").unwrap()
226        );
227        assert_eq!(
228            AllowedHttpHost::host("192.168.1.1"),
229            parse_allowed_http_host("http://192.168.1.1").unwrap()
230        );
231        assert_eq!(
232            AllowedHttpHost::host_and_port("192.168.1.1", 3002),
233            parse_allowed_http_host("192.168.1.1:3002").unwrap()
234        );
235        assert_eq!(
236            AllowedHttpHost::host_and_port("192.168.1.1", 3002),
237            parse_allowed_http_host("http://192.168.1.1:3002").unwrap()
238        );
239        assert_eq!(
240            AllowedHttpHost::host("[::1]"),
241            parse_allowed_http_host("[::1]").unwrap()
242        );
243        assert_eq!(
244            AllowedHttpHost::host_and_port("[::1]", 8001),
245            parse_allowed_http_host("http://[::1]:8001").unwrap()
246        );
247    }
248
249    #[test]
250    fn test_allowed_hosts_rejects_path() {
251        assert!(parse_allowed_http_host("http://spin.fermyon.dev/a").is_err());
252        assert!(parse_allowed_http_host("http://spin.fermyon.dev:6666/a/b").is_err());
253    }
254
255    #[test]
256    fn test_allowed_hosts_rejects_ftp_url() {
257        assert!(parse_allowed_http_host("ftp://spin.fermyon.dev").is_err());
258        assert!(parse_allowed_http_host("ftp://spin.fermyon.dev:6666").is_err());
259    }
260
261    #[test]
262    fn test_allowed_hosts_respects_allow_all() {
263        assert_eq!(
264            AllowedHttpHosts::AllowAll,
265            parse_allowed_http_hosts(&["insecure:allow-all"]).unwrap()
266        );
267        assert_eq!(
268            AllowedHttpHosts::AllowAll,
269            parse_allowed_http_hosts(&["spin.fermyon.dev", "insecure:allow-all"]).unwrap()
270        );
271    }
272}