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(feature = "as4")]
use crate::core::SessionContext;
#[cfg(feature = "as4")]
use crate::observability::EventBus;
#[cfg(feature = "async-ocsp")]
pub(crate) async fn validated_pinned_ocsp_client(
url: &str,
config: &TransportConfig,
context: &'static str,
) -> Result<reqwest::Client> {
let target = validate_egress_target(
url,
context,
EgressSchemePolicy::AllowPlaintextForSignedOcsp,
)
.await?;
let pinned = target
.resolved_host
.as_deref()
.map(|host| (host, target.resolved_addrs.as_slice()));
build_ocsp_http_client(config, context, pinned)
}
#[cfg(feature = "async-ocsp")]
fn build_ocsp_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 OCSP HTTP client: {err}"),
ErrorContext::new(context),
)
})
}
#[cfg(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)]
struct ValidatedEgressTarget {
url: reqwest::Url,
resolved_host: Option<String>,
resolved_addrs: Vec<SocketAddr>,
}
#[cfg(feature = "testing")]
fn enforce_loopback_target(url: &str, context: &'static str) -> Result<()> {
let parsed = reqwest::Url::parse(url).map_err(|_| {
AsxError::new(
ErrorCode::InvalidInput,
format!("malformed egress URL: {url}"),
ErrorContext::new(context),
)
})?;
let is_loopback = parsed
.host_str()
.and_then(|host| {
host.trim_matches(['[', ']'])
.parse::<std::net::IpAddr>()
.ok()
})
.is_some_and(|ip| ip.is_loopback());
if !is_loopback {
return Err(AsxError::new(
ErrorCode::PolicyViolation,
format!(
"localhost testing transport refuses non-loopback target {url}; \
use an explicit 127.0.0.1 or [::1] address, or the production \
transport for real endpoints"
),
ErrorContext::new(context),
));
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EgressSchemePolicy {
HttpsOnly,
AllowPlaintextForSignedOcsp,
}
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::AllowPlaintextForSignedOcsp => {}
"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()
.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((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 >= 240 }
fn is_private_ipv6(ip: std::net::Ipv6Addr) -> bool {
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| (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: concat!("asx/", env!("CARGO_PKG_VERSION")).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 {
let mut message = format!("HTTP transport error: {err}");
let mut source = std::error::Error::source(&err);
while let Some(cause) = source {
message.push_str(": ");
message.push_str(&cause.to_string());
source = cause.source();
}
AsxError::new(
ErrorCode::TransportFailure,
message,
ErrorContext::new(context),
)
}
#[cfg(feature = "as2")]
#[derive(Debug)]
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")]
#[derive(Debug)]
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> {
self.send_inner(url, output, true).await
}
#[cfg(feature = "testing")]
pub fn new_for_localhost_testing() -> Result<Self> {
let config = TransportConfig::default();
let client = reqwest::Client::builder()
.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))
.build()
.map_err(|err| {
AsxError::new(
ErrorCode::TransportFailure,
format!("failed to build localhost test HTTP client: {err}"),
ErrorContext::new("as4_transport_localhost_testing_init"),
)
})?;
Ok(Self {
client,
runtime_config: config,
})
}
#[cfg(feature = "testing")]
pub async fn send_to_localhost(
&self,
url: &str,
output: &As4SendOutput,
) -> Result<HttpSendOutcome> {
enforce_loopback_target(url, "as4_transport_send_localhost")?;
self.send_inner(url, output, false).await
}
pub async fn send_and_verify(
&self,
url: &str,
session: &SessionContext,
event_bus: &EventBus,
output: &As4SendOutput,
policy: &crate::as4::As4ReceiptPolicy,
) -> Result<As4SendAndVerifyOutcome> {
let http = self.send_inner(url, output, true).await?;
finish_send_and_verify(session, event_bus, output, policy, http)
}
#[cfg(feature = "testing")]
pub async fn send_and_verify_to_localhost(
&self,
url: &str,
session: &SessionContext,
event_bus: &EventBus,
output: &As4SendOutput,
policy: &crate::as4::As4ReceiptPolicy,
) -> Result<As4SendAndVerifyOutcome> {
enforce_loopback_target(url, "as4_transport_send_verify_localhost")?;
let http = self.send_inner(url, output, false).await?;
finish_send_and_verify(session, event_bus, output, policy, http)
}
async fn send_inner(
&self,
url: &str,
output: &As4SendOutput,
validate_url: bool,
) -> Result<HttpSendOutcome> {
let target = if validate_url {
validate_egress_target_with_policy(url, "as4_transport_send").await?
} else {
let parsed = reqwest::Url::parse(url).map_err(|_| {
AsxError::new(
ErrorCode::InvalidInput,
format!("malformed egress URL: {url}"),
ErrorContext::new("as4_transport_send"),
)
})?;
ValidatedEgressTarget {
url: parsed,
resolved_host: None,
resolved_addrs: vec![],
}
};
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(feature = "as4")]
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct As4SendAndVerifyOutcome {
pub http: HttpSendOutcome,
pub signal: crate::as4::As4SyncSignal,
}
#[cfg(feature = "as4")]
impl As4SendAndVerifyOutcome {
pub fn into_receipt(self) -> Result<crate::as4::As4VerifiedReceipt> {
self.signal.into_receipt()
}
}
#[cfg(feature = "as4")]
fn finish_send_and_verify(
session: &SessionContext,
event_bus: &EventBus,
output: &As4SendOutput,
policy: &crate::as4::As4ReceiptPolicy,
http: HttpSendOutcome,
) -> Result<As4SendAndVerifyOutcome> {
let content_type = http
.header("Content-Type")
.unwrap_or("application/soap+xml")
.to_string();
let signal = match crate::as4::verify_sync_response(
session,
event_bus,
output,
&http.body,
&content_type,
policy,
) {
Ok(signal) => signal,
Err(err) if !http.is_success() => {
return Err(AsxError::new(
ErrorCode::TransportFailure,
format!(
"AS4 counterparty returned HTTP {} with no parseable eb:SignalMessage: {}",
http.status, err.message
),
ErrorContext::for_session_with_message(
"as4_transport_send_and_verify",
session,
&output.message_id,
),
));
}
Err(err) => return Err(err),
};
Ok(As4SendAndVerifyOutcome { http, signal })
}
#[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"));
}
#[cfg(feature = "testing")]
#[test]
fn loopback_guard_accepts_only_loopback_literals() {
for ok in [
"http://127.0.0.1:8080/as4",
"http://127.5.5.5/as4",
"http://[::1]:9090/as4",
] {
enforce_loopback_target(ok, "test").unwrap_or_else(|e| panic!("{ok} must pass: {e}"));
}
for bad in [
"http://example.com/as4",
"https://partner.example/as4",
"http://10.0.0.5/as4",
"http://169.254.169.254/latest/meta-data",
"http://localhost:8080/as4",
] {
let err = enforce_loopback_target(bad, "test")
.expect_err("non-loopback literal must be refused");
assert_eq!(err.code, ErrorCode::PolicyViolation, "for {bad}");
}
}
#[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);
}
}