1use core::fmt;
4use core::str::FromStr;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8use super::validate::{Validate, Validator, ViolationCode};
9
10pub const URL_MAX_LEN: usize = 255;
14
15#[derive(Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub struct Url(String);
34
35impl Url {
36 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 pub fn new_lenient(value: impl Into<String>) -> Self {
57 Self(value.into())
58 }
59
60 #[must_use]
62 pub fn as_str(&self) -> &str {
63 &self.0
64 }
65
66 #[must_use]
68 pub fn into_string(self) -> String {
69 self.0
70 }
71
72 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 #[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 #[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 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}
160impl 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#[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#[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#[derive(Clone, Debug)]
254pub struct UrlPolicy {
255 pub allowed_schemes: Vec<String>,
257 pub allow_private_networks: bool,
261 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 #[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 #[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 #[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 #[must_use]
310 pub fn allowing_private_networks(mut self) -> Self {
311 self.allow_private_networks = true;
312 self
313 }
314
315 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 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 || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
377 }
378 IpAddr::V6(v6) => {
379 v6.is_loopback()
380 || v6.is_unspecified()
381 || (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 || 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
397fn 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 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 "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 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 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}