Skip to main content

ocpi_kit/types/
url.rs

1//! `Url` — the OCPI `URL` type, and the policy that keeps a hub from becoming an SSRF proxy.
2
3use core::fmt;
4use core::str::FromStr;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8use super::validate::{Validate, Validator, ViolationCode};
9
10/// The maximum length the spec gives for a `URL`.
11///
12/// Spec: 2.3.0 §types_url_type — *"An URL a string(255) type"*
13pub const URL_MAX_LEN: usize = 255;
14
15/// An OCPI `URL`.
16///
17/// The text is stored **exactly as received**. A URL is an identifier as much as a location: a
18/// peer that registered `https://example.com/ocpi/cpo/2.3.0` must see that string again, not the
19/// `https://example.com/ocpi/cpo/2.3.0` that a normalising parser would hand back with a
20/// re-encoded path or an added trailing slash. [`Url::parse`] gives the parsed form when it is
21/// needed for making a request.
22///
23/// ```
24/// use ocpi_kit::types::Url;
25///
26/// let url = Url::new("https://example.com/ocpi/cpo/2.3.0/locations").unwrap();
27/// assert_eq!(url.as_str(), "https://example.com/ocpi/cpo/2.3.0/locations");
28/// assert_eq!(url.parse().unwrap().host_str(), Some("example.com"));
29/// ```
30///
31/// Spec: 2.3.0 §types_url_type
32#[derive(Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub struct Url(String);
34
35impl Url {
36    /// Creates a `Url`, checking that it parses as an absolute URL and fits `string(255)`.
37    ///
38    /// # Errors
39    ///
40    /// Returns [`InvalidUrl`] if the text is not an absolute URL or is longer than 255
41    /// characters.
42    pub fn new(value: impl Into<String>) -> Result<Self, InvalidUrl> {
43        let value = value.into();
44        let parsed = url::Url::parse(&value).map_err(|e| InvalidUrl(format!("{value:?}: {e}")))?;
45        if parsed.cannot_be_a_base() {
46            return Err(InvalidUrl(format!("{value:?}: not an absolute http(s) URL")));
47        }
48        let len = value.chars().count();
49        if len > URL_MAX_LEN {
50            return Err(InvalidUrl(format!("URL is {len} characters, the limit is {URL_MAX_LEN}")));
51        }
52        Ok(Self(value))
53    }
54
55    /// Creates a `Url` without checking anything. Used by `Deserialize`.
56    pub fn new_lenient(value: impl Into<String>) -> Self {
57        Self(value.into())
58    }
59
60    /// The URL exactly as received or constructed.
61    #[must_use]
62    pub fn as_str(&self) -> &str {
63        &self.0
64    }
65
66    /// Consumes this value and yields the inner text.
67    #[must_use]
68    pub fn into_string(self) -> String {
69        self.0
70    }
71
72    /// Parses the URL.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`InvalidUrl`] if the stored text is not a URL. This can only happen for values
77    /// that came off the wire, since [`Url::new`] checks up front.
78    pub fn parse(&self) -> Result<url::Url, InvalidUrl> {
79        url::Url::parse(&self.0).map_err(|e| InvalidUrl(format!("{:?}: {e}", self.0)))
80    }
81
82    /// This URL with a path segment appended, keeping exactly one `/` between the two.
83    ///
84    /// Endpoint URLs discovered from a peer sometimes carry a trailing slash and sometimes do
85    /// not; this joins correctly either way and never emits a double slash or a trailing one.
86    ///
87    /// ```
88    /// use ocpi_kit::types::Url;
89    /// let base = Url::new("https://example.com/ocpi/cpo/2.3.0/locations/").unwrap();
90    /// assert_eq!(base.join("NL").join("TNM").as_str(),
91    ///            "https://example.com/ocpi/cpo/2.3.0/locations/NL/TNM");
92    /// ```
93    #[must_use]
94    pub fn join(&self, segment: &str) -> Self {
95        let base = self.0.trim_end_matches('/');
96        let segment = segment.trim_start_matches('/').trim_end_matches('/');
97        if segment.is_empty() {
98            return Self(base.to_owned());
99        }
100        Self(format!("{base}/{segment}"))
101    }
102
103    /// This URL with a query string appended, using `?` or `&` as appropriate.
104    #[must_use]
105    pub fn with_query(&self, query: &str) -> Self {
106        if query.is_empty() {
107            return self.clone();
108        }
109        let sep = if self.0.contains('?') { '&' } else { '?' };
110        Self(format!("{}{sep}{query}", self.0))
111    }
112
113    /// Whether this URL is acceptable under `policy`.
114    ///
115    /// # Errors
116    ///
117    /// Returns the reason the URL was refused.
118    pub fn check(&self, policy: &UrlPolicy) -> Result<(), UrlRefused> {
119        policy.check(self)
120    }
121}
122
123impl Validate for Url {
124    fn validate_in(&self, v: &mut Validator) {
125        match url::Url::parse(&self.0) {
126            Ok(u) if u.cannot_be_a_base() => {
127                v.report(ViolationCode::IllegalCharacter, format!("{:?} is not an absolute URL", self.0));
128            }
129            Ok(_) => {}
130            Err(e) => v.report(ViolationCode::IllegalCharacter, format!("{:?} is not a URL: {e}", self.0)),
131        }
132        let len = self.0.chars().count();
133        if len > URL_MAX_LEN {
134            v.report(ViolationCode::TooLong, format!("URL({URL_MAX_LEN}) holds {len} characters"));
135        }
136    }
137}
138
139impl fmt::Display for Url {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        f.write_str(&self.0)
142    }
143}
144impl fmt::Debug for Url {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        fmt::Debug::fmt(&self.0, f)
147    }
148}
149impl AsRef<str> for Url {
150    fn as_ref(&self) -> &str {
151        &self.0
152    }
153}
154impl FromStr for Url {
155    type Err = InvalidUrl;
156    fn from_str(s: &str) -> Result<Self, Self::Err> {
157        Self::new(s)
158    }
159}
160// The infallible conversions are **lenient**, matching `Deserialize`; use `Url::new` or
161// `str::parse` for the checked path.
162impl From<&str> for Url {
163    fn from(s: &str) -> Self {
164        Self::new_lenient(s)
165    }
166}
167
168impl From<String> for Url {
169    fn from(s: String) -> Self {
170        Self::new_lenient(s)
171    }
172}
173impl From<url::Url> for Url {
174    fn from(value: url::Url) -> Self {
175        Self(value.to_string())
176    }
177}
178
179impl Serialize for Url {
180    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
181        serializer.serialize_str(&self.0)
182    }
183}
184impl<'de> Deserialize<'de> for Url {
185    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
186        String::deserialize(deserializer).map(Self)
187    }
188}
189
190#[cfg(feature = "schema")]
191impl schemars::JsonSchema for Url {
192    fn schema_name() -> std::borrow::Cow<'static, str> {
193        "URL".into()
194    }
195    fn json_schema(_g: &mut schemars::SchemaGenerator) -> schemars::Schema {
196        schemars::json_schema!({ "type": "string", "format": "uri", "maxLength": URL_MAX_LEN })
197    }
198}
199
200/// Why a URL was refused by a [`UrlPolicy`].
201#[derive(Clone, Debug, PartialEq, Eq)]
202pub struct UrlRefused(String);
203
204impl fmt::Display for UrlRefused {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        write!(f, "URL refused: {}", self.0)
207    }
208}
209impl std::error::Error for UrlRefused {}
210
211/// Why a string is not a usable OCPI `URL`.
212#[derive(Clone, Debug, PartialEq, Eq)]
213pub struct InvalidUrl(String);
214
215impl fmt::Display for InvalidUrl {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        write!(f, "invalid URL: {}", self.0)
218    }
219}
220impl std::error::Error for InvalidUrl {}
221
222/// What a party is willing to send a request to.
223///
224/// OCPI hands a server URLs that it is then expected to call: `Credentials.url`,
225/// `Endpoint.url`, and every `response_url` in the Commands and Charging Profiles modules. A
226/// party that fetches those without checking is a server-side request forgery proxy for anyone
227/// it has registered with. The specification says nothing about this, so this crate ships a
228/// default that says no to the things a CPO never legitimately needs to call.
229///
230/// # What this does not do
231///
232/// A `UrlPolicy` inspects the URL, and only the URL. It cannot see where a **host name**
233/// resolves, so `https://ptp.example.com/cb` passes even when that name has an `A` record for
234/// `169.254.169.254`, and a name that resolves differently between the check and the connection
235/// defeats it outright (a DNS rebind). Closing that needs a resolver in the connection path,
236/// which belongs to whatever HTTP client is doing the fetching rather than to a URL type.
237///
238/// So treat this as the first of two layers, not as the whole defence. In production, pair it
239/// with [`with_allowed_hosts`](Self::with_allowed_hosts) — an explicit list per peer is not
240/// subject to either problem — and with an egress policy on the network that refuses the link-
241/// local and private ranges outright. The literal-IP rules below are what stops the careless
242/// cases; the allow-list is what stops the deliberate ones.
243///
244/// ```
245/// use ocpi_kit::types::{Url, UrlPolicy};
246///
247/// let policy = UrlPolicy::default();
248/// assert!(policy.check(&Url::new("https://msp.example.com/cb/1").unwrap()).is_ok());
249/// assert!(policy.check(&Url::new("http://msp.example.com/cb/1").unwrap()).is_err()); // not TLS
250/// assert!(policy.check(&Url::new("https://127.0.0.1/cb").unwrap()).is_err());        // loopback
251/// assert!(policy.check(&Url::new("file:///etc/passwd").unwrap()).is_err());          // scheme
252/// ```
253#[derive(Clone, Debug)]
254pub struct UrlPolicy {
255    /// Schemes that may be used. Defaults to `https` only.
256    pub allowed_schemes: Vec<String>,
257    /// Whether loopback, link-local, private and unspecified addresses may be targeted.
258    ///
259    /// Defaults to `false`. Set to `true` for local development and integration tests.
260    pub allow_private_networks: bool,
261    /// When non-empty, only these hosts (matched case-insensitively, plus their subdomains) may
262    /// be targeted.
263    pub allowed_hosts: Vec<String>,
264}
265
266impl Default for UrlPolicy {
267    fn default() -> Self {
268        Self {
269            allowed_schemes: vec!["https".to_owned()],
270            allow_private_networks: false,
271            allowed_hosts: Vec::new(),
272        }
273    }
274}
275
276impl UrlPolicy {
277    /// A policy that permits anything, for tests and for talking to a peer over plain HTTP on a
278    /// trusted network.
279    #[must_use]
280    pub fn permissive() -> Self {
281        Self {
282            allowed_schemes: vec!["https".to_owned(), "http".to_owned()],
283            allow_private_networks: true,
284            allowed_hosts: Vec::new(),
285        }
286    }
287
288    /// Restricts this policy to `hosts` and their subdomains.
289    #[must_use]
290    pub fn with_allowed_hosts<I, S>(mut self, hosts: I) -> Self
291    where
292        I: IntoIterator<Item = S>,
293        S: Into<String>,
294    {
295        self.allowed_hosts = hosts.into_iter().map(Into::into).collect();
296        self
297    }
298
299    /// Allows plain `http` in addition to whatever is already allowed.
300    #[must_use]
301    pub fn allowing_http(mut self) -> Self {
302        if !self.allowed_schemes.iter().any(|s| s == "http") {
303            self.allowed_schemes.push("http".to_owned());
304        }
305        self
306    }
307
308    /// Allows targets on private and loopback networks.
309    #[must_use]
310    pub fn allowing_private_networks(mut self) -> Self {
311        self.allow_private_networks = true;
312        self
313    }
314
315    /// Checks `url` against this policy.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`UrlRefused`] naming the rule that rejected the URL.
320    pub fn check(&self, url: &Url) -> Result<(), UrlRefused> {
321        let parsed = url.parse().map_err(|e| UrlRefused(e.to_string()))?;
322        let scheme = parsed.scheme();
323        if !self.allowed_schemes.iter().any(|s| s == scheme) {
324            return Err(UrlRefused(format!(
325                "scheme {scheme:?} is not allowed (allowed: {})",
326                self.allowed_schemes.join(", ")
327            )));
328        }
329        let Some(host) = parsed.host() else {
330            return Err(UrlRefused("URL has no host".to_owned()));
331        };
332        if !self.allow_private_networks && is_private_host(&host) {
333            return Err(UrlRefused(format!("{host} is on a private or loopback network")));
334        }
335        if !self.allowed_hosts.is_empty() {
336            let host_text = host.to_string();
337            let ok = self.allowed_hosts.iter().any(|allowed| {
338                host_text.eq_ignore_ascii_case(allowed)
339                    || host_text.len() > allowed.len()
340                        && host_text.as_bytes()[host_text.len() - allowed.len() - 1] == b'.'
341                        && host_text[host_text.len() - allowed.len()..].eq_ignore_ascii_case(allowed)
342            });
343            if !ok {
344                return Err(UrlRefused(format!("host {host_text:?} is not in the allow-list")));
345            }
346        }
347        Ok(())
348    }
349}
350
351fn is_private_host(host: &url::Host<&str>) -> bool {
352    use std::net::IpAddr;
353    match host {
354        url::Host::Ipv4(ip) => is_private_ip(&IpAddr::V4(*ip)),
355        url::Host::Ipv6(ip) => is_private_ip(&IpAddr::V6(*ip)),
356        url::Host::Domain(name) => {
357            // Already lower-cased, so these are case-insensitive comparisons, not extension
358            // checks; `.local` here is an mDNS suffix rather than a file extension.
359            let lower = name.to_ascii_lowercase();
360            lower == "localhost" || lower.ends_with(".localhost") || lower.strip_suffix(".local").is_some()
361        }
362    }
363}
364
365fn is_private_ip(ip: &std::net::IpAddr) -> bool {
366    use std::net::IpAddr;
367    match ip {
368        IpAddr::V4(v4) => {
369            v4.is_private()
370                || v4.is_loopback()
371                || v4.is_link_local()
372                || v4.is_unspecified()
373                || v4.is_broadcast()
374                || v4.is_documentation()
375                // 100.64.0.0/10, carrier-grade NAT.
376                || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
377        }
378        IpAddr::V6(v6) => {
379            v6.is_loopback()
380                || v6.is_unspecified()
381                // Unique local addresses fc00::/7 and link-local fe80::/10.
382                || (v6.segments()[0] & 0xfe00) == 0xfc00
383                || (v6.segments()[0] & 0xffc0) == 0xfe80
384                || v6.to_ipv4_mapped().is_some_and(|v4| is_private_ip(&IpAddr::V4(v4)))
385                // The deprecated IPv4-compatible form `::a.b.c.d`, which `to_ipv4_mapped` does
386                // not cover, and the NAT64 well-known prefix 64:ff9b::/96 — both are ways of
387                // spelling an IPv4 address that a naive check would wave through.
388                || v6.segments()[..6] == [0, 0, 0, 0, 0, 0]
389                    && v6.segments()[6] != 0
390                    && is_private_ip(&IpAddr::V4(embedded_v4(v6)))
391                || v6.segments()[..4] == [0x0064, 0xff9b, 0, 0]
392                    && is_private_ip(&IpAddr::V4(embedded_v4(v6)))
393        }
394    }
395}
396
397/// The IPv4 address carried in the low 32 bits of an IPv6 address.
398fn embedded_v4(v6: &std::net::Ipv6Addr) -> std::net::Ipv4Addr {
399    let o = v6.octets();
400    std::net::Ipv4Addr::new(o[12], o[13], o[14], o[15])
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn text_is_preserved_exactly() {
409        // `url::Url` would normalise this to `https://example.com/`.
410        let u = Url::new("https://example.com").unwrap();
411        assert_eq!(u.as_str(), "https://example.com");
412        assert_eq!(serde_json::to_string(&u).unwrap(), "\"https://example.com\"");
413    }
414
415    #[test]
416    fn join_handles_trailing_slashes_either_way() {
417        for base in ["https://e.com/l", "https://e.com/l/"] {
418            let u = Url::new(base).unwrap();
419            assert_eq!(u.join("NL").join("TNM").join("14").as_str(), "https://e.com/l/NL/TNM/14");
420        }
421    }
422
423    #[test]
424    fn with_query_picks_the_right_separator() {
425        let u = Url::new("https://e.com/cdrs").unwrap();
426        assert_eq!(u.with_query("limit=10").as_str(), "https://e.com/cdrs?limit=10");
427        assert_eq!(
428            u.with_query("limit=10").with_query("offset=5").as_str(),
429            "https://e.com/cdrs?limit=10&offset=5"
430        );
431    }
432
433    #[test]
434    fn default_policy_blocks_the_ssrf_shapes() {
435        let p = UrlPolicy::default();
436        assert!(p.check(&Url::new("https://msp.example.com/cb").unwrap()).is_ok());
437        for bad in [
438            "http://msp.example.com/cb",
439            "https://127.0.0.1/cb",
440            "https://localhost/cb",
441            "https://10.0.0.5/cb",
442            "https://192.168.1.1/cb",
443            "https://169.254.169.254/latest/meta-data",
444            "https://[::1]/cb",
445            "https://[fd00::1]/cb",
446            "https://[fe80::1]/cb",
447            // The same metadata endpoint spelled three other ways.
448            "https://[::ffff:169.254.169.254]/latest/meta-data",
449            "https://[::169.254.169.254]/latest/meta-data",
450            "https://[64:ff9b::169.254.169.254]/latest/meta-data",
451        ] {
452            assert!(p.check(&Url::new(bad).unwrap()).is_err(), "{bad} should be refused");
453        }
454        // A public address in any of those forms is still reachable.
455        assert!(p.check(&Url::new("https://[64:ff9b::93.184.216.34]/cb").unwrap()).is_ok());
456    }
457
458    #[test]
459    fn a_host_name_is_not_resolved_so_the_allow_list_is_the_real_defence() {
460        // Documented limitation, asserted so it cannot regress into a false sense of safety:
461        // the policy sees the URL, not where the name points.
462        let p = UrlPolicy::default();
463        assert!(p.check(&Url::new("https://metadata.example.com/latest").unwrap()).is_ok());
464        let strict = p.with_allowed_hosts(["ptp.example.com"]);
465        assert!(strict.check(&Url::new("https://metadata.example.com/latest").unwrap()).is_err());
466    }
467
468    #[test]
469    fn host_allow_list_matches_subdomains_only_at_a_dot_boundary() {
470        let p = UrlPolicy::default().with_allowed_hosts(["example.com"]);
471        assert!(p.check(&Url::new("https://example.com/a").unwrap()).is_ok());
472        assert!(p.check(&Url::new("https://ocpi.example.com/a").unwrap()).is_ok());
473        assert!(p.check(&Url::new("https://notexample.com/a").unwrap()).is_err());
474        assert!(p.check(&Url::new("https://example.com.evil.net/a").unwrap()).is_err());
475    }
476
477    #[test]
478    fn over_long_urls_are_reported_not_dropped() {
479        let long = format!("https://e.com/{}", "x".repeat(300));
480        assert!(Url::new(&long).is_err());
481        let lenient = Url::new_lenient(&long);
482        assert_eq!(lenient.validate().unwrap_err().as_slice()[0].code, ViolationCode::TooLong);
483    }
484}