use std::net::{IpAddr, Ipv4Addr};
use reqwest::Client;
#[derive(Debug, thiserror::Error)]
pub enum HttpClientError {
#[error(transparent)]
Crypto(#[from] kryptering::Error),
#[error("failed to build hardened HTTP client: {0}")]
Build(#[from] reqwest::Error),
}
#[derive(Clone)]
pub struct AttestedHttpClient {
inner: Client,
backend: kryptering::BackendInfo,
verified: bool,
}
impl std::fmt::Debug for AttestedHttpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AttestedHttpClient")
.field("backend", &self.backend)
.field("verified", &self.verified)
.finish_non_exhaustive()
}
}
impl AttestedHttpClient {
#[must_use]
pub fn backend_info(&self) -> &kryptering::BackendInfo {
&self.backend
}
#[must_use]
pub const fn is_verified(&self) -> bool {
self.verified
}
pub(crate) fn client(&self) -> &Client {
&self.inner
}
}
pub const MAX_REDIRECTS: usize = 5;
fn is_disallowed_ipv4(v4: Ipv4Addr) -> bool {
let o = v4.octets();
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_documentation()
|| v4.is_multicast()
|| o[0] == 0
|| (o[0] == 100 && (o[1] & 0xc0) == 0x40)
|| (o[0] == 192 && o[1] == 0 && o[2] == 0)
|| (o[0] == 198 && (o[1] & 0xfe) == 18)
|| o[0] >= 240
}
pub fn is_disallowed_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_disallowed_ipv4(v4),
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return is_disallowed_ipv4(v4);
}
let first = v6.segments()[0];
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| (first & 0xfe00) == 0xfc00
|| (first & 0xffc0) == 0xfe80
}
}
}
fn unbracket(host: &str) -> &str {
host.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(host)
}
pub fn hardened_http_client() -> Result<AttestedHttpClient, HttpClientError> {
let roots = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let tls = kryptering::build_tls_client_config(roots)?;
attested_http_client(tls)
}
pub fn attested_http_client(
tls: kryptering::AttestedTlsConfig,
) -> Result<AttestedHttpClient, HttpClientError> {
let policy = reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= MAX_REDIRECTS {
return attempt.error("too many redirects");
}
if let Some(host) = attempt.url().host_str() {
if let Ok(ip) = unbracket(host).parse::<IpAddr>() {
if is_disallowed_ip(ip) {
return attempt.stop();
}
}
}
attempt.follow()
});
let config = tls.config();
let inner = Client::builder()
.redirect(policy)
.use_preconfigured_tls((*config).clone())
.build()
.map_err(HttpClientError::Build)?;
Ok(AttestedHttpClient {
inner,
backend: kryptering::backend_info()?,
verified: true,
})
}
#[cfg(not(feature = "fips"))]
pub fn unverified_http_client(client: Client) -> Result<AttestedHttpClient, HttpClientError> {
Ok(AttestedHttpClient {
inner: client,
backend: kryptering::backend_info()?,
verified: false,
})
}
#[derive(Debug)]
pub enum UrlGuardError {
Parse(String),
Scheme(String),
NoHost,
Resolution(String),
NoAddresses(String),
NonPublic(String),
}
impl std::fmt::Display for UrlGuardError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UrlGuardError::Parse(m) => write!(f, "invalid URL: {m}"),
UrlGuardError::Scheme(s) => {
write!(
f,
"URL scheme not allowed: {s} (only http/https are supported)"
)
}
UrlGuardError::NoHost => write!(f, "URL has no host"),
UrlGuardError::Resolution(m) => write!(f, "failed to resolve host: {m}"),
UrlGuardError::NoAddresses(h) => write!(f, "host {h} resolved to no addresses"),
UrlGuardError::NonPublic(m) => {
write!(f, "host {m} is a non-public address (SSRF guard)")
}
}
}
}
pub async fn validate_fetch_url(url: &str) -> Result<(), UrlGuardError> {
let parsed =
reqwest::Url::parse(url).map_err(|e| UrlGuardError::Parse(format!("{url}: {e}")))?;
match parsed.scheme() {
"http" | "https" => {}
other => return Err(UrlGuardError::Scheme(other.to_string())),
}
let host = parsed.host_str().ok_or(UrlGuardError::NoHost)?;
let host_bare = unbracket(host);
if let Ok(ip) = host_bare.parse::<IpAddr>() {
if is_disallowed_ip(ip) {
return Err(UrlGuardError::NonPublic(host_bare.to_string()));
}
return Ok(());
}
let port = parsed.port_or_known_default().unwrap_or(0);
let host_owned = host_bare.to_string();
let host_for_lookup = host_owned.clone();
let addrs: Vec<std::net::SocketAddr> = tokio::task::spawn_blocking(move || {
use std::net::ToSocketAddrs;
(host_for_lookup.as_str(), port)
.to_socket_addrs()
.map(|it| it.collect::<Vec<_>>())
})
.await
.map_err(|e| UrlGuardError::Resolution(format!("DNS resolution task failed: {e}")))?
.map_err(|e| UrlGuardError::Resolution(format!("{host_owned}: {e}")))?;
if addrs.is_empty() {
return Err(UrlGuardError::NoAddresses(host_owned));
}
for addr in &addrs {
if is_disallowed_ip(addr.ip()) {
return Err(UrlGuardError::NonPublic(format!(
"{host_owned} resolved to {}",
addr.ip()
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use rcgen::{generate_simple_self_signed, CertifiedKey};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn https_fixture() -> (CertificateDer<'static>, PrivateKeyDer<'static>) {
let CertifiedKey { cert, key_pair } =
generate_simple_self_signed(vec!["localhost".to_owned()])
.expect("test certificate generation");
let private_key = PrivatePkcs8KeyDer::from(key_pair.serialize_der());
(cert.der().clone(), private_key.into())
}
async fn spawn_https_server(
certificate: CertificateDer<'static>,
private_key: PrivateKeyDer<'static>,
) -> (u16, tokio::task::JoinHandle<()>) {
let provider = rustls::crypto::ring::default_provider();
let config = rustls::ServerConfig::builder_with_provider(Arc::new(provider))
.with_safe_default_protocol_versions()
.expect("test TLS versions")
.with_no_client_auth()
.with_single_cert(vec![certificate], private_key)
.expect("test TLS identity");
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config));
let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.await
.expect("bind HTTPS fixture");
let port = listener.local_addr().expect("fixture address").port();
let task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("fixture connection");
if let Ok(mut stream) = acceptor.accept(stream).await {
let mut request = [0_u8; 4096];
let _ = stream.read(&mut request).await;
let _ = stream
.write_all(
b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nOK",
)
.await;
let _ = stream.shutdown().await;
}
});
(port, task)
}
fn attested_client_with_root(root: Option<CertificateDer<'static>>) -> AttestedHttpClient {
#[cfg(feature = "fips")]
kryptering::initialize_backend().expect("initialize FIPS backend for HTTPS unit test");
let mut roots = rustls::RootCertStore::empty();
if let Some(root) = root {
roots.add(root).expect("add test trust anchor");
}
let tls = kryptering::build_tls_client_config(roots).expect("attested test TLS config");
attested_http_client(tls).expect("attested test HTTP client")
}
#[test]
fn loopback_and_metadata_are_disallowed() {
assert!(is_disallowed_ip("127.0.0.1".parse().unwrap()));
assert!(is_disallowed_ip("169.254.169.254".parse().unwrap()));
assert!(is_disallowed_ip("10.0.0.1".parse().unwrap()));
assert!(is_disallowed_ip("192.168.1.1".parse().unwrap()));
assert!(is_disallowed_ip("172.16.0.1".parse().unwrap()));
assert!(is_disallowed_ip("100.64.0.1".parse().unwrap())); assert!(is_disallowed_ip("::1".parse().unwrap()));
assert!(is_disallowed_ip("fe80::1".parse().unwrap()));
assert!(is_disallowed_ip("fc00::1".parse().unwrap()));
assert!(is_disallowed_ip("::ffff:127.0.0.1".parse().unwrap()));
}
#[test]
fn public_addresses_are_allowed() {
assert!(!is_disallowed_ip("8.8.8.8".parse().unwrap()));
assert!(!is_disallowed_ip("1.1.1.1".parse().unwrap()));
assert!(!is_disallowed_ip("2606:4700:4700::1111".parse().unwrap()));
}
#[test]
fn reserved_ipv4_ranges_are_disallowed() {
assert!(is_disallowed_ip("0.1.2.3".parse().unwrap())); assert!(is_disallowed_ip("192.0.0.1".parse().unwrap())); assert!(is_disallowed_ip("198.18.0.1".parse().unwrap())); assert!(is_disallowed_ip("198.19.255.255".parse().unwrap())); assert!(is_disallowed_ip("240.0.0.1".parse().unwrap())); assert!(is_disallowed_ip("255.255.255.255".parse().unwrap())); assert!(is_disallowed_ip("203.0.113.7".parse().unwrap()));
}
#[tokio::test]
async fn rejects_non_http_scheme() {
let err = validate_fetch_url("file:///etc/passwd").await.unwrap_err();
assert!(matches!(err, UrlGuardError::Scheme(_)));
}
#[tokio::test]
async fn rejects_literal_loopback() {
let err = validate_fetch_url("http://127.0.0.1/x").await.unwrap_err();
assert!(matches!(err, UrlGuardError::NonPublic(_)));
}
#[tokio::test]
async fn rejects_literal_metadata() {
let err = validate_fetch_url("http://169.254.169.254/latest/meta-data/")
.await
.unwrap_err();
assert!(matches!(err, UrlGuardError::NonPublic(_)));
}
#[tokio::test]
async fn rejects_localhost_without_duplicate_non_public_message() {
let err = validate_fetch_url("http://localhost/x").await.unwrap_err();
let msg = err.to_string();
assert!(matches!(err, UrlGuardError::NonPublic(_)));
assert_eq!(
msg.matches("non-public address").count(),
1,
"non-public wording should not be duplicated: {msg}"
);
}
#[tokio::test]
async fn selected_tls_provider_connects_to_trusted_https_fixture() {
let (certificate, private_key) = https_fixture();
let (port, server) = spawn_https_server(certificate.clone(), private_key).await;
let client = attested_client_with_root(Some(certificate));
let response = client
.client()
.get(format!("https://localhost:{port}/"))
.send()
.await
.expect("trusted HTTPS request");
assert_eq!(response.status(), reqwest::StatusCode::OK);
server.await.expect("HTTPS fixture task");
}
#[tokio::test]
async fn selected_tls_provider_rejects_wrong_hostname() {
let (certificate, private_key) = https_fixture();
let (port, server) = spawn_https_server(certificate.clone(), private_key).await;
let client = attested_client_with_root(Some(certificate));
let result = client
.client()
.get(format!("https://127.0.0.1:{port}/"))
.send()
.await;
assert!(result.is_err(), "hostname mismatch unexpectedly succeeded");
server.await.expect("HTTPS fixture task");
}
#[tokio::test]
async fn selected_tls_provider_rejects_untrusted_certificate() {
let (certificate, private_key) = https_fixture();
let (port, server) = spawn_https_server(certificate, private_key).await;
let client = attested_client_with_root(None);
let result = client
.client()
.get(format!("https://localhost:{port}/"))
.send()
.await;
assert!(
result.is_err(),
"untrusted certificate unexpectedly succeeded"
);
server.await.expect("HTTPS fixture task");
}
}