use areev_core::types::capability::split_url;
#[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>;
pub use areev_core::types::capability::AllowedHost;
#[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}") })
}
}
}
pub fn is_private_destination(url: &str) -> bool {
let Ok((_, host, _)) = split_url(url) else {
return true;
};
let host = host.strip_suffix('.').unwrap_or(&host);
if host == "localhost" || host.ends_with(".localhost") {
return true;
}
let bare = host.trim_start_matches('[').trim_end_matches(']');
if let Some(v4) = bare
.parse::<std::net::Ipv4Addr>()
.ok()
.or_else(|| parse_ipv4_inet_aton(bare))
{
return v4_is_private(v4);
}
if let Ok(v6) = bare.parse::<std::net::Ipv6Addr>() {
if let Some(v4) = v6.to_ipv4_mapped() {
return v4_is_private(v4);
}
let seg = v6.segments();
return v6.is_loopback()
|| v6.is_unspecified()
|| (seg[0] & 0xfe00) == 0xfc00
|| (seg[0] & 0xffc0) == 0xfe80;
}
false
}
fn v4_is_private(v4: std::net::Ipv4Addr) -> bool {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
|| matches!(v4.octets(), [100, b, _, _] if (64..=127).contains(&b))
}
fn parse_ipv4_inet_aton(host: &str) -> Option<std::net::Ipv4Addr> {
let parts: Vec<&str> = host.split('.').collect();
if parts.is_empty() || parts.len() > 4 {
return None;
}
let mut nums = Vec::with_capacity(parts.len());
for p in &parts {
nums.push(parse_c_uint(p)?);
}
let addr: u32 = match nums.as_slice() {
[a] => u32::try_from(*a).ok()?,
[a, b] => {
let a = u8::try_from(*a).ok()?;
if *b > 0x00ff_ffff {
return None;
}
(u32::from(a) << 24) | (*b as u32)
}
[a, b, c] => {
let a = u8::try_from(*a).ok()?;
let b = u8::try_from(*b).ok()?;
if *c > 0x0000_ffff {
return None;
}
(u32::from(a) << 24) | (u32::from(b) << 16) | (*c as u32)
}
[a, b, c, d] => u32::from_be_bytes([
u8::try_from(*a).ok()?,
u8::try_from(*b).ok()?,
u8::try_from(*c).ok()?,
u8::try_from(*d).ok()?,
]),
_ => return None,
};
Some(std::net::Ipv4Addr::from(addr))
}
fn parse_c_uint(part: &str) -> Option<u64> {
let (radix, digits) = if let Some(hex) = part.strip_prefix("0x").or_else(|| part.strip_prefix("0X")) {
(16, hex)
} else if part.len() > 1 && part.starts_with('0') {
(8, &part[1..])
} else {
(10, part)
};
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_alphanumeric()) {
return None;
}
u64::from_str_radix(digits, radix).ok()
}
pub fn same_origin(a: &str, b: &str) -> bool {
match (split_url(a), split_url(b)) {
(Ok(x), Ok(y)) => x == y,
_ => false,
}
}
pub fn resolve_location(base: &str, location: &str) -> Option<String> {
let location = location.trim();
if location.is_empty() {
return None;
}
if location.chars().any(|c| c.is_control()) {
return None;
}
if let Some(scheme) = leading_scheme(location) {
if scheme != "http" && scheme != "https" {
return None;
}
split_url(location).ok()?;
return Some(location.to_string());
}
let (base_scheme, base_host, base_port) = split_url(base).ok()?;
let default_port = if base_scheme == "https" { 443 } else { 80 };
let authority = if base_port == default_port {
base_host.clone()
} else {
format!("{base_host}:{base_port}")
};
let origin = format!("{base_scheme}://{authority}");
if let Some(rest) = location.strip_prefix("//") {
return resolve_location(base, &format!("{base_scheme}://{rest}"));
}
if location.starts_with('/') {
return Some(format!("{origin}{location}"));
}
let after_scheme = base.split_once("://").map(|(_, r)| r).unwrap_or("");
let path = after_scheme
.find('/')
.map(|i| &after_scheme[i..])
.unwrap_or("/");
let path = path.split(['?', '#']).next().unwrap_or("/");
let dir = match path.rfind('/') {
Some(i) => &path[..=i],
None => "/",
};
Some(format!("{origin}{dir}{location}"))
}
fn leading_scheme(reference: &str) -> Option<String> {
let end = reference.find(':')?;
let scheme = &reference[..end];
if reference[..end].contains(['/', '?', '#']) {
return None;
}
let mut chars = scheme.chars();
if !chars.next()?.is_ascii_alphabetic() {
return None;
}
if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
return None;
}
Some(scheme.to_ascii_lowercase())
}
#[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());
}
#[test]
fn private_destinations_are_recognized_in_every_literal_form() {
for private in [
"http://127.0.0.1:7461/api",
"http://127.8.9.10/", "http://localhost:8080/",
"http://console.localhost/", "http://169.254.169.254/latest/meta-data/", "http://10.0.0.5/",
"http://172.16.0.1/",
"http://192.168.1.1/admin",
"http://0.0.0.0:80/",
"http://[::1]:9000/",
"http://[fd00::1]/", "http://[fe80::1]:80/", "http://[::ffff:127.0.0.1]/", ] {
assert!(is_private_destination(private), "{private} must classify private");
}
for public in [
"https://api.github.com/",
"https://gmail.googleapis.com/gmail/v1/x",
"http://93.184.216.34/", "https://[2606:4700::6810:85e5]/",
] {
assert!(!is_private_destination(public), "{public} must classify public");
}
assert!(is_private_destination("not-a-url"));
}
#[test]
fn private_destinations_are_recognized_in_the_alternate_integer_encodings() {
for private in [
"http://2130706433/", "http://0x7f000001/", "http://017700000001/", "http://127.1/", "http://127.0.0x1/", "http://0/", "http://2852039166/latest/meta-data/", "http://0xA9FEA9FE/", "http://127.0.0.1./", "http://localhost./", ] {
assert!(is_private_destination(private), "{private} must classify private");
}
for public in [
"https://api.github.com/",
"http://134744072/", "http://999.999.999.999/", ] {
assert!(!is_private_destination(public), "{public} must classify public");
}
}
#[test]
fn same_origin_compares_scheme_host_and_port() {
assert!(same_origin("https://api.example.com/a", "https://api.example.com/b?q=1"));
assert!(same_origin("https://api.example.com/a", "https://API.Example.com:443/b"));
assert!(!same_origin("https://api.example.com/a", "http://api.example.com/a"));
assert!(!same_origin("https://api.example.com/a", "https://api.example.com:8443/a"));
assert!(!same_origin("https://api.example.com/a", "https://other.example.com/a"));
assert!(!same_origin("https://api.example.com/a", "https://api.example.com@evil.com/a"));
}
#[test]
fn same_origin_fails_closed_on_an_unparseable_url() {
assert!(!same_origin("https://api.example.com/a", "not-a-url"));
assert!(!same_origin("", ""));
}
#[test]
fn a_location_resolves_in_all_four_shapes() {
let base = "https://api.example.com/v1/messages?page=2";
assert_eq!(
resolve_location(base, "https://other.example.com/x").as_deref(),
Some("https://other.example.com/x")
);
assert_eq!(
resolve_location(base, "//other.example.com/x").as_deref(),
Some("https://other.example.com/x"),
"protocol-relative takes the base's scheme"
);
assert_eq!(
resolve_location(base, "/x").as_deref(),
Some("https://api.example.com/x"),
"an absolute path takes the base's origin"
);
assert_eq!(
resolve_location(base, "next").as_deref(),
Some("https://api.example.com/v1/next"),
"a relative path takes the base's DIRECTORY, and the query is dropped"
);
}
#[test]
fn a_non_default_port_survives_resolution() {
assert_eq!(
resolve_location("http://127.0.0.1:8080/a/b", "/c").as_deref(),
Some("http://127.0.0.1:8080/c"),
"resolving must not silently move the request to port 80"
);
}
#[test]
fn a_location_that_is_not_an_http_url_is_refused_rather_than_guessed_at() {
let base = "https://api.example.com/v1/x";
for bad in ["", " ", "file:///etc/passwd", "javascript:alert(1)", "ftp://f.example/x"] {
assert_eq!(resolve_location(base, bad), None, "{bad:?} must not resolve");
}
}
#[test]
fn a_location_carrying_a_control_character_is_refused() {
let base = "https://api.example.com/v1/x";
assert_eq!(resolve_location(base, "/a\r\nX-Injected: 1"), None);
assert_eq!(resolve_location(base, "https://ok.example/\u{0}"), None);
}
}