use std::net::SocketAddr;
use std::time::Duration;
use crate::core::{AsxError, ErrorCode, ErrorContext, Result};
use crate::http::HttpHeaders;
#[cfg(feature = "as2")]
use crate::as2::As2SendOutput;
#[cfg(feature = "as4")]
use crate::as4::As4SendOutput;
#[cfg_attr(not(feature = "as4"), allow(dead_code))]
pub(crate) async fn validate_egress_url(url: &str, context: &'static str) -> Result<()> {
let target = validate_egress_target_with_policy(url, context).await?;
let _ = (&target.url, &target.resolved_host, &target.resolved_addrs);
Ok(())
}
#[derive(Debug, Clone)]
struct ValidatedEgressTarget {
url: reqwest::Url,
resolved_host: Option<String>,
resolved_addrs: Vec<SocketAddr>,
}
async fn validate_egress_target_with_policy(
url: &str,
context: &'static str,
) -> 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" => {
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 {
url: parsed,
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()
.use_rustls_tls()
.https_only(true)
.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((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 {
std::net::IpAddr::V4(ip) => {
let [a, b, ..] = ip.octets();
matches!(
(a, b),
(127, _) | (10, _) | (172, 16..=31) | (192, 168) | (169, 254)
)
}
std::net::IpAddr::V6(ip) => {
ip.is_loopback()
|| ip.is_unspecified()
|| (ip.segments()[0] & 0xfe00) == 0xfc00
|| (ip.segments()[0] & 0xffc0) == 0xfe80
}
}
}
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(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,
}
impl Default for TransportConfig {
fn default() -> Self {
Self {
connect_timeout: Duration::from_secs(10),
request_timeout: Duration::from_secs(60),
user_agent: "asx/0.2".to_string(),
pool_max_idle_per_host: 4,
pool_idle_timeout: Duration::from_secs(90),
}
}
}
impl TransportConfig {
pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self
}
pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = timeout;
self
}
pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpSendOutcome {
pub status: u16,
pub headers: HttpHeaders,
pub body: std::sync::Arc<[u8]>,
}
impl HttpSendOutcome {
pub fn is_success(&self) -> bool {
(200..300).contains(&self.status)
}
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
pub fn is_sync_mdn(&self) -> bool {
self.header("Content-Type")
.map(|ct| ct.to_ascii_lowercase().contains("multipart/report"))
.unwrap_or(false)
}
}
#[cfg(any(feature = "as2", feature = "as4"))]
fn reqwest_to_asx(err: reqwest::Error, context: &'static str) -> AsxError {
AsxError::new(
ErrorCode::TransportFailure,
format!("HTTP transport error: {err}"),
ErrorContext::new(context),
)
}
#[cfg(feature = "as2")]
pub struct As2HttpTransport {
client: reqwest::Client,
runtime_config: TransportConfig,
}
#[cfg(feature = "as2")]
impl As2HttpTransport {
pub fn new(config: TransportConfig) -> Result<Self> {
let client = build_http_client(&config, "as2_transport_init", None)?;
Ok(Self {
client,
runtime_config: config,
})
}
pub async fn send(&self, url: &str, output: &As2SendOutput) -> Result<HttpSendOutcome> {
let target = validate_egress_target_with_policy(url, "as2_transport_send").await?;
let mut headers = reqwest::header::HeaderMap::new();
for (name, value) in &output.http_headers {
let key = reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
AsxError::new(
ErrorCode::InvalidInput,
format!("invalid HTTP header name '{name}'"),
ErrorContext::new("as2_transport_send"),
)
})?;
let val = reqwest::header::HeaderValue::from_str(value).map_err(|_| {
AsxError::new(
ErrorCode::InvalidInput,
format!("invalid HTTP header value for '{name}'"),
ErrorContext::new("as2_transport_send"),
)
})?;
headers.insert(key, val);
}
if let Some(traceparent) = &output.traceparent {
let val = reqwest::header::HeaderValue::from_str(traceparent).map_err(|_| {
AsxError::new(
ErrorCode::InvalidInput,
"invalid traceparent header value",
ErrorContext::new("as2_transport_send"),
)
})?;
headers.insert(reqwest::header::HeaderName::from_static("traceparent"), val);
}
let body_bytes = output.mime.body.clone();
let client = if let Some(host) = target.resolved_host.as_deref() {
build_http_client(
&self.runtime_config,
"as2_transport_send",
Some((host, &target.resolved_addrs)),
)?
} else {
self.client.clone()
};
let response = client
.post(target.url.clone())
.headers(headers)
.body(body_bytes.to_vec())
.send()
.await
.map_err(|e| reqwest_to_asx(e, "as2_transport_send"))?;
let status = response.status().as_u16();
let resp_headers: Vec<(String, String)> = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body = response
.bytes()
.await
.map_err(|e| reqwest_to_asx(e, "as2_transport_recv_body"))?
.to_vec()
.into();
Ok(HttpSendOutcome {
status,
headers: HttpHeaders::from_vec(resp_headers),
body,
})
}
pub async fn send_async_mdn(&self, request: &As2AsyncMdnRequest) -> Result<HttpSendOutcome> {
let target = validate_egress_target_with_policy(&request.url, "as2_async_mdn_send").await?;
if request.as2_from.trim().is_empty() {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"As2AsyncMdnRequest.as2_from must not be empty",
ErrorContext::new("as2_async_mdn_send"),
));
}
if request.as2_to.trim().is_empty() {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"As2AsyncMdnRequest.as2_to must not be empty",
ErrorContext::new("as2_async_mdn_send"),
));
}
let stripped = request
.original_message_id
.trim()
.trim_matches(|c| c == '<' || c == '>');
let message_id = format!("<{stripped}>");
let mdn_content_type =
extract_as2_mdn_content_type(&request.mdn_bytes).unwrap_or_else(|| {
"multipart/report; report-type=disposition-notification".to_string()
});
let client = if let Some(host) = target.resolved_host.as_deref() {
build_http_client(
&self.runtime_config,
"as2_async_mdn_send",
Some((host, &target.resolved_addrs)),
)?
} else {
self.client.clone()
};
let response = client
.post(target.url.clone())
.header("AS2-Version", "1.2")
.header("AS2-From", &request.as2_from)
.header("AS2-To", &request.as2_to)
.header("Message-ID", &message_id)
.header("Content-Type", mdn_content_type)
.header("MIME-Version", "1.0")
.body(request.mdn_bytes.to_vec())
.send()
.await
.map_err(|e| reqwest_to_asx(e, "as2_async_mdn_send"))?;
let status = response.status().as_u16();
let resp_headers: HttpHeaders = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body = response
.bytes()
.await
.map_err(|e| reqwest_to_asx(e, "as2_async_mdn_recv_body"))?
.to_vec();
Ok(HttpSendOutcome {
status,
headers: resp_headers,
body: body.into(),
})
}
}
#[cfg(feature = "as2")]
fn extract_as2_mdn_content_type(mdn_bytes: &[u8]) -> Option<String> {
let text = std::str::from_utf8(mdn_bytes).ok()?;
let (headers, _) = text.split_once("\r\n\r\n")?;
headers.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.trim().eq_ignore_ascii_case("Content-Type") {
let ct = value.trim();
if ct.is_empty() {
None
} else {
Some(ct.to_string())
}
} else {
None
}
})
}
#[cfg(feature = "as2")]
#[derive(Debug, Clone)]
pub struct As2AsyncMdnRequest {
pub url: String,
pub original_message_id: String,
pub mdn_bytes: std::sync::Arc<[u8]>,
pub as2_from: String,
pub as2_to: String,
}
#[cfg(feature = "as4")]
pub struct As4HttpTransport {
client: reqwest::Client,
runtime_config: TransportConfig,
}
#[cfg(feature = "as4")]
impl As4HttpTransport {
pub fn new(config: TransportConfig) -> Result<Self> {
let client = build_http_client(&config, "as4_transport_init", None)?;
Ok(Self {
client,
runtime_config: config,
})
}
pub async fn send(&self, url: &str, output: &As4SendOutput) -> Result<HttpSendOutcome> {
let target = validate_egress_target_with_policy(url, "as4_transport_send").await?;
let client = if let Some(host) = target.resolved_host.as_deref() {
build_http_client(
&self.runtime_config,
"as4_transport_send",
Some((host, &target.resolved_addrs)),
)?
} else {
self.client.clone()
};
let mut request = client
.post(target.url.clone())
.header("Content-Type", &output.http_content_type)
.body(output.soap_envelope.body.to_vec());
if let Some(traceparent) = &output.traceparent {
request = request.header("traceparent", traceparent);
}
let response = request
.send()
.await
.map_err(|e| reqwest_to_asx(e, "as4_transport_send"))?;
let status = response.status().as_u16();
let resp_headers: HttpHeaders = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body: Vec<u8> = response
.bytes()
.await
.map_err(|e| reqwest_to_asx(e, "as4_transport_recv_body"))?
.to_vec();
Ok(HttpSendOutcome {
status,
headers: resp_headers,
body: body.into(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
}
#[test]
fn http_send_outcome_is_success_range() {
let make = |status: u16| HttpSendOutcome {
status,
headers: HttpHeaders::new(),
body: vec![].into(),
};
assert!(make(200).is_success());
assert!(make(204).is_success());
assert!(!make(400).is_success());
assert!(!make(500).is_success());
}
#[test]
fn http_send_outcome_header_lookup_is_case_insensitive() {
let outcome = HttpSendOutcome {
status: 200,
headers: HttpHeaders::from_vec(vec![(
"content-type".into(),
"multipart/report; boundary=foo".into(),
)]),
body: vec![].into(),
};
assert_eq!(
outcome.header("Content-Type"),
Some("multipart/report; boundary=foo")
);
assert!(outcome.is_sync_mdn());
}
#[test]
fn http_send_outcome_is_sync_mdn_requires_multipart_report() {
let outcome = HttpSendOutcome {
status: 200,
headers: HttpHeaders::from_vec(vec![(
"Content-Type".into(),
"application/pkcs7-mime".into(),
)]),
body: vec![].into(),
};
assert!(!outcome.is_sync_mdn());
}
#[cfg(feature = "as2")]
#[test]
fn as2_transport_builds_with_default_config() {
As2HttpTransport::new(TransportConfig::default()).expect("should build without error");
}
#[cfg(feature = "as2")]
#[test]
fn extract_as2_mdn_content_type_reads_top_level_header() {
let mdn = b"Content-Type: multipart/report; report-type=disposition-notification; boundary=\"b\"\r\n\
MIME-Version: 1.0\r\n\
\r\n\
--b\r\n\
Content-Type: text/plain\r\n\
\r\n\
ok\r\n\
--b--\r\n";
let ct = extract_as2_mdn_content_type(mdn).expect("content type");
assert!(ct.starts_with("multipart/report;"));
assert!(ct.contains("boundary=\"b\""));
}
#[cfg(feature = "as4")]
#[test]
fn as4_transport_builds_with_default_config() {
As4HttpTransport::new(TransportConfig::default()).expect("should build without error");
}
#[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_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://203.0.113.1/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://203.0.113.1/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://203.0.113.1/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);
}
}