Skip to content

Commit 26cfead

Browse files
authored
Merge pull request #727 from itowlson/allowed-http-hosts-fixes
Rework allowed HTTP host validation and checking
2 parents e8b6667 + b35c39c commit 26cfead

File tree

12 files changed

+341
-62
lines changed

12 files changed

+341
-62
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/loader/src/bindle/mod.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use crate::{
1515
config::{RawAppManifest, RawComponentManifest},
1616
utils::{find_manifest, parcels_in_group},
1717
},
18-
validation::validate_allowed_http_hosts,
18+
validation::{parse_allowed_http_hosts, validate_allowed_http_hosts},
1919
};
2020
use anyhow::{anyhow, Context, Result};
2121
use bindle::Invoice;
@@ -68,9 +68,10 @@ async fn prepare(
6868
toml::from_slice(&reader.get_parcel(&find_manifest(&invoice)?).await?)?;
6969
log::trace!("Recreated manifest from bindle: {:?}", raw);
7070

71+
validate_raw_app_manifest(&raw)?;
72+
7173
let mut config_root = raw.config.take().unwrap_or_default();
7274
for component in &mut raw.components {
73-
validate_allowed_http_hosts(&component.wasm.allowed_http_hosts)?;
7475
if let Some(config) = component.config.take() {
7576
let path = component.id.clone().try_into().with_context(|| {
7677
format!("component ID {:?} not a valid config path", component.id)
@@ -106,6 +107,12 @@ async fn prepare(
106107
})
107108
}
108109

110+
fn validate_raw_app_manifest(raw: &RawAppManifest) -> Result<()> {
111+
raw.components
112+
.iter()
113+
.try_for_each(|c| validate_allowed_http_hosts(&c.wasm.allowed_http_hosts))
114+
}
115+
109116
/// Given a raw component manifest, prepare its assets and return a fully formed core component.
110117
async fn core(
111118
raw: RawComponentManifest,
@@ -140,7 +147,7 @@ async fn core(
140147
None => vec![],
141148
};
142149
let environment = raw.wasm.environment.unwrap_or_default();
143-
let allowed_http_hosts = raw.wasm.allowed_http_hosts.unwrap_or_default();
150+
let allowed_http_hosts = parse_allowed_http_hosts(&raw.wasm.allowed_http_hosts)?;
144151
let wasm = WasmConfig {
145152
environment,
146153
mounts,

crates/loader/src/local/mod.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ use spin_manifest::{
2121
use std::{path::Path, str::FromStr, sync::Arc};
2222
use tokio::{fs::File, io::AsyncReadExt};
2323

24-
use crate::{bindle::BindleConnectionInfo, validation::validate_allowed_http_hosts};
24+
use crate::{
25+
bindle::BindleConnectionInfo,
26+
validation::{parse_allowed_http_hosts, validate_allowed_http_hosts},
27+
};
2528

2629
/// Given the path to a spin.toml manifest file, prepare its assets locally and
2730
/// get a prepared application configuration consumable by a Spin execution context.
@@ -225,7 +228,7 @@ async fn core(
225228
None => vec![],
226229
};
227230
let environment = raw.wasm.environment.unwrap_or_default();
228-
let allowed_http_hosts = raw.wasm.allowed_http_hosts.unwrap_or_default();
231+
let allowed_http_hosts = parse_allowed_http_hosts(&raw.wasm.allowed_http_hosts)?;
229232
let wasm = WasmConfig {
230233
environment,
231234
mounts,

crates/loader/src/local/tests.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -185,15 +185,16 @@ async fn test_invalid_url_in_allowed_http_hosts_is_rejected() -> Result<()> {
185185
let dir = temp_dir.path();
186186
let app = from_file(MANIFEST, dir, &None, false).await;
187187

188-
assert!(
189-
app.is_err(),
190-
"Expected url can be parsed by reqwest Url, but there was invalid url"
191-
);
188+
assert!(app.is_err(), "Expected allowed_http_hosts parsing error");
192189

193190
let e = app.unwrap_err().to_string();
194191
assert!(
195-
e.contains("some-random-api.ml"),
196-
"Expected error to contain invalid url `some-random-api.ml`"
192+
e.contains("ftp://some-random-api.ml"),
193+
"Expected allowed_http_hosts parse error to contain `ftp://some-random-api.ml`"
194+
);
195+
assert!(
196+
e.contains("example.com/wib/wob"),
197+
"Expected allowed_http_hosts parse error to contain `example.com/wib/wob`"
197198
);
198199

199200
Ok(())

crates/loader/src/validation.rs

Lines changed: 239 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,246 @@
11
#![deny(missing_docs)]
22

3-
use anyhow::{Context, Result};
3+
use anyhow::{anyhow, Result};
44
use reqwest::Url;
5+
use spin_manifest::{AllowedHttpHost, AllowedHttpHosts};
56

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

crates/loader/tests/invalid-url-in-allowed-http-hosts.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ trigger = { type = "http", base = "/" }
66
[[component]]
77
id = "hello"
88
source = "path/to/wasm/file.wasm"
9-
allowed_http_hosts = [ "some-random-api.ml" ]
9+
allowed_http_hosts = [ "ftp://some-random-api.ml", "example.com/wib/wob" ]
1010
[component.trigger]
1111
route = "/hello"

crates/manifest/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ indexmap = "1"
1010
serde = { version = "1.0", features = [ "derive" ] }
1111
spin-config = { path = "../config" }
1212
thiserror = "1"
13+
url = "2.2"

0 commit comments

Comments
 (0)