use std::net::IpAddr;
use std::net::SocketAddr;
use url::Host;
use url::Origin;
use url::Url;
use super::private_network::is_private_hostname;
use super::private_network::is_private_ip;
pub const DEFAULT_MAX_BODY_BYTES: u64 = 100 * 1024 * 1024;
pub const DEFAULT_MAX_REDIRECTS: u8 = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Scheme {
Https,
Http,
}
impl Scheme {
fn matches(self, scheme: &str) -> bool {
match self {
Self::Https => scheme.eq_ignore_ascii_case("https"),
Self::Http => scheme.eq_ignore_ascii_case("http"),
}
}
}
#[derive(Debug, Clone)]
pub struct UrlPolicy {
pub allowed_schemes: Vec<Scheme>,
pub allow_private_networks: bool,
pub trusted_origins: Vec<Origin>,
pub credentialed_origins: Vec<Origin>,
pub max_redirects: u8,
pub max_body_bytes: u64,
}
impl Default for UrlPolicy {
fn default() -> Self {
Self {
allowed_schemes: vec![Scheme::Https],
allow_private_networks: false,
trusted_origins: Vec::new(),
credentialed_origins: Vec::new(),
max_redirects: DEFAULT_MAX_REDIRECTS,
max_body_bytes: DEFAULT_MAX_BODY_BYTES,
}
}
}
impl UrlPolicy {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn allow_http(mut self) -> Self {
if !self.allowed_schemes.contains(&Scheme::Http) {
self.allowed_schemes.push(Scheme::Http);
}
self
}
#[must_use]
pub fn allow_private_networks(mut self) -> Self {
self.allow_private_networks = true;
self
}
#[must_use]
pub fn trust_origin(mut self, origin: &Url) -> Self {
self.trusted_origins.push(origin.origin());
self
}
#[must_use]
pub fn credential_origin(mut self, origin: &Url) -> Self {
self.credentialed_origins.push(origin.origin());
self
}
#[must_use]
pub fn max_redirects(mut self, max_redirects: u8) -> Self {
self.max_redirects = max_redirects;
self
}
#[must_use]
pub fn max_body_bytes(mut self, max_body_bytes: u64) -> Self {
self.max_body_bytes = max_body_bytes;
self
}
#[must_use]
pub fn is_trusted(&self, url: &Url) -> bool {
let origin = url.origin();
self.trusted_origins.contains(&origin)
}
#[must_use]
pub fn is_credentialed(&self, url: &Url) -> bool {
let origin = url.origin();
self.credentialed_origins.contains(&origin)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum UrlValidationError {
#[error("url scheme \"{scheme}\" is not allowed")]
SchemeNotAllowed {
scheme: String,
},
#[error("url must not contain credentials")]
EmbeddedCredentials,
#[error("url has no host")]
MissingHost,
#[error("host \"{host}\" is not allowed (private, loopback or reserved address)")]
PrivateHost {
host: String,
},
#[error("could not resolve host \"{host}\": {message}")]
Resolution {
host: String,
message: String,
},
#[error("host \"{host}\" resolves to blocked address {address}")]
PrivateAddress {
host: String,
address: IpAddr,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidatedUrl {
pub url: Url,
pub addresses: Vec<SocketAddr>,
}
pub async fn validate_url(
url: &Url,
policy: &UrlPolicy,
) -> Result<ValidatedUrl, UrlValidationError> {
if !policy
.allowed_schemes
.iter()
.any(|scheme| scheme.matches(url.scheme()))
{
return Err(UrlValidationError::SchemeNotAllowed {
scheme: url.scheme().to_owned(),
});
}
if !url.username().is_empty() || url.password().is_some() {
return Err(UrlValidationError::EmbeddedCredentials);
}
let host = url.host().ok_or(UrlValidationError::MissingHost)?;
let host_string = url.host_str().unwrap_or_default().to_owned();
if policy.is_trusted(url) {
return Ok(ValidatedUrl {
url: url.clone(),
addresses: Vec::new(),
});
}
let check_private = !policy.allow_private_networks;
let port = url
.port_or_known_default()
.unwrap_or(if url.scheme() == "http" { 80 } else { 443 });
let addresses = match host {
Host::Ipv4(ip) => vec![SocketAddr::new(IpAddr::V4(ip), port)],
Host::Ipv6(ip) => vec![SocketAddr::new(IpAddr::V6(ip), port)],
Host::Domain(domain) => {
if check_private && is_private_hostname(domain) {
return Err(UrlValidationError::PrivateHost { host: host_string });
}
let resolved = tokio::net::lookup_host((domain, port))
.await
.map_err(|error| UrlValidationError::Resolution {
host: host_string.clone(),
message: error.to_string(),
})?;
let addresses: Vec<SocketAddr> = resolved.collect();
if addresses.is_empty() {
return Err(UrlValidationError::Resolution {
host: host_string,
message: "no addresses".to_owned(),
});
}
addresses
}
};
if check_private && let Some(blocked) = addresses.iter().find(|addr| is_private_ip(addr.ip())) {
return Err(UrlValidationError::PrivateAddress {
host: host_string,
address: blocked.ip(),
});
}
Ok(ValidatedUrl {
url: url.clone(),
addresses,
})
}