use core::fmt;
use core::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::validate::{Validate, Validator, ViolationCode};
pub const URL_MAX_LEN: usize = 255;
#[derive(Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Url(String);
impl Url {
pub fn new(value: impl Into<String>) -> Result<Self, InvalidUrl> {
let value = value.into();
let parsed = url::Url::parse(&value).map_err(|e| InvalidUrl(format!("{value:?}: {e}")))?;
if parsed.cannot_be_a_base() {
return Err(InvalidUrl(format!("{value:?}: not an absolute http(s) URL")));
}
let len = value.chars().count();
if len > URL_MAX_LEN {
return Err(InvalidUrl(format!("URL is {len} characters, the limit is {URL_MAX_LEN}")));
}
Ok(Self(value))
}
pub fn new_lenient(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_string(self) -> String {
self.0
}
pub fn parse(&self) -> Result<url::Url, InvalidUrl> {
url::Url::parse(&self.0).map_err(|e| InvalidUrl(format!("{:?}: {e}", self.0)))
}
#[must_use]
pub fn join(&self, segment: &str) -> Self {
let base = self.0.trim_end_matches('/');
let segment = segment.trim_start_matches('/').trim_end_matches('/');
if segment.is_empty() {
return Self(base.to_owned());
}
Self(format!("{base}/{segment}"))
}
#[must_use]
pub fn with_query(&self, query: &str) -> Self {
if query.is_empty() {
return self.clone();
}
let sep = if self.0.contains('?') { '&' } else { '?' };
Self(format!("{}{sep}{query}", self.0))
}
pub fn check(&self, policy: &UrlPolicy) -> Result<(), UrlRefused> {
policy.check(self)
}
}
impl Validate for Url {
fn validate_in(&self, v: &mut Validator) {
match url::Url::parse(&self.0) {
Ok(u) if u.cannot_be_a_base() => {
v.report(ViolationCode::IllegalCharacter, format!("{:?} is not an absolute URL", self.0));
}
Ok(_) => {}
Err(e) => v.report(ViolationCode::IllegalCharacter, format!("{:?} is not a URL: {e}", self.0)),
}
let len = self.0.chars().count();
if len > URL_MAX_LEN {
v.report(ViolationCode::TooLong, format!("URL({URL_MAX_LEN}) holds {len} characters"));
}
}
}
impl fmt::Display for Url {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl fmt::Debug for Url {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
impl AsRef<str> for Url {
fn as_ref(&self) -> &str {
&self.0
}
}
impl FromStr for Url {
type Err = InvalidUrl;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl From<&str> for Url {
fn from(s: &str) -> Self {
Self::new_lenient(s)
}
}
impl From<String> for Url {
fn from(s: String) -> Self {
Self::new_lenient(s)
}
}
impl From<url::Url> for Url {
fn from(value: url::Url) -> Self {
Self(value.to_string())
}
}
impl Serialize for Url {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for Url {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer).map(Self)
}
}
#[cfg(feature = "schema")]
impl schemars::JsonSchema for Url {
fn schema_name() -> std::borrow::Cow<'static, str> {
"URL".into()
}
fn json_schema(_g: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({ "type": "string", "format": "uri", "maxLength": URL_MAX_LEN })
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UrlRefused(String);
impl fmt::Display for UrlRefused {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "URL refused: {}", self.0)
}
}
impl std::error::Error for UrlRefused {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InvalidUrl(String);
impl fmt::Display for InvalidUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid URL: {}", self.0)
}
}
impl std::error::Error for InvalidUrl {}
#[derive(Clone, Debug)]
pub struct UrlPolicy {
pub allowed_schemes: Vec<String>,
pub allow_private_networks: bool,
pub allowed_hosts: Vec<String>,
}
impl Default for UrlPolicy {
fn default() -> Self {
Self {
allowed_schemes: vec!["https".to_owned()],
allow_private_networks: false,
allowed_hosts: Vec::new(),
}
}
}
impl UrlPolicy {
#[must_use]
pub fn permissive() -> Self {
Self {
allowed_schemes: vec!["https".to_owned(), "http".to_owned()],
allow_private_networks: true,
allowed_hosts: Vec::new(),
}
}
#[must_use]
pub fn with_allowed_hosts<I, S>(mut self, hosts: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.allowed_hosts = hosts.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn allowing_http(mut self) -> Self {
if !self.allowed_schemes.iter().any(|s| s == "http") {
self.allowed_schemes.push("http".to_owned());
}
self
}
#[must_use]
pub fn allowing_private_networks(mut self) -> Self {
self.allow_private_networks = true;
self
}
pub fn check(&self, url: &Url) -> Result<(), UrlRefused> {
let parsed = url.parse().map_err(|e| UrlRefused(e.to_string()))?;
let scheme = parsed.scheme();
if !self.allowed_schemes.iter().any(|s| s == scheme) {
return Err(UrlRefused(format!(
"scheme {scheme:?} is not allowed (allowed: {})",
self.allowed_schemes.join(", ")
)));
}
let Some(host) = parsed.host() else {
return Err(UrlRefused("URL has no host".to_owned()));
};
if !self.allow_private_networks && is_private_host(&host) {
return Err(UrlRefused(format!("{host} is on a private or loopback network")));
}
if !self.allowed_hosts.is_empty() {
let host_text = host.to_string();
let ok = self.allowed_hosts.iter().any(|allowed| {
host_text.eq_ignore_ascii_case(allowed)
|| host_text.len() > allowed.len()
&& host_text.as_bytes()[host_text.len() - allowed.len() - 1] == b'.'
&& host_text[host_text.len() - allowed.len()..].eq_ignore_ascii_case(allowed)
});
if !ok {
return Err(UrlRefused(format!("host {host_text:?} is not in the allow-list")));
}
}
Ok(())
}
}
fn is_private_host(host: &url::Host<&str>) -> bool {
use std::net::IpAddr;
match host {
url::Host::Ipv4(ip) => is_private_ip(&IpAddr::V4(*ip)),
url::Host::Ipv6(ip) => is_private_ip(&IpAddr::V6(*ip)),
url::Host::Domain(name) => {
let lower = name.to_ascii_lowercase();
lower == "localhost" || lower.ends_with(".localhost") || lower.strip_suffix(".local").is_some()
}
}
}
fn is_private_ip(ip: &std::net::IpAddr) -> bool {
use std::net::IpAddr;
match ip {
IpAddr::V4(v4) => {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_documentation()
|| (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
}
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|| v6.to_ipv4_mapped().is_some_and(|v4| is_private_ip(&IpAddr::V4(v4)))
|| v6.segments()[..6] == [0, 0, 0, 0, 0, 0]
&& v6.segments()[6] != 0
&& is_private_ip(&IpAddr::V4(embedded_v4(v6)))
|| v6.segments()[..4] == [0x0064, 0xff9b, 0, 0]
&& is_private_ip(&IpAddr::V4(embedded_v4(v6)))
}
}
}
fn embedded_v4(v6: &std::net::Ipv6Addr) -> std::net::Ipv4Addr {
let o = v6.octets();
std::net::Ipv4Addr::new(o[12], o[13], o[14], o[15])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_is_preserved_exactly() {
let u = Url::new("https://example.com").unwrap();
assert_eq!(u.as_str(), "https://example.com");
assert_eq!(serde_json::to_string(&u).unwrap(), "\"https://example.com\"");
}
#[test]
fn join_handles_trailing_slashes_either_way() {
for base in ["https://e.com/l", "https://e.com/l/"] {
let u = Url::new(base).unwrap();
assert_eq!(u.join("NL").join("TNM").join("14").as_str(), "https://e.com/l/NL/TNM/14");
}
}
#[test]
fn with_query_picks_the_right_separator() {
let u = Url::new("https://e.com/cdrs").unwrap();
assert_eq!(u.with_query("limit=10").as_str(), "https://e.com/cdrs?limit=10");
assert_eq!(
u.with_query("limit=10").with_query("offset=5").as_str(),
"https://e.com/cdrs?limit=10&offset=5"
);
}
#[test]
fn default_policy_blocks_the_ssrf_shapes() {
let p = UrlPolicy::default();
assert!(p.check(&Url::new("https://msp.example.com/cb").unwrap()).is_ok());
for bad in [
"http://msp.example.com/cb",
"https://127.0.0.1/cb",
"https://localhost/cb",
"https://10.0.0.5/cb",
"https://192.168.1.1/cb",
"https://169.254.169.254/latest/meta-data",
"https://[::1]/cb",
"https://[fd00::1]/cb",
"https://[fe80::1]/cb",
"https://[::ffff:169.254.169.254]/latest/meta-data",
"https://[::169.254.169.254]/latest/meta-data",
"https://[64:ff9b::169.254.169.254]/latest/meta-data",
] {
assert!(p.check(&Url::new(bad).unwrap()).is_err(), "{bad} should be refused");
}
assert!(p.check(&Url::new("https://[64:ff9b::93.184.216.34]/cb").unwrap()).is_ok());
}
#[test]
fn a_host_name_is_not_resolved_so_the_allow_list_is_the_real_defence() {
let p = UrlPolicy::default();
assert!(p.check(&Url::new("https://metadata.example.com/latest").unwrap()).is_ok());
let strict = p.with_allowed_hosts(["ptp.example.com"]);
assert!(strict.check(&Url::new("https://metadata.example.com/latest").unwrap()).is_err());
}
#[test]
fn host_allow_list_matches_subdomains_only_at_a_dot_boundary() {
let p = UrlPolicy::default().with_allowed_hosts(["example.com"]);
assert!(p.check(&Url::new("https://example.com/a").unwrap()).is_ok());
assert!(p.check(&Url::new("https://ocpi.example.com/a").unwrap()).is_ok());
assert!(p.check(&Url::new("https://notexample.com/a").unwrap()).is_err());
assert!(p.check(&Url::new("https://example.com.evil.net/a").unwrap()).is_err());
}
#[test]
fn over_long_urls_are_reported_not_dropped() {
let long = format!("https://e.com/{}", "x".repeat(300));
assert!(Url::new(&long).is_err());
let lenient = Url::new_lenient(&long);
assert_eq!(lenient.validate().unwrap_err().as_slice()[0].code, ViolationCode::TooLong);
}
}