use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs};
use std::time::Duration;
#[derive(Debug, Clone, Copy)]
pub struct ClientTimeouts {
pub connect: Duration,
pub total: Duration,
}
impl Default for ClientTimeouts {
fn default() -> Self {
Self {
connect: Duration::from_secs(10),
total: Duration::from_secs(60),
}
}
}
pub fn client_builder(timeouts: ClientTimeouts) -> reqwest::ClientBuilder {
reqwest::Client::builder()
.connect_timeout(timeouts.connect)
.timeout(timeouts.total)
.redirect(reqwest::redirect::Policy::limited(5))
}
pub fn client(timeouts: ClientTimeouts) -> reqwest::Client {
client_builder(timeouts)
.build()
.expect("building an HTTP client fails only on TLS backend init")
}
fn redirect_decision(
previous_hops: usize,
url: &url::Url,
allow_local: bool,
) -> Result<(), String> {
if previous_hops >= 5 {
return Err("too many redirects".to_string());
}
check_url(url, allow_local).map_err(|e| format!("refused to follow redirect: {e}"))
}
pub fn checked_client(timeouts: ClientTimeouts, allow_local: bool) -> reqwest::Client {
client_builder(timeouts)
.redirect(reqwest::redirect::Policy::custom(
move |attempt| match redirect_decision(
attempt.previous().len(),
attempt.url(),
allow_local,
) {
Ok(()) => attempt.follow(),
Err(e) => attempt.error(e),
},
))
.build()
.expect("building an HTTP client fails only on TLS backend init")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UrlRejection {
Scheme(String),
Unresolvable(String),
PrivateAddress {
host: String,
addr: IpAddr,
},
}
impl UrlRejection {
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Self::Scheme(_) => "scheme",
Self::Unresolvable(_) => "unresolvable",
Self::PrivateAddress { .. } => "private_address",
}
}
}
impl std::fmt::Display for UrlRejection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Scheme(s) => write!(
f,
"scheme '{s}' is not fetchable - only http and https are allowed"
),
Self::Unresolvable(h) => write!(f, "host '{h}' did not resolve"),
Self::PrivateAddress { host, addr } => write!(
f,
"'{host}' resolves to {addr}, which is on a loopback, private, or \
link-local network. Fetching it from an agent would reach the \
user's own machine or LAN - including cloud metadata services \
that hand out credentials. Set `[security] allow_local_network = \
true` if this agent is genuinely meant to talk to a local service."
),
}
}
}
pub fn is_restricted_addr(addr: IpAddr) -> bool {
match addr {
IpAddr::V4(v4) => is_restricted_v4(v4),
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
Some(v4) => is_restricted_v4(v4),
None => is_restricted_v6(v6),
},
}
}
fn is_restricted_v4(a: Ipv4Addr) -> bool {
let [b0, b1, ..] = a.octets();
a.is_loopback()
|| a.is_private()
|| a.is_link_local()
|| a.is_unspecified()
|| a.is_multicast()
|| a.is_broadcast()
|| a.is_documentation()
|| (b0 == 100 && (64..128).contains(&b1))
|| (b0 == 192 && b1 == 0 && a.octets()[2] == 0)
|| b0 >= 240
}
fn is_restricted_v6(a: Ipv6Addr) -> bool {
let seg0 = a.segments()[0];
a.is_loopback()
|| a.is_unspecified()
|| a.is_multicast()
|| (seg0 & 0xfe00) == 0xfc00
|| (seg0 & 0xffc0) == 0xfe80
}
pub fn check_url(url: &url::Url, allow_local: bool) -> Result<(), UrlRejection> {
check_url_with(url, allow_local, resolve_host)
}
fn resolve_host(host: &str, port: u16) -> Option<Vec<IpAddr>> {
(host, port)
.to_socket_addrs()
.ok()
.map(|addrs| addrs.map(|sa| sa.ip()).collect())
}
pub(crate) fn check_url_with(
url: &url::Url,
allow_local: bool,
resolve: fn(&str, u16) -> Option<Vec<IpAddr>>,
) -> Result<(), UrlRejection> {
match url.scheme() {
"http" | "https" => {}
other => return Err(UrlRejection::Scheme(other.to_string())),
}
if allow_local {
return Ok(());
}
let host = url
.host()
.expect("http/https URLs always have a host per the URL Standard");
let (host_str, literal) = match host {
url::Host::Ipv4(v4) => (v4.to_string(), Some(IpAddr::V4(v4))),
url::Host::Ipv6(v6) => (v6.to_string(), Some(IpAddr::V6(v6))),
url::Host::Domain(d) => (d.to_string(), None),
};
if let Some(addr) = literal {
return match is_restricted_addr(addr) {
true => Err(UrlRejection::PrivateAddress {
host: host_str,
addr,
}),
false => Ok(()),
};
}
let host = host_str.as_str();
let port = url.port_or_known_default().unwrap_or(80);
let resolved = resolve(host, port).filter(|addrs: &Vec<IpAddr>| !addrs.is_empty());
let Some(resolved) = resolved else {
return Err(UrlRejection::Unresolvable(host.to_string()));
};
for addr in resolved {
if is_restricted_addr(addr) {
return Err(UrlRejection::PrivateAddress {
host: host.to_string(),
addr,
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(test)]
async fn redirecting_server(target: String, hops: usize) -> std::net::SocketAddr {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
for i in 0..=hops {
let (mut sock, _) = listener.accept().await.expect("accept");
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf).await;
let body = match i < hops {
true => format!(
"HTTP/1.1 307 Temporary Redirect\r\nLocation: {target}\r\n\
Content-Length: 0\r\nConnection: close\r\n\r\n"
),
false => "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\
Connection: close\r\n\r\n"
.to_string(),
};
let _ = sock.write_all(body.as_bytes()).await;
let _ = sock.flush().await;
}
});
addr
}
#[tokio::test(flavor = "multi_thread")]
async fn checked_client_refuses_a_redirect_to_a_restricted_address() {
let addr = redirecting_server("http://169.254.169.254/latest/".to_string(), 1).await;
let client = checked_client(ClientTimeouts::default(), false);
let err = client
.get(format!("http://{addr}/hook"))
.send()
.await
.expect_err("the hop to 169.254.169.254 must be refused");
let mut chain = err.to_string();
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&err);
while let Some(e) = src {
chain.push_str(&format!(": {e}"));
src = e.source();
}
let refused = chain.contains("refused to follow redirect");
assert!(refused, "{chain}");
}
#[tokio::test(flavor = "multi_thread")]
async fn checked_client_follows_a_permitted_redirect() {
let destination = redirecting_server(String::new(), 0).await;
let addr = redirecting_server(format!("http://{destination}/done"), 1).await;
let client = checked_client(ClientTimeouts::default(), true);
let resp = client
.get(format!("http://{addr}/hook"))
.send()
.await
.expect("a permitted hop is followed to its destination");
assert_eq!(resp.status().as_u16(), 204);
}
#[test]
fn a_redirect_hop_is_checked_like_any_other_url() {
let private = "http://169.254.169.254/latest/".parse().unwrap();
assert!(redirect_decision(0, &private, false).is_err());
assert!(redirect_decision(0, &"http://127.0.0.1/x".parse().unwrap(), false).is_err());
assert!(redirect_decision(0, &private, true).is_ok());
}
#[test]
fn a_redirect_loop_is_bounded_regardless_of_destination() {
let ok = "http://127.0.0.1/x".parse().unwrap();
assert!(redirect_decision(4, &ok, true).is_ok());
let err = redirect_decision(5, &ok, true).expect_err("the sixth hop is refused");
assert!(err.contains("too many redirects"), "{err}");
}
fn u(s: &str) -> url::Url {
url::Url::parse(s).expect("test URL parses")
}
#[test]
fn the_default_timeouts_are_a_real_floor() {
let t = ClientTimeouts::default();
assert!(t.connect > Duration::ZERO, "a connect timeout is the point");
assert!(
t.total > t.connect,
"the total budget must exceed the connect"
);
}
#[test]
fn the_client_builder_produces_a_usable_client() {
assert!(client_builder(ClientTimeouts::default()).build().is_ok());
let custom = ClientTimeouts {
connect: Duration::from_secs(1),
total: Duration::from_secs(2),
};
assert!(client_builder(custom).build().is_ok());
let _ = client(ClientTimeouts::default());
}
#[test]
fn rejects_non_http_schemes() {
for s in ["file:///etc/passwd", "ftp://example.com/x", "gopher://x/"] {
let err = check_url(&u(s), false).unwrap_err();
assert_eq!(err.kind(), "scheme", "{s} → {err:?}");
}
}
#[test]
fn rejects_cloud_metadata_endpoint() {
let err = check_url(&u("http://169.254.169.254/latest/meta-data/"), false).unwrap_err();
assert_eq!(err.kind(), "private_address");
}
#[test]
fn rejects_loopback_and_private_literals() {
for s in [
"http://127.0.0.1:3000/api/agents",
"http://0.0.0.0/",
"http://10.0.0.5/",
"http://192.168.1.1/",
"http://172.16.0.1/",
"http://100.64.0.1/",
"http://[::1]/",
"http://[fd00::1]/",
"http://[fe80::1]/",
] {
let err = check_url(&u(s), false).unwrap_err();
assert_eq!(err.kind(), "private_address", "{s} → {err:?}");
}
}
#[test]
fn rejects_ipv4_mapped_loopback() {
let err = check_url(&u("http://[::ffff:127.0.0.1]/"), false).unwrap_err();
assert_eq!(err.kind(), "private_address", "{err:?}");
assert!(is_restricted_addr("::ffff:10.0.0.1".parse().unwrap()));
}
#[test]
fn allows_public_literals() {
for s in ["http://93.184.216.34/", "https://[2606:2800:220:1::]/"] {
assert!(check_url(&u(s), false).is_ok(), "{s} should be allowed");
}
}
#[test]
fn allow_local_waives_the_address_check_but_not_the_scheme() {
assert!(check_url(&u("http://127.0.0.1:11434/api/tags"), true).is_ok());
assert!(check_url(&u("file:///etc/passwd"), true).is_err());
}
#[test]
fn unresolvable_host_is_reported_as_such() {
let err = check_url(&u("http://this-host-does-not-exist.invalid/"), false).unwrap_err();
assert_eq!(err.kind(), "unresolvable", "{err:?}");
}
#[test]
fn rejects_localhost_by_name() {
let err = check_url(&u("http://localhost:8080/"), false).unwrap_err();
assert_eq!(err.kind(), "private_address", "{err:?}");
}
fn resolves_to_nothing(_: &str, _: u16) -> Option<Vec<IpAddr>> {
Some(Vec::new())
}
fn resolver_fails(_: &str, _: u16) -> Option<Vec<IpAddr>> {
None
}
fn resolves_public(_: &str, _: u16) -> Option<Vec<IpAddr>> {
Some(vec!["93.184.216.34".parse().expect("valid")])
}
fn resolves_to_both(_: &str, _: u16) -> Option<Vec<IpAddr>> {
Some(vec![
"93.184.216.34".parse().expect("valid"),
"127.0.0.1".parse().expect("valid"),
])
}
#[test]
fn a_hostname_resolving_publicly_is_allowed() {
assert!(check_url_with(&u("https://example.com/x"), false, resolves_public).is_ok());
}
#[test]
fn a_hostname_resolving_to_both_public_and_private_is_refused() {
let err = check_url_with(&u("https://split.example/x"), false, resolves_to_both)
.expect_err("a private answer must settle it");
assert_eq!(err.kind(), "private_address");
}
#[test]
fn no_addresses_reads_as_unresolvable() {
for resolver in [resolves_to_nothing, resolver_fails] {
let err = check_url_with(&u("https://nowhere.example/x"), false, resolver)
.expect_err("no address means no fetch");
assert_eq!(err.kind(), "unresolvable");
}
}
static RESOLVER_CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
fn counting_resolver(_: &str, _: u16) -> Option<Vec<IpAddr>> {
RESOLVER_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some(vec!["93.184.216.34".parse().expect("valid")])
}
#[test]
fn the_scheme_is_checked_before_anything_is_resolved() {
use std::sync::atomic::Ordering::SeqCst;
let before = RESOLVER_CALLS.load(SeqCst);
assert!(check_url_with(&u("https://example.com/x"), false, counting_resolver).is_ok());
assert_eq!(RESOLVER_CALLS.load(SeqCst), before + 1);
let err = check_url_with(&u("ftp://example.com/x"), false, counting_resolver)
.expect_err("ftp is refused");
assert_eq!(err.kind(), "scheme");
assert_eq!(
RESOLVER_CALLS.load(SeqCst),
before + 1,
"a refused scheme must not trigger a DNS lookup"
);
assert!(check_url_with(&u("https://93.184.216.34/x"), false, counting_resolver).is_ok());
assert_eq!(
RESOLVER_CALLS.load(SeqCst),
before + 1,
"an IP literal must not trigger a DNS lookup"
);
}
#[test]
fn each_rejection_has_its_own_kind() {
let kinds = [
UrlRejection::Scheme("ftp".into()).kind(),
UrlRejection::Unresolvable("h".into()).kind(),
UrlRejection::PrivateAddress {
host: "h".into(),
addr: "127.0.0.1".parse().expect("valid"),
}
.kind(),
];
let unique: std::collections::HashSet<_> = kinds.iter().collect();
assert_eq!(
unique.len(),
kinds.len(),
"kinds must be distinct: {kinds:?}"
);
}
#[test]
fn rejection_messages_name_the_fix() {
let err = check_url(&u("http://127.0.0.1/"), false).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("allow_local_network"), "{msg}");
assert!(msg.contains("127.0.0.1"), "{msg}");
assert!(
UrlRejection::Unresolvable("h".into())
.to_string()
.contains("did not resolve")
);
assert!(
UrlRejection::Scheme("ftp".into())
.to_string()
.contains("only http and https")
);
}
#[test]
fn restricted_classification_covers_reserved_ranges() {
for s in [
"192.0.0.1", "240.0.0.1", "255.255.255.255",
"224.0.0.1", "192.0.2.1", ] {
assert!(is_restricted_addr(s.parse().unwrap()), "{s}");
}
assert!(!is_restricted_addr("8.8.8.8".parse().unwrap()));
assert!(is_restricted_addr("ff02::1".parse().unwrap()));
assert!(!is_restricted_addr("2001:4860:4860::8888".parse().unwrap()));
}
}