use std::net::SocketAddr;
use std::time::Duration;
use zeroize::Zeroize;
use crate::core::{AsxError, ErrorCode, ErrorContext, Result};
#[cfg(all(feature = "http-client", any(feature = "as2", feature = "as4")))]
pub(crate) async fn validated_pinned_revocation_client(
url: &str,
config: &TransportConfig,
context: &'static str,
) -> Result<reqwest::Client> {
let target = validate_egress_target(
url,
context,
EgressSchemePolicy::AllowPlaintextForSignedRevocation,
)
.await?;
let pinned = target
.resolved_host
.as_deref()
.map(|host| (host, target.resolved_addrs.as_slice()));
build_revocation_http_client(config, context, pinned)
}
#[cfg(all(feature = "http-client", any(feature = "as2", feature = "as4")))]
fn build_revocation_http_client(
config: &TransportConfig,
context: &'static str,
pinned_resolution: Option<(&str, &[SocketAddr])>,
) -> Result<reqwest::Client> {
let mut builder = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(config.connect_timeout)
.timeout(config.request_timeout)
.user_agent(&config.user_agent);
if let Some((host, addrs)) = pinned_resolution {
builder = builder.resolve_to_addrs(host, addrs);
}
builder.build().map_err(|err| {
AsxError::new(
ErrorCode::TransportFailure,
format!("failed to build revocation HTTP client: {err}"),
ErrorContext::new(context),
)
})
}
#[cfg(any(feature = "as2", feature = "as4"))]
pub(crate) async fn validated_pinned_client(
url: &str,
config: &TransportConfig,
context: &'static str,
) -> Result<reqwest::Client> {
let target = validate_egress_target_with_policy(url, context).await?;
match target.resolved_host.as_deref() {
Some(host) => build_http_client(config, context, Some((host, &target.resolved_addrs))),
None => build_http_client(config, context, None),
}
}
#[derive(Debug, Clone)]
#[cfg_attr(not(any(feature = "as2", feature = "as4")), allow(dead_code))]
struct ValidatedEgressTarget {
resolved_host: Option<String>,
resolved_addrs: Vec<SocketAddr>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(any(feature = "as2", feature = "as4")), allow(dead_code))]
pub(crate) enum EgressSchemePolicy {
HttpsOnly,
AllowPlaintextForSignedRevocation,
}
#[cfg(any(feature = "as2", feature = "as4", test))]
async fn validate_egress_target_with_policy(
url: &str,
context: &'static str,
) -> Result<ValidatedEgressTarget> {
validate_egress_target(url, context, EgressSchemePolicy::HttpsOnly).await
}
#[cfg(test)]
async fn validate_egress_url(url: &str, context: &'static str) -> Result<()> {
validate_egress_target_with_policy(url, context)
.await
.map(|_| ())
}
async fn validate_egress_target(
url: &str,
context: &'static str,
scheme_policy: EgressSchemePolicy,
) -> Result<ValidatedEgressTarget> {
let parsed = reqwest::Url::parse(url).map_err(|_| {
AsxError::new(
ErrorCode::InvalidInput,
format!("malformed egress URL: {url}"),
ErrorContext::new(context),
)
})?;
match parsed.scheme() {
"https" => {}
"http" if scheme_policy == EgressSchemePolicy::AllowPlaintextForSignedRevocation => {}
"http" => {
return Err(AsxError::new(
ErrorCode::PolicyViolation,
"plain HTTP egress is not permitted; use HTTPS for all outbound transport",
ErrorContext::new(context),
));
}
scheme => {
return Err(AsxError::new(
ErrorCode::InvalidInput,
format!(
"egress URL scheme '{scheme}' is not allowed; \
only http and https are permitted"
),
ErrorContext::new(context),
));
}
}
let mut resolved_host = None;
let mut resolved_addrs = Vec::new();
if let Some(host) = parsed.host_str() {
if is_private_host(host) {
return Err(AsxError::new(
ErrorCode::InvalidInput,
format!(
"egress URL host '{host}' is a private or loopback address; \
outbound requests to internal networks are not permitted"
),
ErrorContext::new(context),
));
}
if host.parse::<std::net::IpAddr>().is_err() {
let port = parsed.port_or_known_default().unwrap_or(443);
let lookup_target = format!("{host}:{port}");
let addrs = tokio::net::lookup_host(&lookup_target).await.map_err(|e| {
AsxError::new(
ErrorCode::InvalidInput,
format!("egress URL host '{host}' could not be resolved: {e}"),
ErrorContext::new(context),
)
})?;
for addr in addrs {
if is_private_ip(addr.ip()) {
return Err(AsxError::new(
ErrorCode::InvalidInput,
format!(
"egress URL host '{host}' resolves to a private or loopback address \
({ip}); outbound requests to internal networks are not permitted",
ip = addr.ip()
),
ErrorContext::new(context),
));
}
resolved_addrs.push(addr);
}
if resolved_addrs.is_empty() {
return Err(AsxError::new(
ErrorCode::InvalidInput,
format!("egress URL host '{host}' resolved to no usable addresses"),
ErrorContext::new(context),
));
}
resolved_host = Some(host.to_string());
}
}
Ok(ValidatedEgressTarget {
resolved_host,
resolved_addrs,
})
}
#[cfg(any(feature = "as2", feature = "as4"))]
fn build_http_client(
config: &TransportConfig,
context: &'static str,
pinned_resolution: Option<(&str, &[SocketAddr])>,
) -> Result<reqwest::Client> {
let mut builder = reqwest::Client::builder()
.https_only(true)
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(config.connect_timeout)
.timeout(config.request_timeout)
.user_agent(&config.user_agent)
.pool_max_idle_per_host(config.pool_max_idle_per_host)
.pool_idle_timeout(Some(config.pool_idle_timeout));
if let Some(identity) = &config.client_identity {
builder = builder.identity(identity.as_reqwest());
}
if let Some((host, addrs)) = pinned_resolution {
builder = builder.resolve_to_addrs(host, addrs);
}
builder.build().map_err(|err| {
AsxError::new(
ErrorCode::TransportFailure,
format!("failed to build HTTP client: {err}"),
ErrorContext::new(context),
)
})
}
pub(crate) fn is_private_ip(addr: std::net::IpAddr) -> bool {
match addr.to_canonical() {
std::net::IpAddr::V4(ip) => is_private_ipv4(ip),
std::net::IpAddr::V6(ip) => is_private_ipv6(ip),
}
}
fn is_private_ipv4(ip: std::net::Ipv4Addr) -> bool {
let [a, b, ..] = ip.octets();
ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_broadcast() || ip.is_documentation() || ip.is_multicast() || a == 0 || (a == 100 && (64..=127).contains(&b)) || (a == 192 && b == 0) || (a == 198 && (b == 18 || b == 19)) || a >= 240 }
fn is_private_ipv6(ip: std::net::Ipv6Addr) -> bool {
let seg = ip.segments();
let low32 = std::net::Ipv4Addr::new(
(seg[6] >> 8) as u8,
seg[6] as u8,
(seg[7] >> 8) as u8,
seg[7] as u8,
);
let nat64_wkp = seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6] == [0, 0, 0, 0];
let nat64_local = seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2] == 0x0001;
let ipv4_compatible = seg[0..6] == [0, 0, 0, 0, 0, 0];
if nat64_wkp || nat64_local || ipv4_compatible {
return is_private_ipv4(low32);
}
if seg[0] == 0x2002 {
let embedded = std::net::Ipv4Addr::new(
(seg[1] >> 8) as u8,
seg[1] as u8,
(seg[2] >> 8) as u8,
seg[2] as u8,
);
return is_private_ipv4(embedded);
}
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| (seg[0] & 0xfe00) == 0xfc00
|| (seg[0] & 0xffc0) == 0xfe80
|| (seg[0] == 0x2001 && seg[1] == 0x0000)
|| (seg[0] == 0x2001 && seg[1] == 0x0db8)
|| seg[0..4] == [0x0100, 0, 0, 0]
}
pub(crate) fn is_private_host(host: &str) -> bool {
if host.eq_ignore_ascii_case("localhost") {
return true;
}
let h_lower = host.to_ascii_lowercase();
let h_bare = h_lower.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = h_bare.parse::<std::net::IpAddr>() {
return is_private_ip(ip);
}
false
}
#[derive(Clone)]
#[cfg_attr(not(any(feature = "as2", feature = "as4")), allow(dead_code))]
pub struct ClientIdentity {
identity: reqwest::Identity,
cert_chain_len: usize,
}
impl std::fmt::Debug for ClientIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientIdentity")
.field(
"cert_chain_pem",
&format_args!("<{} bytes>", self.cert_chain_len),
)
.field("key_pem", &"<redacted>")
.finish()
}
}
impl ClientIdentity {
pub fn from_pem(cert_chain_pem: impl AsRef<[u8]>, key_pem: impl AsRef<[u8]>) -> Result<Self> {
let cert_chain_pem = cert_chain_pem.as_ref();
let key_pem = key_pem.as_ref();
if cert_chain_pem.is_empty() {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"client identity certificate chain is empty",
ErrorContext::new("transport_client_identity"),
));
}
if key_pem.is_empty() {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"client identity private key is empty",
ErrorContext::new("transport_client_identity"),
));
}
let mut combined = Vec::with_capacity(key_pem.len() + cert_chain_pem.len() + 1);
combined.extend_from_slice(key_pem);
if !key_pem.ends_with(b"\n") {
combined.push(b'\n');
}
combined.extend_from_slice(cert_chain_pem);
let parsed = reqwest::Identity::from_pem(&combined);
combined.zeroize();
Ok(Self {
identity: parsed.map_err(|err| {
AsxError::new(
ErrorCode::InvalidInput,
format!("invalid TLS client identity (certificate/key pair): {err}"),
ErrorContext::new("transport_client_identity"),
)
})?,
cert_chain_len: cert_chain_pem.len(),
})
}
#[cfg_attr(not(any(feature = "as2", feature = "as4")), allow(dead_code))]
fn as_reqwest(&self) -> reqwest::Identity {
self.identity.clone()
}
}
#[derive(Debug, Clone)]
pub struct TransportConfig {
pub connect_timeout: Duration,
pub request_timeout: Duration,
pub user_agent: String,
pub pool_max_idle_per_host: usize,
pub pool_idle_timeout: Duration,
pub client_identity: Option<ClientIdentity>,
}
impl Default for TransportConfig {
fn default() -> Self {
Self {
connect_timeout: Duration::from_secs(10),
request_timeout: Duration::from_secs(60),
user_agent: concat!("asx/", env!("CARGO_PKG_VERSION")).to_string(),
pool_max_idle_per_host: 4,
pool_idle_timeout: Duration::from_secs(90),
client_identity: None,
}
}
}
pub(crate) async fn read_body_capped(
mut resp: reqwest::Response,
cap: usize,
stage: &'static str,
) -> Result<bytes::Bytes> {
let too_large = |size: String| {
AsxError::new(
ErrorCode::PayloadTooLarge,
format!("response body {size} exceeds the {cap} byte limit"),
ErrorContext::new(stage),
)
};
if let Some(declared) = resp.content_length()
&& declared > cap as u64
{
return Err(too_large(format!("of {declared} bytes (Content-Length)")));
}
let mut body = bytes::BytesMut::new();
while let Some(chunk) = resp.chunk().await.map_err(|err| {
AsxError::new(
ErrorCode::TransportFailure,
format!("failed to read response body: {err}"),
ErrorContext::new(stage),
)
})? {
if body.len().saturating_add(chunk.len()) > cap {
return Err(too_large(format!(
"of more than {} bytes",
body.len().saturating_add(chunk.len())
)));
}
body.extend_from_slice(&chunk);
}
Ok(body.freeze())
}
#[cfg(test)]
mod tests {
use super::*;
async fn serve_once(head: String, body_chunks: Vec<Vec<u8>>) -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("accept");
let mut request = [0u8; 1024];
let _ = socket.read(&mut request).await;
let _ = socket.write_all(head.as_bytes()).await;
for chunk in body_chunks {
if socket.write_all(&chunk).await.is_err() {
return;
}
}
let _ = socket.shutdown().await;
});
format!("http://{addr}/")
}
#[tokio::test]
async fn read_body_capped_refuses_oversized_bodies() {
const CAP: usize = 1024;
let client = reqwest::Client::new();
let url = serve_once(
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
CAP + 1
),
vec![vec![b'a'; CAP + 1]],
)
.await;
let resp = client.get(&url).send().await.expect("response");
let err = read_body_capped(resp, CAP, "capped_test")
.await
.expect_err("a Content-Length over the cap must be refused");
assert_eq!(err.code, ErrorCode::PayloadTooLarge, "{}", err.message);
let chunks: Vec<Vec<u8>> = (0..16)
.map(|_| {
let data = vec![b'b'; 256];
let mut chunk = format!("{:x}\r\n", data.len()).into_bytes();
chunk.extend_from_slice(&data);
chunk.extend_from_slice(b"\r\n");
chunk
})
.chain(std::iter::once(b"0\r\n\r\n".to_vec()))
.collect();
let url = serve_once(
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
.to_string(),
chunks,
)
.await;
let resp = client.get(&url).send().await.expect("response");
let err = read_body_capped(resp, CAP, "capped_test")
.await
.expect_err("a chunked body past the cap must be refused");
assert_eq!(err.code, ErrorCode::PayloadTooLarge, "{}", err.message);
let url = serve_once(
format!("HTTP/1.1 200 OK\r\nContent-Length: {CAP}\r\nConnection: close\r\n\r\n"),
vec![vec![b'c'; CAP]],
)
.await;
let resp = client.get(&url).send().await.expect("response");
let body = read_body_capped(resp, CAP, "capped_test")
.await
.expect("a body at the cap is accepted");
assert_eq!(body.len(), CAP);
}
#[test]
fn transition_benchmark_and_documentation_ranges_are_private() {
let private = [
"64:ff9b::10.0.0.1", "64:ff9b:1::a00:1", "2002:0a00:0001::1", "::10.0.0.1", "2001:0::1", "198.18.0.1", "2001:db8::1", "100::1", ];
for addr in private {
let ip: std::net::IpAddr = addr.parse().expect(addr);
assert!(is_private_ip(ip), "{addr} must be private");
}
let public = [
"64:ff9b::8.8.8.8",
"2002:0808:0808::1",
"8.8.8.8",
"2606:4700::1111",
];
for addr in public {
let ip: std::net::IpAddr = addr.parse().expect(addr);
assert!(!is_private_ip(ip), "{addr} must stay public");
}
}
#[test]
fn transport_config_defaults_are_sensible() {
let cfg = TransportConfig::default();
assert_eq!(cfg.connect_timeout, Duration::from_secs(10));
assert_eq!(cfg.request_timeout, Duration::from_secs(60));
assert!(cfg.user_agent.starts_with("asx/"));
assert!(cfg.pool_max_idle_per_host > 0);
}
#[tokio::test]
async fn validate_egress_url_rejects_non_http_scheme() {
let err = validate_egress_url("ftp://example.com/file", "ctx")
.await
.unwrap_err();
assert_eq!(err.code, ErrorCode::InvalidInput);
assert!(err.message.contains("ftp"));
}
#[tokio::test]
async fn validate_egress_url_rejects_localhost() {
for host in &["https://localhost/path", "https://localhost:8080/as2"] {
let err = validate_egress_url(host, "ctx").await.unwrap_err();
assert_eq!(err.code, ErrorCode::InvalidInput);
}
}
#[tokio::test]
async fn validate_egress_url_rejects_loopback_ipv4() {
for url in &["https://127.0.0.1/as2", "https://127.1.2.3:8443/as2"] {
let err = validate_egress_url(url, "ctx").await.unwrap_err();
assert_eq!(
err.code,
ErrorCode::InvalidInput,
"expected rejection for {url}"
);
}
}
#[tokio::test]
async fn validate_egress_url_rejects_private_ipv4_ranges() {
for url in &[
"https://10.0.0.1/as2",
"https://172.16.0.1/as2",
"https://172.31.255.255/as2",
"https://192.168.1.100/as2",
"https://169.254.1.1/as2",
] {
let err = validate_egress_url(url, "ctx").await.unwrap_err();
assert_eq!(
err.code,
ErrorCode::InvalidInput,
"expected rejection for {url}"
);
}
}
#[tokio::test]
async fn validate_egress_url_rejects_ipv4_mapped_ipv6_and_reserved_v4() {
for url in &[
"https://[::ffff:127.0.0.1]/as4", "https://[::ffff:169.254.169.254]/as4", "https://[::ffff:10.0.0.1]/as4", "https://0.0.0.0/as4", "https://100.64.0.1/as4", "https://203.0.113.9/as4", ] {
let err = validate_egress_url(url, "ctx").await.unwrap_err();
assert_eq!(
err.code,
ErrorCode::InvalidInput,
"expected rejection for {url}"
);
}
}
#[tokio::test]
async fn validate_egress_url_rejects_private_ipv6_ranges() {
for url in &[
"https://[::1]/as4",
"https://[fc00::1]/as4",
"https://[fd12:3456:789a::1]/as4",
"https://[fe80::1]/as4",
] {
let err = validate_egress_url(url, "ctx").await.unwrap_err();
assert_eq!(
err.code,
ErrorCode::InvalidInput,
"expected rejection for {url}"
);
}
}
#[tokio::test]
async fn validate_egress_url_accepts_public_https() {
validate_egress_url("https://8.8.8.8/as2/receive", "ctx")
.await
.expect("public HTTPS should be accepted");
}
#[tokio::test]
async fn validate_egress_url_rejects_public_http_by_default() {
let err = validate_egress_url("http://8.8.8.8/as4/receive", "ctx")
.await
.unwrap_err();
assert_eq!(err.code, ErrorCode::PolicyViolation);
}
#[tokio::test]
async fn validate_egress_target_for_ip_literal_requires_no_dns_pinning() {
let target = validate_egress_target_with_policy("https://8.8.8.8/as2/receive", "ctx")
.await
.expect("public ip literal should validate");
assert!(target.resolved_host.is_none());
assert!(target.resolved_addrs.is_empty());
}
#[tokio::test]
async fn validate_egress_url_rejects_malformed() {
let err = validate_egress_url("not a url at all", "ctx")
.await
.unwrap_err();
assert_eq!(err.code, ErrorCode::InvalidInput);
}
}