use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use super::Result;
const MAX_REDIRECTS: usize = 10;
fn host_as_ip(host: &str) -> Option<IpAddr> {
let unbracketed = host
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(host);
unbracketed.parse::<IpAddr>().ok()
}
pub(crate) fn is_blocked_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_blocked_v4(v4),
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
Some(v4) => is_blocked_v4(v4),
None => is_blocked_v6(v6),
},
}
}
fn is_blocked_v4(a: Ipv4Addr) -> bool {
let o = a.octets();
a.is_loopback() || a.is_private() || a.is_link_local() || a.is_broadcast() || a.is_documentation() || a.is_unspecified() || a.is_multicast() || o[0] == 0 || (o[0] == 100 && (o[1] & 0xc0) == 0x40) || (o[0] == 192 && o[1] == 0 && o[2] == 0) }
fn is_blocked_v6(a: Ipv6Addr) -> bool {
if let Some(v4) = embedded_v4(a) {
return is_blocked_v4(v4);
}
a.is_loopback() || a.is_unspecified() || a.is_multicast() || (a.segments()[0] & 0xfe00) == 0xfc00 || (a.segments()[0] & 0xffc0) == 0xfe80 }
fn embedded_v4(a: Ipv6Addr) -> Option<Ipv4Addr> {
let s = a.segments();
let tail = || {
Ipv4Addr::new(
(s[6] >> 8) as u8,
(s[6] & 0xff) as u8,
(s[7] >> 8) as u8,
(s[7] & 0xff) as u8,
)
};
if s[0..6] == [0, 0, 0, 0, 0, 0] {
return Some(tail());
}
if s[0] == 0x0064 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] {
return Some(tail());
}
None
}
pub fn precheck_url(url: &str, allow_private: bool) -> Result<()> {
let parsed =
reqwest::Url::parse(url).map_err(|e| format!("invalid --url {}: {e}", redact_url(url)))?;
match parsed.scheme() {
"http" | "https" => {}
other => return Err(format!("unsupported URL scheme {other:?} (use http/https)")),
}
let host = parsed
.host_str()
.ok_or_else(|| format!("--url has no host: {}", redact_url(url)))?;
if !allow_private {
if let Some(ip) = host_as_ip(host) {
if is_blocked_ip(ip) {
return Err(format!(
"SSRF guard: --url host {ip} is loopback/private/metadata; pass --allow-private-cidrs to override"
));
}
}
}
Ok(())
}
pub fn is_blocked_url_host(url: &str, allow_private: bool) -> bool {
if allow_private {
return false;
}
reqwest::Url::parse(url)
.ok()
.and_then(|u| u.host_str().and_then(host_as_ip).map(is_blocked_ip))
.unwrap_or(false)
}
pub fn redact_url(url: &str) -> String {
match reqwest::Url::parse(url) {
Ok(mut u) => {
let _ = u.set_username("");
let _ = u.set_password(None);
u.set_query(None);
u.to_string()
}
Err(_) => match (url.find("://"), url.find('@')) {
(Some(s), Some(at)) if at > s + 3 => {
format!("{}//{}", &url[..s + 1], &url[at + 1..])
}
_ => url.to_string(),
},
}
}
pub(crate) struct GuardedResolver {
allow_private: bool,
}
impl reqwest::dns::Resolve for GuardedResolver {
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
let allow_private = self.allow_private;
let host = name.as_str().to_string();
Box::pin(async move {
let resolved: Vec<SocketAddr> = tokio::net::lookup_host((host.as_str(), 0u16))
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
.collect();
if resolved.is_empty() {
return Err(format!("no addresses resolved for {host}").into());
}
let allowed: Vec<SocketAddr> = if allow_private {
resolved
} else {
resolved
.into_iter()
.filter(|sa| !is_blocked_ip(sa.ip()))
.collect()
};
if allowed.is_empty() {
return Err(format!(
"SSRF guard: {host} resolves only to loopback/private/metadata addresses"
)
.into());
}
let addrs: reqwest::dns::Addrs = Box::new(allowed.into_iter());
Ok(addrs)
})
}
}
pub fn build_import_client(
tls: &crate::config::TlsConfig,
connect_timeout: std::time::Duration,
read_timeout: std::time::Duration,
allow_private: bool,
) -> Result<reqwest::Client> {
let redirect_policy =
reqwest::redirect::Policy::custom(move |attempt| {
match redirect_verdict(
attempt.url().host_str(),
attempt.previous().len(),
allow_private,
) {
RedirectVerdict::Follow => attempt.follow(),
RedirectVerdict::TooMany => attempt.error(SsrfRedirectError(format!(
"exceeded {MAX_REDIRECTS} redirects"
))),
RedirectVerdict::Blocked(ip) => {
attempt.error(SsrfRedirectError(format!("redirect to blocked IP {ip}")))
}
}
});
let mut builder = reqwest::ClientBuilder::new() .user_agent(crate::USER_AGENT)
.connect_timeout(connect_timeout)
.read_timeout(read_timeout)
.no_proxy()
.redirect(redirect_policy)
.dns_resolver(Arc::new(GuardedResolver { allow_private }));
if let Some(ref ca_path) = tls.ca_cert {
let pem = std::fs::read(ca_path).map_err(|e| format!("read CA cert {ca_path}: {e}"))?;
let cert = reqwest::tls::Certificate::from_pem(&pem)
.map_err(|e| format!("parse CA cert {ca_path}: {e}"))?;
builder = builder.add_root_certificate(cert);
}
builder
.build()
.map_err(|e| format!("build import HTTP client: {e}"))
}
#[derive(Debug, PartialEq, Eq)]
enum RedirectVerdict {
Follow,
TooMany,
Blocked(IpAddr),
}
fn redirect_verdict(host: Option<&str>, previous: usize, allow_private: bool) -> RedirectVerdict {
if previous >= MAX_REDIRECTS {
return RedirectVerdict::TooMany;
}
if !allow_private {
if let Some(ip) = host.and_then(host_as_ip) {
if is_blocked_ip(ip) {
return RedirectVerdict::Blocked(ip);
}
}
}
RedirectVerdict::Follow
}
#[derive(Debug)]
struct SsrfRedirectError(String);
impl std::fmt::Display for SsrfRedirectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "SSRF guard: {}", self.0)
}
}
impl std::error::Error for SsrfRedirectError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocks_ipv4_loopback_private_metadata() {
for s in [
"127.0.0.1",
"127.1.2.3",
"10.0.0.1",
"172.16.5.4",
"192.168.1.1",
"169.254.169.254", "100.100.100.200", "0.0.0.0",
"255.255.255.255",
] {
let ip: IpAddr = s.parse().unwrap();
assert!(is_blocked_ip(ip), "{s} should be blocked");
}
}
#[test]
fn blocks_ipv6_loopback_ula_linklocal_and_mapped_metadata() {
for s in [
"::1",
"::",
"fc00::1",
"fd00:ec2::254", "fe80::1",
"::ffff:169.254.169.254", "::ffff:127.0.0.1",
"::ffff:10.0.0.1",
"::169.254.169.254", "::7f00:1", "64:ff9b::a9fe:a9fe", "64:ff9b::10.0.0.5", ] {
let ip: IpAddr = s.parse().unwrap();
assert!(is_blocked_ip(ip), "{s} should be blocked");
}
}
#[test]
fn allows_public_addresses() {
for s in [
"1.1.1.1",
"8.8.8.8",
"93.184.216.34",
"2606:4700:4700::1111",
] {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_blocked_ip(ip), "{s} should be allowed");
}
}
#[test]
fn host_as_ip_handles_bracketed_ipv6() {
assert_eq!(host_as_ip("[::1]"), Some("::1".parse().unwrap()));
assert_eq!(host_as_ip("127.0.0.1"), Some("127.0.0.1".parse().unwrap()));
assert_eq!(host_as_ip("example.com"), None);
}
#[test]
fn precheck_rejects_ip_literal_metadata_and_loopback() {
assert!(precheck_url("http://169.254.169.254/latest/meta-data/", false).is_err());
assert!(precheck_url("http://127.0.0.1:8081/", false).is_err());
assert!(precheck_url("http://[::1]/", false).is_err());
assert!(precheck_url("http://10.0.0.5/api", false).is_err());
}
#[test]
fn precheck_allows_public_and_hostnames_and_honors_optout() {
assert!(precheck_url("https://artifactory.example.com/artifactory", false).is_ok());
assert!(precheck_url("http://93.184.216.34/", false).is_ok());
assert!(precheck_url("http://internal-nexus/", false).is_ok());
assert!(precheck_url("http://10.0.0.5/", true).is_ok());
}
#[test]
fn precheck_rejects_non_http_scheme() {
assert!(precheck_url("file:///etc/passwd", false).is_err());
assert!(precheck_url("gopher://evil/", false).is_err());
}
#[test]
fn redirect_verdict_blocks_metadata_and_loopback_hops() {
assert_eq!(
redirect_verdict(Some("169.254.169.254"), 1, false),
RedirectVerdict::Blocked("169.254.169.254".parse().unwrap())
);
assert_eq!(
redirect_verdict(Some("[::1]"), 1, false),
RedirectVerdict::Blocked("::1".parse().unwrap())
);
assert_eq!(
redirect_verdict(Some("evil.example.com"), 1, false),
RedirectVerdict::Follow
);
assert_eq!(
redirect_verdict(Some("93.184.216.34"), 1, false),
RedirectVerdict::Follow
);
assert_eq!(
redirect_verdict(Some("example.com"), MAX_REDIRECTS, false),
RedirectVerdict::TooMany
);
assert_eq!(
redirect_verdict(Some("10.0.0.1"), 1, true),
RedirectVerdict::Follow
);
}
#[test]
fn redact_strips_userinfo_and_query() {
assert_eq!(
redact_url("https://user:s3cret@nexus.example.com/repo?token=abc"),
"https://nexus.example.com/repo"
);
assert_eq!(
redact_url("https://nexus.example.com/repo"),
"https://nexus.example.com/repo"
);
}
}