use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use url::Url;
use rho_tools::tool::ToolError;
tokio::task_local! {
static ALLOW_RANGES_OVERRIDE: Vec<Cidr>;
}
pub(super) async fn ensure_public_url(
raw_url: &str,
allow_ranges: &[Cidr],
) -> Result<(), ToolError> {
let url =
Url::parse(raw_url).map_err(|error| ToolError::Message(format!("invalid url: {error}")))?;
if url.scheme() != "http" && url.scheme() != "https" {
return Err(ToolError::Message(
"only http and https URLs are supported".into(),
));
}
let host = url
.host_str()
.ok_or_else(|| ToolError::Message("URL must include a hostname".into()))?;
let hostname = normalize_hostname(host);
if hostname.is_empty() {
return Err(ToolError::Message("URL must include a hostname".into()));
}
if hostname == "localhost" || hostname.ends_with(".localhost") {
return Err(ToolError::Message(format!(
"blocked internal hostname: {hostname}"
)));
}
if let Ok(ip) = hostname.parse::<IpAddr>() {
return assert_public_address(ip, &hostname, allow_ranges);
}
let addrs = tokio::net::lookup_host((hostname.as_str(), 0))
.await
.map_err(|error| ToolError::Message(format!("failed to resolve {hostname}: {error}")))?
.map(|addr| addr.ip())
.collect::<Vec<_>>();
if addrs.is_empty() {
return Err(ToolError::Message(format!(
"failed to resolve {hostname}: no addresses returned"
)));
}
for ip in addrs {
assert_public_address(ip, &hostname, allow_ranges)?;
}
Ok(())
}
pub(super) fn configured_allow_ranges() -> Result<Vec<Cidr>, ToolError> {
if let Ok(ranges) = ALLOW_RANGES_OVERRIDE.try_with(Clone::clone) {
return Ok(ranges);
}
allow_ranges_from_env()
}
#[cfg(test)]
pub(super) async fn with_allow_ranges<F>(ranges: Vec<Cidr>, f: F) -> F::Output
where
F: std::future::Future,
{
ALLOW_RANGES_OVERRIDE.scope(ranges, f).await
}
fn assert_public_address(
ip: IpAddr,
hostname: &str,
allow_ranges: &[Cidr],
) -> Result<(), ToolError> {
let ip = normalize_ip(ip);
if allow_ranges.iter().any(|range| range.contains(ip)) {
return Ok(());
}
if is_blocked(ip) {
return Err(ToolError::Message(format!(
"blocked internal address for {hostname}: {ip}"
)));
}
Ok(())
}
fn normalize_hostname(hostname: &str) -> String {
hostname
.trim()
.trim_matches(|c| c == '[' || c == ']')
.trim_end_matches('.')
.to_ascii_lowercase()
}
fn normalize_ip(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip),
IpAddr::V4(_) => ip,
}
}
fn is_blocked(ip: IpAddr) -> bool {
match normalize_ip(ip) {
IpAddr::V4(v4) => is_blocked_v4(v4),
IpAddr::V6(v6) => is_blocked_v6(v6),
}
}
fn is_blocked_v4(ip: Ipv4Addr) -> bool {
let [a, b, ..] = ip.octets();
ip.is_unspecified()
|| ip.is_loopback()
|| ip.is_private()
|| ip.is_link_local()
|| ip.is_broadcast()
|| a == 0
|| (a == 100 && (64..=127).contains(&b)) || (a == 198 && (18..=19).contains(&b)) || a >= 224 }
fn embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
if let Some(v4) = ip.to_ipv4_mapped() {
return Some(v4);
}
let segments = ip.segments();
let from_segments = |high: u16, low: u16| {
Ipv4Addr::new(
(high >> 8) as u8,
(high & 0xff) as u8,
(low >> 8) as u8,
(low & 0xff) as u8,
)
};
if segments[0] == 0x2002 {
return Some(from_segments(segments[1], segments[2]));
}
if segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2..6] == [0, 0, 0, 0] {
return Some(from_segments(segments[6], segments[7]));
}
if segments[..6] == [0, 0, 0, 0, 0, 0] && !ip.is_unspecified() && !ip.is_loopback() {
return Some(from_segments(segments[6], segments[7]));
}
None
}
fn is_blocked_v6(ip: Ipv6Addr) -> bool {
if let Some(v4) = embedded_ipv4(ip) {
return is_blocked_v4(v4);
}
let segments = ip.segments();
ip.is_unspecified()
|| ip.is_loopback()
|| (segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80 || (segments[0] & 0xff00) == 0xff00 || (segments[0] == 0x2001 && segments[1] == 0x0db8) || (segments[0] == 0x2001 && segments[1] == 0x0002 && segments[2] == 0) || (segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 1) || (segments[0] == 0x0100
&& segments[1] == 0
&& segments[2] == 0
&& segments[3] == 0) }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct Cidr {
network: IpAddr,
prefix: u8,
}
impl Cidr {
pub(super) fn parse(raw: &str) -> Result<Self, String> {
let raw = raw.trim();
if raw.is_empty() {
return Err("empty CIDR".into());
}
let (addr_part, prefix_part) = match raw.split_once('/') {
Some((addr, prefix)) => (addr, Some(prefix)),
None => (raw, None),
};
if let Some(prefix) = prefix_part {
if !prefix.chars().all(|c| c.is_ascii_digit()) {
return Err(format!("invalid CIDR prefix in {raw:?}"));
}
}
let network = normalize_ip(
addr_part
.parse()
.map_err(|_| format!("invalid CIDR address in {raw:?}"))?,
);
let max_prefix = match network {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
};
let prefix = match prefix_part {
Some(value) => value
.parse::<u8>()
.map_err(|_| format!("invalid CIDR prefix in {raw:?}"))?,
None => max_prefix,
};
if prefix == 0 || prefix > max_prefix {
return Err(format!("CIDR prefix out of range in {raw:?}"));
}
Ok(Self { network, prefix })
}
fn contains(self, ip: IpAddr) -> bool {
let ip = normalize_ip(ip);
match (self.network, ip) {
(IpAddr::V4(network), IpAddr::V4(candidate)) => {
prefix_match(&network.octets(), &candidate.octets(), self.prefix)
}
(IpAddr::V6(network), IpAddr::V6(candidate)) => {
prefix_match(&network.octets(), &candidate.octets(), self.prefix)
}
_ => false,
}
}
}
fn prefix_match(network: &[u8], candidate: &[u8], prefix: u8) -> bool {
let full_bytes = (prefix / 8) as usize;
let rem_bits = prefix % 8;
if network[..full_bytes] != candidate[..full_bytes] {
return false;
}
if rem_bits == 0 {
return true;
}
let mask = 0xffu8 << (8 - rem_bits);
(network[full_bytes] & mask) == (candidate[full_bytes] & mask)
}
pub(super) fn allow_ranges_from_env() -> Result<Vec<Cidr>, ToolError> {
let Ok(raw) = std::env::var("RHO_SSRF_ALLOW_RANGES") else {
return Ok(Vec::new());
};
if raw.trim().is_empty() {
return Ok(Vec::new());
}
raw.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(|part| {
Cidr::parse(part).map_err(|error| {
ToolError::Message(format!(
"invalid RHO_SSRF_ALLOW_RANGES entry {part:?}: {error}"
))
})
})
.collect()
}
#[cfg(test)]
#[path = "ssrf_tests.rs"]
mod tests;