use std::fmt;
use std::net::{Ipv4Addr, Ipv6Addr};
pub const ENDPOINT_VARS: [&str; 4] = [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Scheme {
Http,
Https,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Origin {
pub scheme: Scheme,
pub host: String,
pub port: u16,
}
impl fmt::Display for Origin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let scheme = match self.scheme {
Scheme::Http => "http",
Scheme::Https => "https",
};
if self.host.contains(':') {
write!(f, "{scheme}://[{}]:{}", self.host, self.port)
} else {
write!(f, "{scheme}://{}:{}", self.host, self.port)
}
}
}
impl Origin {
pub fn is_loopback_literal(&self) -> bool {
if let Ok(v4) = self.host.parse::<Ipv4Addr>() {
return v4.is_loopback();
}
self.host
.parse::<Ipv6Addr>()
.is_ok_and(|v6| v6 == Ipv6Addr::LOCALHOST)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EndpointError {
EndpointNotAllowed { origin: String },
EmptyAllowlist,
Malformed { what: &'static str },
UserInfo,
Wildcard,
PlaintextNotLoopback { origin: String },
}
impl fmt::Display for EndpointError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EndpointError::EndpointNotAllowed { origin } => {
write!(f, "EndpointNotAllowed: {origin} is not in the allowlist")
}
EndpointError::EmptyAllowlist => f.write_str("the endpoint allowlist is empty"),
EndpointError::Malformed { what } => write!(f, "malformed endpoint: {what}"),
EndpointError::UserInfo => f.write_str("an endpoint may not carry user information"),
EndpointError::Wildcard => f.write_str("an endpoint may not carry a wildcard"),
EndpointError::PlaintextNotLoopback { origin } => write!(
f,
"{origin}: plaintext is allowed only to a loopback IP literal"
),
}
}
}
impl std::error::Error for EndpointError {}
fn malformed<T>(what: &'static str) -> Result<T, EndpointError> {
Err(EndpointError::Malformed { what })
}
pub fn parse(url: &str) -> Result<(Origin, String), EndpointError> {
let url = url.trim();
if url.contains('*') {
return Err(EndpointError::Wildcard);
}
let Some((scheme, rest)) = url.split_once("://") else {
return malformed("no scheme");
};
let scheme = match scheme.to_ascii_lowercase().as_str() {
"http" => Scheme::Http,
"https" => Scheme::Https,
_ => return malformed("the scheme is not http or https"),
};
let (authority, path) = match rest.find(['/', '?', '#']) {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
if authority.contains('@') {
return Err(EndpointError::UserInfo);
}
if authority.is_empty() {
return malformed("no host");
}
let (host, port) = if let Some(after) = authority.strip_prefix('[') {
let Some((h, tail)) = after.split_once(']') else {
return malformed("an unclosed IPv6 literal");
};
if h.parse::<Ipv6Addr>().is_err() {
return malformed("not an IPv6 literal");
}
let port = match tail {
"" => None,
p => match p.strip_prefix(':') {
Some(p) => Some(p),
None => return malformed("text after an IPv6 literal"),
},
};
(h.to_ascii_lowercase(), port)
} else {
let (h, p) = match authority.rsplit_once(':') {
Some((h, p)) => (h, Some(p)),
None => (authority, None),
};
if h.contains(':') {
return malformed("an IPv6 literal outside brackets");
}
(h.to_ascii_lowercase(), p)
};
let host = host.strip_suffix('.').unwrap_or(&host).to_string();
if host.is_empty()
|| !host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':'))
{
return malformed("not a host name or IP literal");
}
let port = match port {
None => match scheme {
Scheme::Http => 80,
Scheme::Https => 443,
},
Some(p) => match p.parse::<u16>() {
Ok(p) if p != 0 => p,
_ => return malformed("not a port"),
},
};
Ok((Origin { scheme, host, port }, path.to_string()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Allowlist(Vec<Origin>);
impl Allowlist {
pub fn new(entries: &[String]) -> Result<Self, EndpointError> {
if entries.is_empty() {
return Err(EndpointError::EmptyAllowlist);
}
let mut out = vec![];
for e in entries {
let (o, path) = parse(e)?;
if !(path.is_empty() || path == "/") {
return malformed("an allowlist entry is an origin, without a path");
}
out.push(o);
}
Ok(Allowlist(out))
}
pub fn check(&self, url: &str) -> Result<Origin, EndpointError> {
let (o, _) = parse(url)?;
if !self.0.contains(&o) {
return Err(EndpointError::EndpointNotAllowed {
origin: o.to_string(),
});
}
if o.scheme == Scheme::Http && !o.is_loopback_literal() {
return Err(EndpointError::PlaintextNotLoopback {
origin: o.to_string(),
});
}
Ok(o)
}
pub fn check_all(
&self,
endpoint: &str,
env: impl Fn(&str) -> Option<String>,
) -> Result<Origin, EndpointError> {
let o = self.check(endpoint)?;
for var in ENDPOINT_VARS {
if let Some(v) = env(var).filter(|v| !v.trim().is_empty()) {
self.check(&v)?;
}
}
Ok(o)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn list(e: &[&str]) -> Allowlist {
Allowlist::new(&e.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap()
}
#[test]
fn origins_normalize() {
let (o, path) = parse("HTTPS://Collector.Example.:443/v1/traces").unwrap();
assert_eq!(o.to_string(), "https://collector.example:443");
assert_eq!(path, "/v1/traces");
assert_eq!(parse("http://127.0.0.1").unwrap().0.port, 80);
assert_eq!(
parse("http://[::1]:4317").unwrap().0.to_string(),
"http://[::1]:4317"
);
}
#[test]
fn the_allowlist_admits_only_exact_origins() {
let l = list(&["http://127.0.0.1:4318", "https://collector.example"]);
assert!(l.check("http://127.0.0.1:4318/v1/traces").is_ok());
assert!(l.check("https://COLLECTOR.example:443").is_ok());
assert_eq!(
l.check("http://127.0.0.1:4317"),
Err(EndpointError::EndpointNotAllowed {
origin: "http://127.0.0.1:4317".into()
})
);
assert!(l.check("https://collector.example:8443").is_err());
assert!(l.check("http://collector.example:443").is_err());
}
#[test]
fn refusals() {
assert_eq!(Allowlist::new(&[]), Err(EndpointError::EmptyAllowlist));
assert_eq!(
Allowlist::new(&["https://*.example".into()]),
Err(EndpointError::Wildcard)
);
assert!(Allowlist::new(&["https://collector.example/v1".into()]).is_err());
let l = list(&[
"http://collector.example:4318",
"http://localhost:4318",
"http://127.0.0.1:4318",
]);
assert!(matches!(
l.check("http://collector.example:4318"),
Err(EndpointError::PlaintextNotLoopback { .. })
));
assert!(matches!(
l.check("http://localhost:4318"),
Err(EndpointError::PlaintextNotLoopback { .. })
));
assert_eq!(
l.check("http://user:secret@127.0.0.1:4318"),
Err(EndpointError::UserInfo)
);
for bad in [
"127.0.0.1:4318",
"ftp://127.0.0.1",
"http://",
"http://h:x",
"http://h:0",
"http://::1:4318",
"http://::1",
"http://fe80::1:4318",
] {
assert!(l.check(bad).is_err(), "{bad}");
}
assert_eq!(
l.check("http://::1:4318"),
Err(EndpointError::Malformed {
what: "an IPv6 literal outside brackets"
})
);
}
#[test]
fn an_environment_destination_outside_the_list_is_refused() {
let l = list(&["http://127.0.0.1:4318"]);
let env = |v: &str| {
(v == "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT").then(|| "http://127.0.0.1:9".to_string())
};
assert_eq!(
l.check_all("http://127.0.0.1:4318", env),
Err(EndpointError::EndpointNotAllowed {
origin: "http://127.0.0.1:9".into()
})
);
assert!(l.check_all("http://127.0.0.1:4318", |_| None).is_ok());
}
}