1use std::fmt;
14use std::net::{Ipv4Addr, Ipv6Addr};
15
16pub const ENDPOINT_VARS: [&str; 4] = [
18 "OTEL_EXPORTER_OTLP_ENDPOINT",
19 "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
20 "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
21 "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
22];
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
26pub enum Scheme {
27 Http,
28 Https,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
33pub struct Origin {
34 pub scheme: Scheme,
35 pub host: String,
37 pub port: u16,
38}
39
40impl fmt::Display for Origin {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 let scheme = match self.scheme {
43 Scheme::Http => "http",
44 Scheme::Https => "https",
45 };
46 if self.host.contains(':') {
47 write!(f, "{scheme}://[{}]:{}", self.host, self.port)
48 } else {
49 write!(f, "{scheme}://{}:{}", self.host, self.port)
50 }
51 }
52}
53
54impl Origin {
55 pub fn is_loopback_literal(&self) -> bool {
57 if let Ok(v4) = self.host.parse::<Ipv4Addr>() {
58 return v4.is_loopback();
59 }
60 self.host
61 .parse::<Ipv6Addr>()
62 .is_ok_and(|v6| v6 == Ipv6Addr::LOCALHOST)
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum EndpointError {
70 EndpointNotAllowed { origin: String },
72 EmptyAllowlist,
74 Malformed { what: &'static str },
76 UserInfo,
78 Wildcard,
80 PlaintextNotLoopback { origin: String },
82}
83
84impl fmt::Display for EndpointError {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 match self {
87 EndpointError::EndpointNotAllowed { origin } => {
88 write!(f, "EndpointNotAllowed: {origin} is not in the allowlist")
89 }
90 EndpointError::EmptyAllowlist => f.write_str("the endpoint allowlist is empty"),
91 EndpointError::Malformed { what } => write!(f, "malformed endpoint: {what}"),
92 EndpointError::UserInfo => f.write_str("an endpoint may not carry user information"),
93 EndpointError::Wildcard => f.write_str("an endpoint may not carry a wildcard"),
94 EndpointError::PlaintextNotLoopback { origin } => write!(
95 f,
96 "{origin}: plaintext is allowed only to a loopback IP literal"
97 ),
98 }
99 }
100}
101
102impl std::error::Error for EndpointError {}
103
104fn malformed<T>(what: &'static str) -> Result<T, EndpointError> {
105 Err(EndpointError::Malformed { what })
106}
107
108pub fn parse(url: &str) -> Result<(Origin, String), EndpointError> {
110 let url = url.trim();
111 if url.contains('*') {
112 return Err(EndpointError::Wildcard);
113 }
114 let Some((scheme, rest)) = url.split_once("://") else {
115 return malformed("no scheme");
116 };
117 let scheme = match scheme.to_ascii_lowercase().as_str() {
118 "http" => Scheme::Http,
119 "https" => Scheme::Https,
120 _ => return malformed("the scheme is not http or https"),
121 };
122 let (authority, path) = match rest.find(['/', '?', '#']) {
123 Some(i) => (&rest[..i], &rest[i..]),
124 None => (rest, ""),
125 };
126 if authority.contains('@') {
127 return Err(EndpointError::UserInfo);
128 }
129 if authority.is_empty() {
130 return malformed("no host");
131 }
132 let (host, port) = if let Some(after) = authority.strip_prefix('[') {
133 let Some((h, tail)) = after.split_once(']') else {
134 return malformed("an unclosed IPv6 literal");
135 };
136 if h.parse::<Ipv6Addr>().is_err() {
137 return malformed("not an IPv6 literal");
138 }
139 let port = match tail {
140 "" => None,
141 p => match p.strip_prefix(':') {
142 Some(p) => Some(p),
143 None => return malformed("text after an IPv6 literal"),
144 },
145 };
146 (h.to_ascii_lowercase(), port)
147 } else {
148 let (h, p) = match authority.rsplit_once(':') {
149 Some((h, p)) => (h, Some(p)),
150 None => (authority, None),
151 };
152 if h.contains(':') {
155 return malformed("an IPv6 literal outside brackets");
156 }
157 (h.to_ascii_lowercase(), p)
158 };
159 let host = host.strip_suffix('.').unwrap_or(&host).to_string();
160 if host.is_empty()
161 || !host
162 .bytes()
163 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':'))
164 {
165 return malformed("not a host name or IP literal");
166 }
167 let port = match port {
168 None => match scheme {
169 Scheme::Http => 80,
170 Scheme::Https => 443,
171 },
172 Some(p) => match p.parse::<u16>() {
173 Ok(p) if p != 0 => p,
174 _ => return malformed("not a port"),
175 },
176 };
177 Ok((Origin { scheme, host, port }, path.to_string()))
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct Allowlist(Vec<Origin>);
183
184impl Allowlist {
185 pub fn new(entries: &[String]) -> Result<Self, EndpointError> {
188 if entries.is_empty() {
189 return Err(EndpointError::EmptyAllowlist);
190 }
191 let mut out = vec![];
192 for e in entries {
193 let (o, path) = parse(e)?;
194 if !(path.is_empty() || path == "/") {
195 return malformed("an allowlist entry is an origin, without a path");
196 }
197 out.push(o);
198 }
199 Ok(Allowlist(out))
200 }
201
202 pub fn check(&self, url: &str) -> Result<Origin, EndpointError> {
205 let (o, _) = parse(url)?;
206 if !self.0.contains(&o) {
207 return Err(EndpointError::EndpointNotAllowed {
208 origin: o.to_string(),
209 });
210 }
211 if o.scheme == Scheme::Http && !o.is_loopback_literal() {
212 return Err(EndpointError::PlaintextNotLoopback {
213 origin: o.to_string(),
214 });
215 }
216 Ok(o)
217 }
218
219 pub fn check_all(
223 &self,
224 endpoint: &str,
225 env: impl Fn(&str) -> Option<String>,
226 ) -> Result<Origin, EndpointError> {
227 let o = self.check(endpoint)?;
228 for var in ENDPOINT_VARS {
229 if let Some(v) = env(var).filter(|v| !v.trim().is_empty()) {
230 self.check(&v)?;
231 }
232 }
233 Ok(o)
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 fn list(e: &[&str]) -> Allowlist {
242 Allowlist::new(&e.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap()
243 }
244
245 #[test]
246 fn origins_normalize() {
247 let (o, path) = parse("HTTPS://Collector.Example.:443/v1/traces").unwrap();
248 assert_eq!(o.to_string(), "https://collector.example:443");
249 assert_eq!(path, "/v1/traces");
250 assert_eq!(parse("http://127.0.0.1").unwrap().0.port, 80);
251 assert_eq!(
252 parse("http://[::1]:4317").unwrap().0.to_string(),
253 "http://[::1]:4317"
254 );
255 }
256
257 #[test]
258 fn the_allowlist_admits_only_exact_origins() {
259 let l = list(&["http://127.0.0.1:4318", "https://collector.example"]);
260 assert!(l.check("http://127.0.0.1:4318/v1/traces").is_ok());
261 assert!(l.check("https://COLLECTOR.example:443").is_ok());
262 assert_eq!(
263 l.check("http://127.0.0.1:4317"),
264 Err(EndpointError::EndpointNotAllowed {
265 origin: "http://127.0.0.1:4317".into()
266 })
267 );
268 assert!(l.check("https://collector.example:8443").is_err());
269 assert!(l.check("http://collector.example:443").is_err());
270 }
271
272 #[test]
273 fn refusals() {
274 assert_eq!(Allowlist::new(&[]), Err(EndpointError::EmptyAllowlist));
275 assert_eq!(
276 Allowlist::new(&["https://*.example".into()]),
277 Err(EndpointError::Wildcard)
278 );
279 assert!(Allowlist::new(&["https://collector.example/v1".into()]).is_err());
280 let l = list(&[
281 "http://collector.example:4318",
282 "http://localhost:4318",
283 "http://127.0.0.1:4318",
284 ]);
285 assert!(matches!(
286 l.check("http://collector.example:4318"),
287 Err(EndpointError::PlaintextNotLoopback { .. })
288 ));
289 assert!(matches!(
290 l.check("http://localhost:4318"),
291 Err(EndpointError::PlaintextNotLoopback { .. })
292 ));
293 assert_eq!(
294 l.check("http://user:secret@127.0.0.1:4318"),
295 Err(EndpointError::UserInfo)
296 );
297 for bad in [
298 "127.0.0.1:4318",
299 "ftp://127.0.0.1",
300 "http://",
301 "http://h:x",
302 "http://h:0",
303 "http://::1:4318",
304 "http://::1",
305 "http://fe80::1:4318",
306 ] {
307 assert!(l.check(bad).is_err(), "{bad}");
308 }
309 assert_eq!(
310 l.check("http://::1:4318"),
311 Err(EndpointError::Malformed {
312 what: "an IPv6 literal outside brackets"
313 })
314 );
315 }
316
317 #[test]
318 fn an_environment_destination_outside_the_list_is_refused() {
319 let l = list(&["http://127.0.0.1:4318"]);
320 let env = |v: &str| {
321 (v == "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT").then(|| "http://127.0.0.1:9".to_string())
322 };
323 assert_eq!(
324 l.check_all("http://127.0.0.1:4318", env),
325 Err(EndpointError::EndpointNotAllowed {
326 origin: "http://127.0.0.1:9".into()
327 })
328 );
329 assert!(l.check_all("http://127.0.0.1:4318", |_| None).is_ok());
330 }
331}