Skip to main content

rustev_otel/
endpoint.rs

1//! The collector endpoint allowlist (spec 028, 3.9.2): every OTLP
2//! destination is checked here, before any connection or DNS lookup, and
3//! refused unless its origin is exactly in the host's list.
4//!
5//! An origin is `scheme://host:port` with the scheme and host lowercased,
6//! the default port made explicit and a trailing dot removed. A URL with
7//! user information, a wildcard, an empty list, and plaintext to a host that
8//! is not a loopback IP literal (`127.0.0.0/8` or `::1`; the name
9//! `localhost` is refused, since its resolution is not checked) are
10//! refused. `OTEL_*` environment variables cannot choose a destination: one
11//! naming an origin outside the list is refused like a configured one.
12
13use std::fmt;
14use std::net::{Ipv4Addr, Ipv6Addr};
15
16/// The environment variables that could name an OTLP destination.
17pub 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/// A URL scheme the exporters speak.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
26pub enum Scheme {
27    Http,
28    Https,
29}
30
31/// A normalized origin.
32#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
33pub struct Origin {
34    pub scheme: Scheme,
35    /// Lowercase, no trailing dot; an IPv6 literal without brackets.
36    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    /// A loopback IP literal: `127.0.0.0/8` or `::1`.
56    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/// Why an endpoint or allowlist entry was refused. Never carries a header
67/// value or other secret; an origin is not one.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum EndpointError {
70    /// The origin is not in the allowlist.
71    EndpointNotAllowed { origin: String },
72    /// The allowlist is empty, which refuses every endpoint.
73    EmptyAllowlist,
74    /// Not a URL this check reads; `what` says why.
75    Malformed { what: &'static str },
76    /// User information in the authority.
77    UserInfo,
78    /// A wildcard in a host or entry.
79    Wildcard,
80    /// Plaintext to a host that is not a loopback IP literal.
81    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
108/// Parse a URL's origin; the path, if any, is returned beside it.
109pub 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        // A colon outside brackets can only be the port separator: an
153        // IPv6 literal must be bracketed (`http://[::1]:4318`).
154        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/// The host's allowlist, parsed.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct Allowlist(Vec<Origin>);
183
184impl Allowlist {
185    /// Every entry an origin (no path), none a wildcard or with user
186    /// information; the list not empty.
187    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    /// Check one destination: in the list, and plaintext only to a
203    /// loopback IP literal.
204    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    /// Check `endpoint` and every `OTEL_*` endpoint variable `env` returns:
220    /// an environment-supplied destination outside the list is refused
221    /// like a configured one (I-3).
222    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}