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!("{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
106fn partition_results<T, E>(results: Vec<Result<T, E>>) -> (Vec<T>, Vec<E>) {
107    // We are going to to be OPTIMISTIC do you hear me
108    let mut oks = Vec::with_capacity(results.len());
109    let mut errs = vec![];
110
111    for result in results {
112        match result {
113            Ok(t) => oks.push(t),
114            Err(e) => errs.push(e),
115        }
116    }
117
118    (oks, errs)
119}
120
121#[cfg(test)]
122mod test {
123    use super::*;
124
125    impl AllowedHttpHost {
126        /// An allow-list entry that specifies a host and allows the default port.
127        fn host(name: impl Into<String>) -> Self {
128            Self {
129                domain: name.into(),
130                port: None,
131            }
132        }
133
134        /// An allow-list entry that specifies a host and port.
135        fn host_and_port(name: impl Into<String>, port: u16) -> Self {
136            Self {
137                domain: name.into(),
138                port: Some(port),
139            }
140        }
141    }
142
143    #[test]
144    fn test_allowed_hosts_accepts_http_url() {
145        assert_eq!(
146            AllowedHttpHost::host("spin.fermyon.dev"),
147            parse_allowed_http_host("http://spin.fermyon.dev").unwrap()
148        );
149        assert_eq!(
150            AllowedHttpHost::host("spin.fermyon.dev"),
151            parse_allowed_http_host("http://spin.fermyon.dev/").unwrap()
152        );
153        assert_eq!(
154            AllowedHttpHost::host("spin.fermyon.dev"),
155            parse_allowed_http_host("https://spin.fermyon.dev").unwrap()
156        );
157    }
158
159    #[test]
160    fn test_allowed_hosts_accepts_http_url_with_port() {
161        assert_eq!(
162            AllowedHttpHost::host_and_port("spin.fermyon.dev", 4444),
163            parse_allowed_http_host("http://spin.fermyon.dev:4444").unwrap()
164        );
165        assert_eq!(
166            AllowedHttpHost::host_and_port("spin.fermyon.dev", 4444),
167            parse_allowed_http_host("http://spin.fermyon.dev:4444/").unwrap()
168        );
169        assert_eq!(
170            AllowedHttpHost::host_and_port("spin.fermyon.dev", 5555),
171            parse_allowed_http_host("https://spin.fermyon.dev:5555").unwrap()
172        );
173    }
174
175    #[test]
176    fn test_allowed_hosts_accepts_plain_host() {
177        assert_eq!(
178            AllowedHttpHost::host("spin.fermyon.dev"),
179            parse_allowed_http_host("spin.fermyon.dev").unwrap()
180        );
181    }
182
183    #[test]
184    fn test_allowed_hosts_accepts_plain_host_with_port() {
185        assert_eq!(
186            AllowedHttpHost::host_and_port("spin.fermyon.dev", 7777),
187            parse_allowed_http_host("spin.fermyon.dev:7777").unwrap()
188        );
189    }
190
191    #[test]
192    fn test_allowed_hosts_accepts_self() {
193        assert_eq!(
194            AllowedHttpHost::host("self"),
195            parse_allowed_http_host("self").unwrap()
196        );
197    }
198
199    #[test]
200    fn test_allowed_hosts_accepts_localhost_addresses() {
201        assert_eq!(
202            AllowedHttpHost::host("localhost"),
203            parse_allowed_http_host("localhost").unwrap()
204        );
205        assert_eq!(
206            AllowedHttpHost::host("localhost"),
207            parse_allowed_http_host("http://localhost").unwrap()
208        );
209        assert_eq!(
210            AllowedHttpHost::host_and_port("localhost", 3001),
211            parse_allowed_http_host("localhost:3001").unwrap()
212        );
213        assert_eq!(
214            AllowedHttpHost::host_and_port("localhost", 3001),
215            parse_allowed_http_host("http://localhost:3001").unwrap()
216        );
217    }
218
219    #[test]
220    fn test_allowed_hosts_accepts_ip_addresses() {
221        assert_eq!(
222            AllowedHttpHost::host("192.168.1.1"),
223            parse_allowed_http_host("192.168.1.1").unwrap()
224        );
225        assert_eq!(
226            AllowedHttpHost::host("192.168.1.1"),
227            parse_allowed_http_host("http://192.168.1.1").unwrap()
228        );
229        assert_eq!(
230            AllowedHttpHost::host_and_port("192.168.1.1", 3002),
231            parse_allowed_http_host("192.168.1.1:3002").unwrap()
232        );
233        assert_eq!(
234            AllowedHttpHost::host_and_port("192.168.1.1", 3002),
235            parse_allowed_http_host("http://192.168.1.1:3002").unwrap()
236        );
237        assert_eq!(
238            AllowedHttpHost::host("[::1]"),
239            parse_allowed_http_host("[::1]").unwrap()
240        );
241        assert_eq!(
242            AllowedHttpHost::host_and_port("[::1]", 8001),
243            parse_allowed_http_host("http://[::1]:8001").unwrap()
244        );
245    }
246
247    #[test]
248    fn test_allowed_hosts_rejects_path() {
249        assert!(parse_allowed_http_host("http://spin.fermyon.dev/a").is_err());
250        assert!(parse_allowed_http_host("http://spin.fermyon.dev:6666/a/b").is_err());
251    }
252
253    #[test]
254    fn test_allowed_hosts_rejects_ftp_url() {
255        assert!(parse_allowed_http_host("ftp://spin.fermyon.dev").is_err());
256        assert!(parse_allowed_http_host("ftp://spin.fermyon.dev:6666").is_err());
257    }
258
259    #[test]
260    fn test_allowed_hosts_respects_allow_all() {
261        assert_eq!(
262            AllowedHttpHosts::AllowAll,
263            parse_allowed_http_hosts(&["insecure:allow-all"]).unwrap()
264        );
265        assert_eq!(
266            AllowedHttpHosts::AllowAll,
267            parse_allowed_http_hosts(&["spin.fermyon.dev", "insecure:allow-all"]).unwrap()
268        );
269    }
270}