#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EgressDenied {
Host { destination: String },
Method { method: String },
}
impl std::fmt::Display for EgressDenied {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EgressDenied::Host { destination } => write!(
f,
"tried to reach '{destination}', which is outside its declared \
allowed_outbound_hosts"
),
EgressDenied::Method { method } => write!(
f,
"tried to issue {method}, which it is not permitted — a caller that \
declares no methods may only read"
),
}
}
}
type Result<T> = std::result::Result<T, String>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AllowedHost {
scheme: String,
host: String,
port: u16,
}
impl AllowedHost {
pub fn parse(spec: &str) -> Result<AllowedHost> {
let bad = |why: &str| format!("allowed_outbound_hosts entry {spec:?}: {why}");
let (scheme, rest) = spec.split_once("://").ok_or_else(|| {
bad("must be a URL with a scheme, e.g. https://api.example.com")
})?;
if scheme != "http" && scheme != "https" {
return Err(bad("scheme must be http or https"));
}
let rest = rest.trim_end_matches('/');
if rest.contains('/') {
return Err(bad("must not include a path — this allows hosts, not paths"));
}
let (host, port) = match rest.rsplit_once(':') {
Some((h, p)) => (
h.to_string(),
p.parse::<u16>().map_err(|_| bad("port is not a number"))?,
),
None => (rest.to_string(), if scheme == "https" { 443 } else { 80 }),
};
if host.is_empty() {
return Err(bad("host is empty"));
}
if host == "*" {
return Err(bad(
"a bare '*' allows the whole internet — name the hosts, or omit \
the allowlist entirely to say so explicitly",
));
}
Ok(AllowedHost { scheme: scheme.to_string(), host: host.to_lowercase(), port })
}
fn matches(&self, scheme: &str, host: &str, port: u16) -> bool {
if self.scheme != scheme || self.port != port {
return false;
}
let host = host.to_lowercase();
match self.host.strip_prefix("*.") {
Some(suffix) => host.ends_with(&format!(".{suffix}")),
None => host == self.host,
}
}
}
#[derive(Debug, Clone)]
pub struct EgressPolicy {
allowed: Vec<AllowedHost>,
unrestricted: bool,
}
#[allow(clippy::derivable_impls)]
impl Default for EgressPolicy {
fn default() -> Self {
EgressPolicy { allowed: Vec::new(), unrestricted: false }
}
}
impl EgressPolicy {
pub fn unrestricted() -> Self {
EgressPolicy { allowed: Vec::new(), unrestricted: true }
}
pub fn from_config(config: Option<&serde_json::Value>) -> Result<EgressPolicy> {
let key = areev_core::types::config_keys::ALLOWED_OUTBOUND_HOSTS;
let Some(list) = config.and_then(|c| c.get(key)) else {
return Ok(EgressPolicy::unrestricted());
};
let entries = list
.as_array()
.ok_or_else(|| format!("{key} must be an array of URL prefixes"))?;
let mut allowed = Vec::new();
for e in entries {
let s = e
.as_str()
.ok_or_else(|| format!("{key} entries must be strings"))?;
allowed.push(AllowedHost::parse(s)?);
}
Ok(EgressPolicy { allowed, unrestricted: false })
}
pub fn is_unrestricted(&self) -> bool {
self.unrestricted
}
pub fn permits(&self, url: &str) -> std::result::Result<(), EgressDenied> {
if self.unrestricted {
return Ok(());
}
let (scheme, host, port) =
split_url(url).map_err(|_| EgressDenied::Host { destination: url.to_string() })?;
if self.allowed.iter().any(|a| a.matches(&scheme, &host, port)) {
Ok(())
} else {
Err(EgressDenied::Host { destination: format!("{scheme}://{host}:{port}") })
}
}
}
fn split_url(url: &str) -> Result<(String, String, u16)> {
let bad = |why: &str| format!("url {url:?}: {why}");
let (scheme, rest) = url.split_once("://").ok_or_else(|| bad("not an absolute URL"))?;
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
let authority = authority.rsplit_once('@').map(|(_, h)| h).unwrap_or(authority);
if authority.is_empty() {
return Err(bad("no host"));
}
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) => (h, p.parse::<u16>().map_err(|_| bad("port is not a number"))?),
None => (authority, if scheme == "https" { 443 } else { 80 }),
};
Ok((scheme.to_lowercase(), host.to_lowercase(), port))
}
#[cfg(test)]
mod tests {
use super::*;
fn policy(entries: &[&str]) -> EgressPolicy {
EgressPolicy::from_config(Some(&serde_json::json!({
"int:allowed_outbound_hosts": entries
})))
.unwrap()
}
#[test]
fn an_exact_host_is_permitted_and_others_are_not() {
let p = policy(&["https://api.github.com"]);
assert!(p.permits("https://api.github.com/repos/x").is_ok());
assert!(p.permits("https://evil.com/").is_err());
}
#[test]
fn the_scheme_and_port_are_part_of_the_match() {
let p = policy(&["https://api.example.com"]);
assert!(p.permits("http://api.example.com/").is_err(), "downgrade must not pass");
assert!(p.permits("https://api.example.com:8443/").is_err(), "another port is another host");
}
#[test]
fn a_wildcard_covers_subdomains_but_not_the_apex_or_a_lookalike() {
let p = policy(&["https://*.example.com"]);
assert!(p.permits("https://api.example.com/").is_ok());
assert!(p.permits("https://a.b.example.com/").is_ok());
assert!(p.permits("https://example.com/").is_err(), "the apex is not a subdomain");
assert!(p.permits("https://evil-example.com/").is_err(), "lookalike must not pass");
assert!(p.permits("https://exampleXcom/").is_err());
}
#[test]
fn userinfo_cannot_disguise_the_real_host() {
let p = policy(&["https://api.github.com"]);
assert!(p.permits("https://api.github.com@evil.com/").is_err());
}
#[test]
fn case_is_not_significant_in_a_hostname() {
let p = policy(&["https://API.GitHub.com"]);
assert!(p.permits("https://api.github.com/").is_ok());
}
#[test]
fn the_derived_default_would_have_been_permissive_so_it_is_written_out() {
assert!(!EgressPolicy::default().is_unrestricted());
assert!(EgressPolicy::default().permits("https://anywhere.example/").is_err());
assert!(EgressPolicy::unrestricted().is_unrestricted());
}
#[test]
fn an_absent_allowlist_is_unrestricted_and_says_so() {
let p = EgressPolicy::from_config(None).unwrap();
assert!(p.is_unrestricted());
assert!(p.permits("https://anywhere.example/").is_ok());
}
#[test]
fn an_empty_allowlist_denies_everything() {
let p = policy(&[]);
assert!(!p.is_unrestricted());
assert!(p.permits("https://api.github.com/").is_err());
}
#[test]
fn a_bare_star_is_refused_rather_than_accepted_as_a_policy() {
let e = EgressPolicy::from_config(Some(&serde_json::json!({
"int:allowed_outbound_hosts": ["https://*"]
})))
.unwrap_err();
assert!(e.contains("whole internet"), "{e}");
}
#[test]
fn a_path_is_refused_because_this_allows_hosts_not_paths() {
let e = AllowedHost::parse("https://api.github.com/repos").unwrap_err();
assert!(e.to_string().contains("path"));
}
#[test]
fn a_missing_scheme_is_refused() {
assert!(AllowedHost::parse("api.github.com").is_err());
assert!(AllowedHost::parse("ftp://files.example.com").is_err());
}
#[test]
fn refusal_names_the_destination() {
let p = policy(&["https://api.github.com"]);
let e = p.permits("https://evil.com/x").unwrap_err();
assert!(matches!(e, EgressDenied::Host { .. }));
assert!(e.to_string().contains("evil.com"), "{e}");
}
#[test]
fn a_url_that_does_not_parse_is_refused_rather_than_allowed() {
let p = policy(&["https://api.github.com"]);
assert!(p.permits("not-a-url").is_err());
assert!(p.permits("").is_err());
}
}