use std::net::{Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
use std::time::Duration;
use reqwest::dns::{Addrs, Resolve, Resolving};
pub const DENY_HOSTS_DEFAULT: [&str; 4] = [
"cardanowall.com",
"*.cardanowall.com",
"localhost",
"127.0.0.1",
];
pub const DEFAULT_TIMEOUT_MS: u64 = 10_000;
pub const DEFAULT_OUTBOUND_MAX_BYTES: u64 = 64 * 1024 * 1024;
pub const DEFAULT_RETRYABLE_STATUSES: [u16; 3] = [502, 503, 504];
const BACKOFF_BASE_MS: [u64; 3] = [1000, 2000, 4000];
const JITTER_RATIO: f64 = 0.25;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpPurpose {
Cardano,
Arweave,
Ipfs,
Https,
Webhook,
}
impl HttpPurpose {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
HttpPurpose::Cardano => "cardano",
HttpPurpose::Arweave => "arweave",
HttpPurpose::Ipfs => "ipfs",
HttpPurpose::Https => "https",
HttpPurpose::Webhook => "webhook",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Post,
}
impl HttpMethod {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
}
}
}
#[derive(Debug, Clone)]
pub struct FetchOutboundOptions {
pub method: HttpMethod,
pub purpose: HttpPurpose,
pub headers: Vec<(String, String)>,
pub body: Option<String>,
pub max_bytes: Option<u64>,
}
impl FetchOutboundOptions {
#[must_use]
pub fn new(method: HttpMethod, purpose: HttpPurpose) -> Self {
Self {
method,
purpose,
headers: Vec::new(),
body: None,
max_bytes: None,
}
}
}
#[derive(Debug, Clone)]
pub struct FetchOutboundResult {
pub status: u16,
pub bytes: Vec<u8>,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpCallRecord {
pub url: String,
pub method: HttpMethod,
pub status: u16,
pub bytes: u64,
pub duration_ms: u64,
pub purpose: HttpPurpose,
}
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub retries: u32,
pub retryable_statuses: Vec<u16>,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
retries: 0,
retryable_statuses: DEFAULT_RETRYABLE_STATUSES.to_vec(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct WrapFetchOutboundConfig {
pub deny_hosts: Vec<String>,
pub retry: RetryConfig,
}
#[derive(Debug, thiserror::Error)]
pub enum OutboundError {
#[error("SERVICE_INDEPENDENCE_VIOLATION: host \"{host}\" is in denyHosts (url={url})")]
DenyHost {
host: String,
url: String,
},
#[error("UNSUPPORTED_PROTOCOL: \"{protocol}\" not in {{http:, https:}} (url={url})")]
UnsupportedProtocol {
protocol: String,
url: String,
},
#[error("UNSUPPORTED_METHOD: \"{method}\" not in {{GET, POST}} (url={url})")]
UnsupportedMethod {
method: String,
url: String,
},
#[error("OUTBOUND_BODY_TOO_LARGE: response exceeded {limit_bytes} bytes (url={url})")]
BodyTooLarge {
url: String,
limit_bytes: u64,
},
#[error("webhook purpose must be sent via fetch_webhook, not fetch_outbound (url={url})")]
WebhookPurposeRejected {
url: String,
},
#[error("OUTBOUND_EXHAUSTED: {attempts} attempts exhausted (url={url}, lastStatus={})",
last_status.map_or_else(|| "-".to_string(), |s| s.to_string()))]
Exhausted {
url: String,
attempts: u32,
last_status: Option<u16>,
last_error: Option<String>,
},
#[error("OUTBOUND_TRANSPORT: {message} (url={url})")]
Transport {
url: String,
message: String,
},
}
impl OutboundError {
#[must_use]
pub fn code(&self) -> &'static str {
match self {
OutboundError::DenyHost { .. } => "SERVICE_INDEPENDENCE_VIOLATION",
OutboundError::UnsupportedProtocol { .. } => "UNSUPPORTED_PROTOCOL",
OutboundError::UnsupportedMethod { .. } => "UNSUPPORTED_METHOD",
OutboundError::BodyTooLarge { .. } => "OUTBOUND_BODY_TOO_LARGE",
OutboundError::WebhookPurposeRejected { .. } => "WEBHOOK_PURPOSE_REJECTED",
OutboundError::Exhausted { .. } => "OUTBOUND_EXHAUSTED",
OutboundError::Transport { .. } => "OUTBOUND_TRANSPORT",
}
}
fn is_preflight(&self) -> bool {
matches!(
self,
OutboundError::DenyHost { .. }
| OutboundError::UnsupportedProtocol { .. }
| OutboundError::UnsupportedMethod { .. }
| OutboundError::WebhookPurposeRejected { .. }
)
}
}
fn canonicalise_host(host: &str) -> String {
let mut h = host;
h = h.strip_prefix('[').unwrap_or(h);
h = h.strip_suffix(']').unwrap_or(h);
h = h.strip_suffix('.').unwrap_or(h);
h.to_lowercase()
}
fn is_loopback_127(host: &str) -> bool {
let octets: Vec<&str> = host.split('.').collect();
if octets.len() != 4 || octets[0] != "127" {
return false;
}
octets
.iter()
.all(|o| !o.is_empty() && o.len() <= 3 && o.bytes().all(|b| b.is_ascii_digit()))
}
#[must_use]
pub fn matches_deny_list<S: AsRef<str>>(host: &str, deny_hosts: &[S]) -> bool {
let h = canonicalise_host(host);
for raw in deny_hosts {
let pattern = raw.as_ref().trim_end_matches('.').to_lowercase();
if let Some(suffix) = pattern.strip_prefix("*.") {
if h.ends_with(&format!(".{suffix}")) {
return true;
}
continue;
}
if h == pattern {
return true;
}
if pattern == "localhost" && (h == "::1" || h == "0.0.0.0" || h == "169.254.169.254") {
return true;
}
if pattern == "127.0.0.1" && is_loopback_127(&h) {
return true;
}
}
false
}
fn parse_protocol(url: &str) -> Option<String> {
let parsed = reqwest::Url::parse(url).ok()?;
Some(format!("{}:", parsed.scheme().to_lowercase()))
}
fn parse_hostname(url: &str) -> Option<String> {
let parsed = reqwest::Url::parse(url).ok()?;
parsed.host_str().map(str::to_string)
}
fn is_allowed_protocol(url: &str) -> bool {
matches!(
parse_protocol(url).as_deref(),
Some("http:") | Some("https:")
)
}
pub trait Clock: Send + Sync {
fn sleep(&self, duration: Duration);
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ThreadSleepClock;
impl Clock for ThreadSleepClock {
fn sleep(&self, duration: Duration) {
std::thread::sleep(duration);
}
}
pub trait Jitter: Send + Sync {
fn multiplier(&self, attempt_index: usize) -> f64;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RandomJitter;
impl Jitter for RandomJitter {
fn multiplier(&self, _attempt_index: usize) -> f64 {
let mut buf = [0u8; 8];
let rand = match getrandom::getrandom(&mut buf) {
Ok(()) => (u64::from_le_bytes(buf) as f64) / (u64::MAX as f64),
Err(_) => 0.5,
};
1.0 + (rand - 0.5) * 2.0 * JITTER_RATIO
}
}
fn backoff_jittered_ms(attempt_index: usize, jitter: &dyn Jitter) -> f64 {
let idx = attempt_index.min(BACKOFF_BASE_MS.len() - 1);
let base = BACKOFF_BASE_MS[idx] as f64;
base * jitter.multiplier(attempt_index)
}
pub trait FetchTransport: Send + Sync {
fn fetch(
&self,
url: &str,
opts: &FetchOutboundOptions,
) -> Result<FetchOutboundResult, OutboundError>;
}
#[allow(clippy::too_many_arguments)]
pub fn wrap_fetch_outbound(
transport: &dyn FetchTransport,
audit: &mut Vec<HttpCallRecord>,
config: &WrapFetchOutboundConfig,
clock: &dyn Clock,
jitter: &dyn Jitter,
url: &str,
opts: &FetchOutboundOptions,
) -> Result<FetchOutboundResult, OutboundError> {
if opts.purpose == HttpPurpose::Webhook {
audit.push(preflight_row(url, HttpPurpose::Webhook));
return Err(OutboundError::WebhookPurposeRejected {
url: url.to_string(),
});
}
if !is_allowed_protocol(url) {
audit.push(preflight_row(url, opts.purpose));
let protocol = parse_protocol(url).unwrap_or_default();
return Err(OutboundError::UnsupportedProtocol {
protocol,
url: url.to_string(),
});
}
if !config.deny_hosts.is_empty() {
let host = parse_hostname(url).unwrap_or_default();
if matches_deny_list(&host, &config.deny_hosts) {
audit.push(HttpCallRecord {
url: url.to_string(),
method: opts.method,
status: 0,
bytes: 0,
duration_ms: 0,
purpose: opts.purpose,
});
return Err(OutboundError::DenyHost {
host: canonicalise_host(&host),
url: url.to_string(),
});
}
}
let retries = config.retry.retries;
let total_attempts = retries + 1;
let mut last_status: Option<u16> = None;
let mut last_error: Option<OutboundError> = None;
for attempt in 1..=total_attempts {
match transport.fetch(url, opts) {
Ok(result) => {
audit.push(HttpCallRecord {
url: url.to_string(),
method: opts.method,
status: result.status,
bytes: result.bytes.len() as u64,
duration_ms: result.duration_ms,
purpose: opts.purpose,
});
if config.retry.retryable_statuses.contains(&result.status) && retries > 0 {
last_status = Some(result.status);
if attempt < total_attempts {
sleep_backoff(clock, jitter, attempt);
continue;
}
break;
}
return Ok(result);
}
Err(e) if e.is_preflight() => {
audit.push(preflight_row(url, opts.purpose));
return Err(e);
}
Err(e) => {
audit.push(HttpCallRecord {
url: url.to_string(),
method: opts.method,
status: 0,
bytes: 0,
duration_ms: 0,
purpose: opts.purpose,
});
last_error = Some(e);
if attempt < total_attempts {
sleep_backoff(clock, jitter, attempt);
continue;
}
break;
}
}
}
if retries == 0 {
if let Some(e) = last_error {
return Err(e);
}
}
Err(OutboundError::Exhausted {
url: url.to_string(),
attempts: total_attempts,
last_status,
last_error: last_error.map(|e| e.to_string()),
})
}
fn preflight_row(url: &str, purpose: HttpPurpose) -> HttpCallRecord {
HttpCallRecord {
url: url.to_string(),
method: HttpMethod::Get,
status: 0,
bytes: 0,
duration_ms: 0,
purpose,
}
}
fn sleep_backoff(clock: &dyn Clock, jitter: &dyn Jitter, attempt: u32) {
let ms = backoff_jittered_ms((attempt - 1) as usize, jitter);
clock.sleep(Duration::from_secs_f64(ms.max(0.0) / 1000.0));
}
pub fn parse_http_method(method: &str, url: &str) -> Result<HttpMethod, OutboundError> {
match method {
"GET" => Ok(HttpMethod::Get),
"POST" => Ok(HttpMethod::Post),
other => Err(OutboundError::UnsupportedMethod {
method: other.to_string(),
url: url.to_string(),
}),
}
}
struct PinnedResolver {
host: String,
addr: std::net::IpAddr,
}
impl Resolve for PinnedResolver {
fn resolve(&self, name: reqwest::dns::Name) -> Resolving {
let want = self.host.to_lowercase();
let got = name.as_str().to_lowercase();
let addr = self.addr;
Box::pin(async move {
if got == want {
let socket = std::net::SocketAddr::new(addr, 0);
let iter: Addrs = Box::new(std::iter::once(socket));
Ok(iter)
} else {
Err(format!("PinnedResolver: refused to resolve unexpected host {got}").into())
}
})
}
}
#[derive(Default)]
pub struct ReqwestTransport {
pinned: Option<(String, std::net::IpAddr)>,
}
impl ReqwestTransport {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn pinned(hostname: impl Into<String>, addr: std::net::IpAddr) -> Self {
Self {
pinned: Some((hostname.into(), addr)),
}
}
fn build_client(&self) -> Result<reqwest::blocking::Client, OutboundError> {
let mut builder = reqwest::blocking::Client::builder()
.timeout(Duration::from_millis(DEFAULT_TIMEOUT_MS))
.redirect(reqwest::redirect::Policy::none());
if let Some((host, addr)) = &self.pinned {
builder = builder.dns_resolver(Arc::new(PinnedResolver {
host: host.clone(),
addr: *addr,
}));
}
builder.build().map_err(|e| OutboundError::Transport {
url: String::new(),
message: e.to_string(),
})
}
}
impl FetchTransport for ReqwestTransport {
fn fetch(
&self,
url: &str,
opts: &FetchOutboundOptions,
) -> Result<FetchOutboundResult, OutboundError> {
let started = std::time::Instant::now();
let max_bytes = opts.max_bytes.unwrap_or(DEFAULT_OUTBOUND_MAX_BYTES);
let client = self.build_client()?;
let method = match opts.method {
HttpMethod::Get => reqwest::Method::GET,
HttpMethod::Post => reqwest::Method::POST,
};
let mut req = client.request(method, url);
for (k, v) in &opts.headers {
req = req.header(k.as_str(), v.as_str());
}
if let Some(body) = &opts.body {
req = req.body(body.clone());
}
let mut resp = req.send().map_err(|e| OutboundError::Transport {
url: url.to_string(),
message: e.to_string(),
})?;
let status = resp.status().as_u16();
if let Some(len) = resp.content_length() {
if len > max_bytes {
return Err(OutboundError::BodyTooLarge {
url: url.to_string(),
limit_bytes: max_bytes,
});
}
}
let bytes = read_body_capped(&mut resp, url, max_bytes)?;
Ok(FetchOutboundResult {
status,
bytes,
duration_ms: started.elapsed().as_millis() as u64,
})
}
}
fn read_body_capped(
resp: &mut reqwest::blocking::Response,
url: &str,
max_bytes: u64,
) -> Result<Vec<u8>, OutboundError> {
use std::io::Read;
let mut out: Vec<u8> = Vec::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = resp.read(&mut buf).map_err(|e| OutboundError::Transport {
url: url.to_string(),
message: e.to_string(),
})?;
if n == 0 {
break;
}
if out.len() as u64 + n as u64 > max_bytes {
return Err(OutboundError::BodyTooLarge {
url: url.to_string(),
limit_bytes: max_bytes,
});
}
out.extend_from_slice(&buf[..n]);
}
Ok(out)
}
pub fn fetch_outbound(
url: &str,
opts: &FetchOutboundOptions,
audit: &mut Vec<HttpCallRecord>,
config: &WrapFetchOutboundConfig,
) -> Result<FetchOutboundResult, OutboundError> {
let transport = ReqwestTransport::new();
wrap_fetch_outbound(
&transport,
audit,
config,
&ThreadSleepClock,
&RandomJitter,
url,
opts,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebhookUrlUnsafeReason {
InvalidUrl,
UnsupportedProtocol,
DnsResolutionFailed,
BlockedIpRange,
NoDnsRecords,
}
impl WebhookUrlUnsafeReason {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
WebhookUrlUnsafeReason::InvalidUrl => "invalid-url",
WebhookUrlUnsafeReason::UnsupportedProtocol => "unsupported-protocol",
WebhookUrlUnsafeReason::DnsResolutionFailed => "dns-resolution-failed",
WebhookUrlUnsafeReason::BlockedIpRange => "blocked-ip-range",
WebhookUrlUnsafeReason::NoDnsRecords => "no-dns-records",
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("WEBHOOK_URL_UNSAFE: {reason} (url={url}, hostname={hostname}{})",
resolved_ip.as_ref().map_or_else(String::new, |ip| format!(", ip={ip}")))]
pub struct WebhookUrlUnsafeError {
pub reason: WebhookUrlUnsafeReason,
pub url: String,
pub hostname: String,
pub resolved_ip: Option<String>,
}
impl std::fmt::Display for WebhookUrlUnsafeReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl WebhookUrlUnsafeError {
#[must_use]
pub const fn code(&self) -> &'static str {
"WEBHOOK_URL_UNSAFE"
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedRecord {
pub address: std::net::IpAddr,
pub family: u8,
}
pub trait ResolveHost: Send + Sync {
fn resolve(&self, hostname: &str) -> Result<Vec<ResolvedRecord>, String>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemResolver;
impl ResolveHost for SystemResolver {
fn resolve(&self, hostname: &str) -> Result<Vec<ResolvedRecord>, String> {
use std::net::ToSocketAddrs;
let addrs = (hostname, 0u16)
.to_socket_addrs()
.map_err(|e| e.to_string())?;
Ok(addrs
.map(|sa| {
let ip = sa.ip();
let family = if ip.is_ipv4() { 4 } else { 6 };
ResolvedRecord {
address: ip,
family,
}
})
.collect())
}
}
#[derive(Default)]
pub struct AssertWebhookUrlSafeOptions<'a> {
pub allow_private_for_tests: bool,
pub resolve_host: Option<&'a dyn ResolveHost>,
}
#[derive(Debug, Clone)]
pub struct AssertWebhookUrlSafeResult {
pub resolved_ip: std::net::IpAddr,
pub family: u8,
pub hostname: String,
}
fn looks_like_ip_literal(host: &str) -> bool {
host.parse::<Ipv4Addr>().is_ok() || host.contains(':')
}
pub fn assert_webhook_url_safe(
url: &str,
opts: &AssertWebhookUrlSafeOptions<'_>,
) -> Result<AssertWebhookUrlSafeResult, WebhookUrlUnsafeError> {
let allow_private = opts.allow_private_for_tests;
let parsed = reqwest::Url::parse(url).map_err(|_| WebhookUrlUnsafeError {
reason: WebhookUrlUnsafeReason::InvalidUrl,
url: url.to_string(),
hostname: String::new(),
resolved_ip: None,
})?;
let scheme = parsed.scheme();
if scheme != "https" && !(allow_private && scheme == "http") {
return Err(WebhookUrlUnsafeError {
reason: WebhookUrlUnsafeReason::UnsupportedProtocol,
url: url.to_string(),
hostname: parsed.host_str().unwrap_or("").to_string(),
resolved_ip: None,
});
}
let raw_host = parsed.host_str().ok_or_else(|| WebhookUrlUnsafeError {
reason: WebhookUrlUnsafeReason::InvalidUrl,
url: url.to_string(),
hostname: String::new(),
resolved_ip: None,
})?;
let hostname = canonicalise_host(raw_host);
let records: Vec<ResolvedRecord> = if looks_like_ip_literal(&hostname) {
let address: std::net::IpAddr = hostname.parse().map_err(|_| WebhookUrlUnsafeError {
reason: WebhookUrlUnsafeReason::DnsResolutionFailed,
url: url.to_string(),
hostname: hostname.clone(),
resolved_ip: None,
})?;
let family = if address.is_ipv4() { 4 } else { 6 };
vec![ResolvedRecord { address, family }]
} else {
let resolver: &dyn ResolveHost = opts.resolve_host.unwrap_or(&SystemResolver);
resolver
.resolve(&hostname)
.map_err(|_| WebhookUrlUnsafeError {
reason: WebhookUrlUnsafeReason::DnsResolutionFailed,
url: url.to_string(),
hostname: hostname.clone(),
resolved_ip: None,
})?
};
if records.is_empty() {
return Err(WebhookUrlUnsafeError {
reason: WebhookUrlUnsafeReason::NoDnsRecords,
url: url.to_string(),
hostname,
resolved_ip: None,
});
}
for rec in &records {
if !allow_private && is_blocked_ip(rec.address) {
return Err(WebhookUrlUnsafeError {
reason: WebhookUrlUnsafeReason::BlockedIpRange,
url: url.to_string(),
hostname,
resolved_ip: Some(rec.address.to_string()),
});
}
}
let first = records[0];
Ok(AssertWebhookUrlSafeResult {
resolved_ip: first.address,
family: first.family,
hostname,
})
}
#[derive(Debug, Clone, Copy)]
pub struct BlockedRange {
pub cidr: &'static str,
pub reason: &'static str,
}
pub const BLOCKED_IPV4_RANGES: [BlockedRange; 15] = [
BlockedRange {
cidr: "0.0.0.0/8",
reason: "current network / \"this host\"",
},
BlockedRange {
cidr: "10.0.0.0/8",
reason: "RFC 1918 private",
},
BlockedRange {
cidr: "100.64.0.0/10",
reason: "CGNAT (RFC 6598)",
},
BlockedRange {
cidr: "127.0.0.0/8",
reason: "loopback",
},
BlockedRange {
cidr: "169.254.0.0/16",
reason: "link-local (covers AWS/GCE/Azure metadata 169.254.169.254)",
},
BlockedRange {
cidr: "172.16.0.0/12",
reason: "RFC 1918 private",
},
BlockedRange {
cidr: "192.0.0.0/24",
reason: "IETF assignment",
},
BlockedRange {
cidr: "192.0.2.0/24",
reason: "TEST-NET-1 (RFC 5737)",
},
BlockedRange {
cidr: "192.168.0.0/16",
reason: "RFC 1918 private",
},
BlockedRange {
cidr: "198.18.0.0/15",
reason: "benchmarking",
},
BlockedRange {
cidr: "198.51.100.0/24",
reason: "TEST-NET-2 (RFC 5737)",
},
BlockedRange {
cidr: "203.0.113.0/24",
reason: "TEST-NET-3 (RFC 5737)",
},
BlockedRange {
cidr: "224.0.0.0/4",
reason: "multicast",
},
BlockedRange {
cidr: "240.0.0.0/4",
reason: "reserved / future use",
},
BlockedRange {
cidr: "255.255.255.255/32",
reason: "broadcast",
},
];
pub const BLOCKED_IPV6_RANGES: [BlockedRange; 12] = [
BlockedRange {
cidr: "::/128",
reason: "unspecified",
},
BlockedRange {
cidr: "::1/128",
reason: "loopback",
},
BlockedRange {
cidr: "::ffff:0:0/96",
reason: "IPv4-mapped IPv6",
},
BlockedRange {
cidr: "64:ff9b::/96",
reason: "IPv4/IPv6 translation",
},
BlockedRange {
cidr: "100::/64",
reason: "discard prefix",
},
BlockedRange {
cidr: "2001:db8::/32",
reason: "documentation",
},
BlockedRange {
cidr: "fc00::/7",
reason: "unique-local (ULA)",
},
BlockedRange {
cidr: "fe80::/10",
reason: "link-local",
},
BlockedRange {
cidr: "ff00::/8",
reason: "multicast",
},
BlockedRange {
cidr: "fd00:ec2::/32",
reason: "AWS IMDS v6",
},
BlockedRange {
cidr: "2001:0:0:1::/64",
reason: "Teredo",
},
BlockedRange {
cidr: "2002::/16",
reason: "6to4",
},
];
#[must_use]
pub fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => is_blocked_ipv4(v4),
std::net::IpAddr::V6(v6) => is_blocked_ipv6(v6),
}
}
#[must_use]
pub fn is_blocked_ip_str(ip: &str) -> bool {
let normalised = canonicalise_host(ip);
match normalised.parse::<std::net::IpAddr>() {
Ok(addr) => is_blocked_ip(addr),
Err(_) => true,
}
}
fn is_blocked_ipv4(addr: Ipv4Addr) -> bool {
let num = u32::from(addr);
BLOCKED_IPV4_RANGES
.iter()
.any(|r| ipv4_in_cidr(num, r.cidr))
}
fn is_blocked_ipv6(addr: Ipv6Addr) -> bool {
let octets = addr.octets();
if octets[..10].iter().all(|&b| b == 0) && octets[10] == 0xff && octets[11] == 0xff {
let embedded = Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]);
if is_blocked_ipv4(embedded) {
return true;
}
return true;
}
BLOCKED_IPV6_RANGES
.iter()
.any(|r| ipv6_in_cidr(&octets, r.cidr))
}
fn ipv4_in_cidr(ip_num: u32, cidr: &str) -> bool {
let Some((base, bits_str)) = cidr.split_once('/') else {
return false;
};
let Ok(bits) = bits_str.parse::<u32>() else {
return false;
};
let Ok(base_addr) = base.parse::<Ipv4Addr>() else {
return false;
};
let base_num = u32::from(base_addr);
if bits == 0 {
return true;
}
if bits >= 32 {
return ip_num == base_num;
}
let mask = u32::MAX << (32 - bits);
(ip_num & mask) == (base_num & mask)
}
fn ipv6_in_cidr(ip_bytes: &[u8; 16], cidr: &str) -> bool {
let Some((base, bits_str)) = cidr.split_once('/') else {
return false;
};
let Ok(bits) = bits_str.parse::<usize>() else {
return false;
};
let Ok(base_addr) = base.parse::<Ipv6Addr>() else {
return false;
};
let base_bytes = base_addr.octets();
let full_bytes = bits / 8;
let rem_bits = bits % 8;
for i in 0..full_bytes {
if ip_bytes[i] != base_bytes[i] {
return false;
}
}
if rem_bits == 0 {
return true;
}
let mask = 0xffu8 << (8 - rem_bits);
(ip_bytes[full_bytes] & mask) == (base_bytes[full_bytes] & mask)
}
#[cfg(test)]
mod tests {
use super::*;
struct StubTransport {
responses: std::sync::Mutex<Vec<Result<FetchOutboundResult, OutboundError>>>,
calls: std::sync::Mutex<usize>,
}
impl StubTransport {
fn ok_once(status: u16, body: Vec<u8>) -> Self {
Self {
responses: std::sync::Mutex::new(vec![Ok(FetchOutboundResult {
status,
bytes: body,
duration_ms: 1,
})]),
calls: std::sync::Mutex::new(0),
}
}
fn from(responses: Vec<Result<FetchOutboundResult, OutboundError>>) -> Self {
Self {
responses: std::sync::Mutex::new(responses),
calls: std::sync::Mutex::new(0),
}
}
fn call_count(&self) -> usize {
*self.calls.lock().unwrap()
}
}
impl FetchTransport for StubTransport {
fn fetch(
&self,
_url: &str,
_opts: &FetchOutboundOptions,
) -> Result<FetchOutboundResult, OutboundError> {
let mut calls = self.calls.lock().unwrap();
let idx = *calls;
*calls += 1;
let responses = self.responses.lock().unwrap();
let pick = if idx < responses.len() {
idx
} else {
responses.len() - 1
};
match &responses[pick] {
Ok(r) => Ok(r.clone()),
Err(e) => Err(clone_err(e)),
}
}
}
fn clone_err(e: &OutboundError) -> OutboundError {
OutboundError::Transport {
url: String::new(),
message: e.to_string(),
}
}
struct RecordingClock {
slept: std::sync::Mutex<Vec<Duration>>,
}
impl RecordingClock {
fn new() -> Self {
Self {
slept: std::sync::Mutex::new(Vec::new()),
}
}
fn millis(&self) -> Vec<f64> {
self.slept
.lock()
.unwrap()
.iter()
.map(|d| d.as_secs_f64() * 1000.0)
.collect()
}
}
impl Clock for RecordingClock {
fn sleep(&self, duration: Duration) {
self.slept.lock().unwrap().push(duration);
}
}
struct FixedJitter(f64);
impl Jitter for FixedJitter {
fn multiplier(&self, _attempt_index: usize) -> f64 {
self.0
}
}
fn cfg(deny: &[&str], retries: u32) -> WrapFetchOutboundConfig {
WrapFetchOutboundConfig {
deny_hosts: deny.iter().map(|s| s.to_string()).collect(),
retry: RetryConfig {
retries,
..RetryConfig::default()
},
}
}
fn run(
transport: &dyn FetchTransport,
audit: &mut Vec<HttpCallRecord>,
config: &WrapFetchOutboundConfig,
url: &str,
opts: &FetchOutboundOptions,
) -> Result<FetchOutboundResult, OutboundError> {
wrap_fetch_outbound(
transport,
audit,
config,
&ThreadSleepClock,
&FixedJitter(1.0),
url,
opts,
)
}
#[test]
fn deny_exact_and_negative() {
assert!(matches_deny_list("cardanowall.com", &["cardanowall.com"]));
assert!(!matches_deny_list("other.com", &["cardanowall.com"]));
}
#[test]
fn deny_wildcard_subdomain_but_not_bare() {
assert!(matches_deny_list(
"api.cardanowall.com",
&["*.cardanowall.com"]
));
assert!(matches_deny_list(
"nested.api.cardanowall.com",
&["*.cardanowall.com"]
));
assert!(!matches_deny_list(
"cardanowall.com",
&["*.cardanowall.com"]
));
}
#[test]
fn deny_case_and_trailing_dot() {
assert!(matches_deny_list("CardanoWall.com.", &["cardanowall.com"]));
assert!(matches_deny_list("CARDANOWALL.COM.", &["cardanowall.com"]));
}
#[test]
fn deny_localhost_aliases() {
assert!(matches_deny_list("[::1]", &["localhost"]));
assert!(matches_deny_list("::1", &["localhost"]));
assert!(matches_deny_list("0.0.0.0", &["localhost"]));
assert!(matches_deny_list("169.254.169.254", &["localhost"]));
}
#[test]
fn deny_127_slash8() {
assert!(matches_deny_list("127.1.2.3", &["127.0.0.1"]));
assert!(matches_deny_list("127.0.0.99", &["127.0.0.1"]));
assert!(matches_deny_list("127.99.0.5", &["127.0.0.1"]));
}
#[test]
fn deny_public_control_and_empty_list() {
assert!(!matches_deny_list("8.8.8.8", &["localhost", "127.0.0.1"]));
assert!(!matches_deny_list("cardanowall.com", &[] as &[&str]));
assert!(!matches_deny_list("127.0.0.1", &[] as &[&str]));
}
#[test]
fn deny_hosts_default_constant() {
assert_eq!(
DENY_HOSTS_DEFAULT,
[
"cardanowall.com",
"*.cardanowall.com",
"localhost",
"127.0.0.1"
]
);
}
#[test]
fn wrap_deny_records_row_and_does_not_call_inner() {
let transport = StubTransport::ok_once(200, vec![]);
let mut audit = Vec::new();
let err = run(
&transport,
&mut audit,
&cfg(&["cardanowall.com"], 0),
"https://cardanowall.com/x",
&FetchOutboundOptions::new(HttpMethod::Get, HttpPurpose::Https),
)
.unwrap_err();
assert_eq!(err.code(), "SERVICE_INDEPENDENCE_VIOLATION");
match err {
OutboundError::DenyHost { host, url } => {
assert_eq!(host, "cardanowall.com");
assert_eq!(url, "https://cardanowall.com/x");
}
other => panic!("expected DenyHost, got {other:?}"),
}
assert_eq!(transport.call_count(), 0);
assert_eq!(audit.len(), 1);
assert_eq!(audit[0].status, 0);
assert_eq!(audit[0].duration_ms, 0);
assert_eq!(audit[0].purpose, HttpPurpose::Https);
}
#[test]
fn wrap_rejects_non_http_protocols() {
for (url, proto) in [
("data:text/plain;base64,SGVsbG8=", "data:"),
("file:///etc/passwd", "file:"),
("ws://example.com/", "ws:"),
] {
let transport = StubTransport::ok_once(200, vec![]);
let mut audit = Vec::new();
let err = run(
&transport,
&mut audit,
&cfg(&[], 0),
url,
&FetchOutboundOptions::new(HttpMethod::Get, HttpPurpose::Https),
)
.unwrap_err();
assert_eq!(err.code(), "UNSUPPORTED_PROTOCOL");
match err {
OutboundError::UnsupportedProtocol { protocol, .. } => assert_eq!(protocol, proto),
other => panic!("expected UnsupportedProtocol, got {other:?}"),
}
assert_eq!(transport.call_count(), 0);
assert_eq!(audit.len(), 1);
assert_eq!(audit[0].method, HttpMethod::Get);
assert_eq!(audit[0].status, 0);
}
}
#[test]
fn parse_method_rejects_non_get_post() {
for m in ["PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] {
let err = parse_http_method(m, "https://example.com/x").unwrap_err();
assert_eq!(err.code(), "UNSUPPORTED_METHOD");
match err {
OutboundError::UnsupportedMethod { method, .. } => assert_eq!(method, m),
other => panic!("expected UnsupportedMethod, got {other:?}"),
}
}
assert_eq!(
parse_http_method("GET", "https://x/").unwrap(),
HttpMethod::Get
);
assert_eq!(
parse_http_method("POST", "https://x/").unwrap(),
HttpMethod::Post
);
}
#[test]
fn wrap_rejects_webhook_purpose() {
let transport = StubTransport::ok_once(200, vec![]);
let mut audit = Vec::new();
let err = run(
&transport,
&mut audit,
&cfg(&[], 0),
"https://example.com/",
&FetchOutboundOptions::new(HttpMethod::Post, HttpPurpose::Webhook),
)
.unwrap_err();
assert!(matches!(err, OutboundError::WebhookPurposeRejected { .. }));
assert_eq!(audit.len(), 1);
assert_eq!(audit[0].purpose, HttpPurpose::Webhook);
assert_eq!(audit[0].status, 0);
}
#[test]
fn wrap_success_records_one_row() {
let transport = StubTransport::ok_once(200, vec![1, 2, 3, 4]);
let mut audit = Vec::new();
let r = run(
&transport,
&mut audit,
&cfg(&[], 0),
"https://example.com/x",
&FetchOutboundOptions::new(HttpMethod::Get, HttpPurpose::Arweave),
)
.unwrap();
assert_eq!(r.status, 200);
assert_eq!(audit.len(), 1);
assert_eq!(audit[0].bytes, 4);
assert_eq!(audit[0].purpose, HttpPurpose::Arweave);
assert_eq!(audit[0].method, HttpMethod::Get);
}
#[test]
fn wrap_errored_fetch_records_status_zero_then_rethrows() {
let transport = StubTransport::from(vec![Err(OutboundError::Transport {
url: "https://example.com/x".into(),
message: "boom".into(),
})]);
let mut audit = Vec::new();
let err = run(
&transport,
&mut audit,
&cfg(&[], 0),
"https://example.com/x",
&FetchOutboundOptions::new(HttpMethod::Get, HttpPurpose::Cardano),
)
.unwrap_err();
assert!(matches!(err, OutboundError::Transport { .. }));
assert_eq!(audit.len(), 1);
assert_eq!(audit[0].status, 0);
assert_eq!(audit[0].bytes, 0);
}
#[test]
fn retries_zero_single_attempt_returns_503() {
let transport = StubTransport::ok_once(503, vec![]);
let mut audit = Vec::new();
let r = run(
&transport,
&mut audit,
&cfg(&[], 0),
"https://example.com/",
&FetchOutboundOptions::new(HttpMethod::Get, HttpPurpose::Https),
)
.unwrap();
assert_eq!(r.status, 503);
assert_eq!(transport.call_count(), 1);
assert_eq!(audit.len(), 1);
}
#[test]
fn retry_503_then_200_records_two_rows() {
let transport = StubTransport::from(vec![
Ok(FetchOutboundResult {
status: 503,
bytes: vec![],
duration_ms: 1,
}),
Ok(FetchOutboundResult {
status: 200,
bytes: vec![1],
duration_ms: 1,
}),
]);
let mut audit = Vec::new();
let clock = RecordingClock::new();
let r = wrap_fetch_outbound(
&transport,
&mut audit,
&cfg(&[], 3),
&clock,
&FixedJitter(1.0),
"https://example.com/",
&FetchOutboundOptions::new(HttpMethod::Get, HttpPurpose::Https),
)
.unwrap();
assert_eq!(r.status, 200);
assert_eq!(
audit.iter().map(|a| a.status).collect::<Vec<_>>(),
vec![503, 200]
);
assert_eq!(clock.millis(), vec![1000.0]);
}
#[test]
fn retry_exhausted_wraps_in_exhausted_error() {
let transport = StubTransport::from(vec![Ok(FetchOutboundResult {
status: 503,
bytes: vec![],
duration_ms: 1,
})]);
let mut audit = Vec::new();
let clock = RecordingClock::new();
let err = wrap_fetch_outbound(
&transport,
&mut audit,
&cfg(&[], 3),
&clock,
&FixedJitter(1.0),
"https://example.com/",
&FetchOutboundOptions::new(HttpMethod::Get, HttpPurpose::Https),
)
.unwrap_err();
match err {
OutboundError::Exhausted {
attempts,
last_status,
..
} => {
assert_eq!(attempts, 4);
assert_eq!(last_status, Some(503));
}
other => panic!("expected Exhausted, got {other:?}"),
}
assert_eq!(audit.len(), 4);
assert_eq!(clock.millis(), vec![1000.0, 2000.0, 4000.0]);
}
#[test]
fn backoff_jitter_band_is_bounded() {
let j = RandomJitter;
for _ in 0..200 {
let ms = backoff_jittered_ms(0, &j);
assert!((750.0..=1250.0).contains(&ms), "out of band: {ms}");
}
}
#[test]
fn retryable_statuses_empty_disables_status_retry() {
let transport = StubTransport::ok_once(503, vec![]);
let mut audit = Vec::new();
let config = WrapFetchOutboundConfig {
deny_hosts: vec![],
retry: RetryConfig {
retries: 3,
retryable_statuses: vec![],
},
};
let r = run(
&transport,
&mut audit,
&config,
"https://example.com/",
&FetchOutboundOptions::new(HttpMethod::Get, HttpPurpose::Https),
)
.unwrap();
assert_eq!(r.status, 503);
assert_eq!(transport.call_count(), 1);
assert_eq!(audit.len(), 1);
}
fn ip(s: &str) -> std::net::IpAddr {
s.parse().unwrap()
}
#[test]
fn ipv4_ranges_all_blocked() {
for s in [
"0.0.0.1",
"10.0.0.1",
"100.64.0.1",
"127.0.0.1",
"169.254.169.254",
"172.16.0.1",
"192.0.0.1",
"192.0.2.1",
"192.168.1.1",
"198.18.0.1",
"198.51.100.1",
"203.0.113.1",
"224.0.0.1",
"240.0.0.1",
"255.255.255.255",
] {
assert!(is_blocked_ip(ip(s)), "expected {s} blocked");
}
}
#[test]
fn ipv4_public_not_blocked() {
for s in ["8.8.8.8", "1.1.1.1", "9.9.9.9", "192.0.1.1"] {
assert!(!is_blocked_ip(ip(s)), "expected {s} allowed");
}
}
#[test]
fn ipv6_ranges_all_blocked() {
for s in [
"::",
"::1",
"64:ff9b::1",
"100::1",
"2001:db8::1",
"fd12:3456:789a:1::1",
"fe80::1",
"ff02::1",
"fd00:ec2::1",
"2001:0:0:1::1",
"2002::1",
] {
assert!(is_blocked_ip(ip(s)), "expected {s} blocked");
}
}
#[test]
fn ipv6_public_not_blocked() {
for s in ["2606:4700:4700::1111", "2001:4860:4860::8888"] {
assert!(!is_blocked_ip(ip(s)), "expected {s} allowed");
}
}
#[test]
fn ipv4_mapped_ipv6_peeled_and_blocked() {
assert!(is_blocked_ip_str("::ffff:127.0.0.1"));
assert!(is_blocked_ip_str("::ffff:10.0.0.1"));
assert!(is_blocked_ip_str("::ffff:169.254.169.254"));
assert!(is_blocked_ip_str("::ffff:8.8.8.8"));
assert!(is_blocked_ip_str("::ffff:0a00:0001"));
}
#[test]
fn malformed_ip_strings_fail_closed() {
assert!(is_blocked_ip_str(""));
assert!(is_blocked_ip_str("not-an-ip"));
assert!(is_blocked_ip_str("999.0.0.1"));
}
#[test]
fn range_constants_have_expected_counts() {
assert_eq!(BLOCKED_IPV4_RANGES.len(), 15);
assert_eq!(BLOCKED_IPV6_RANGES.len(), 12);
}
struct StubResolver(Vec<ResolvedRecord>);
impl ResolveHost for StubResolver {
fn resolve(&self, _hostname: &str) -> Result<Vec<ResolvedRecord>, String> {
Ok(self.0.clone())
}
}
struct FailingResolver;
impl ResolveHost for FailingResolver {
fn resolve(&self, _hostname: &str) -> Result<Vec<ResolvedRecord>, String> {
Err("ENOTFOUND".into())
}
}
fn rec(s: &str) -> ResolvedRecord {
let address = ip(s);
ResolvedRecord {
address,
family: if address.is_ipv4() { 4 } else { 6 },
}
}
fn with_resolver<'a>(r: &'a dyn ResolveHost) -> AssertWebhookUrlSafeOptions<'a> {
AssertWebhookUrlSafeOptions {
allow_private_for_tests: false,
resolve_host: Some(r),
}
}
#[test]
fn webhook_https_public_ip_allowed() {
let resolver = StubResolver(vec![rec("93.184.216.34")]);
let r =
assert_webhook_url_safe("https://example.com/hook", &with_resolver(&resolver)).unwrap();
assert_eq!(r.resolved_ip, ip("93.184.216.34"));
assert_eq!(r.family, 4);
assert_eq!(r.hostname, "example.com");
}
#[test]
fn webhook_http_rejected_by_default() {
let resolver = StubResolver(vec![rec("93.184.216.34")]);
let err = assert_webhook_url_safe("http://example.com/hook", &with_resolver(&resolver))
.unwrap_err();
assert_eq!(err.reason, WebhookUrlUnsafeReason::UnsupportedProtocol);
}
#[test]
fn webhook_non_http_schemes_rejected() {
for url in [
"data:text/plain;base64,SGVsbG8=",
"file:///etc/passwd",
"ftp://x/y",
] {
let err =
assert_webhook_url_safe(url, &AssertWebhookUrlSafeOptions::default()).unwrap_err();
assert_eq!(err.reason, WebhookUrlUnsafeReason::UnsupportedProtocol);
}
}
#[test]
fn webhook_mixed_records_any_blocked_rejects() {
let resolver = StubResolver(vec![rec("8.8.8.8"), rec("127.0.0.1")]);
let err = assert_webhook_url_safe("https://attacker.example/x", &with_resolver(&resolver))
.unwrap_err();
assert_eq!(err.reason, WebhookUrlUnsafeReason::BlockedIpRange);
assert_eq!(err.resolved_ip.as_deref(), Some("127.0.0.1"));
}
#[test]
fn webhook_ipv6_blocked() {
let resolver = StubResolver(vec![rec("fe80::1")]);
let err = assert_webhook_url_safe("https://attacker.example/x", &with_resolver(&resolver))
.unwrap_err();
assert_eq!(err.reason, WebhookUrlUnsafeReason::BlockedIpRange);
}
#[test]
fn webhook_dns_failure_and_empty() {
let err =
assert_webhook_url_safe("https://nope.invalid/x", &with_resolver(&FailingResolver))
.unwrap_err();
assert_eq!(err.reason, WebhookUrlUnsafeReason::DnsResolutionFailed);
let empty = StubResolver(vec![]);
let err =
assert_webhook_url_safe("https://void.example/x", &with_resolver(&empty)).unwrap_err();
assert_eq!(err.reason, WebhookUrlUnsafeReason::NoDnsRecords);
}
#[test]
fn webhook_invalid_urls_rejected() {
for url in ["", "not a url"] {
let err =
assert_webhook_url_safe(url, &AssertWebhookUrlSafeOptions::default()).unwrap_err();
assert_eq!(err.reason, WebhookUrlUnsafeReason::InvalidUrl);
}
}
#[test]
fn webhook_ip_literals() {
let r = assert_webhook_url_safe(
"https://8.8.8.8/hook",
&AssertWebhookUrlSafeOptions::default(),
)
.unwrap();
assert_eq!(r.resolved_ip, ip("8.8.8.8"));
assert_eq!(r.family, 4);
let err = assert_webhook_url_safe(
"https://127.0.0.1/hook",
&AssertWebhookUrlSafeOptions::default(),
)
.unwrap_err();
assert_eq!(err.reason, WebhookUrlUnsafeReason::BlockedIpRange);
let err = assert_webhook_url_safe(
"https://[fe80::1]/hook",
&AssertWebhookUrlSafeOptions::default(),
)
.unwrap_err();
assert_eq!(err.reason, WebhookUrlUnsafeReason::BlockedIpRange);
let err = assert_webhook_url_safe(
"https://[::1]/hook",
&AssertWebhookUrlSafeOptions::default(),
)
.unwrap_err();
assert_eq!(err.reason, WebhookUrlUnsafeReason::BlockedIpRange);
}
#[test]
fn webhook_ipv4_mapped_loopback_via_resolver() {
let resolver = StubResolver(vec![rec("::ffff:127.0.0.1")]);
let err = assert_webhook_url_safe("https://sneaky.example/x", &with_resolver(&resolver))
.unwrap_err();
assert_eq!(err.reason, WebhookUrlUnsafeReason::BlockedIpRange);
}
#[test]
fn webhook_metadata_ip_via_resolver() {
let resolver = StubResolver(vec![rec("169.254.169.254")]);
let err = assert_webhook_url_safe("https://metadata.example/x", &with_resolver(&resolver))
.unwrap_err();
assert_eq!(err.reason, WebhookUrlUnsafeReason::BlockedIpRange);
}
#[test]
fn webhook_allow_private_for_tests_permits_http_loopback() {
let opts = AssertWebhookUrlSafeOptions {
allow_private_for_tests: true,
resolve_host: None,
};
let r = assert_webhook_url_safe("http://127.0.0.1:3000/hook", &opts).unwrap();
assert_eq!(r.resolved_ip, ip("127.0.0.1"));
}
#[test]
fn webhook_error_carries_fields() {
let resolver = StubResolver(vec![rec("10.0.0.1")]);
let err =
assert_webhook_url_safe("https://x.example/y", &with_resolver(&resolver)).unwrap_err();
assert_eq!(err.code(), "WEBHOOK_URL_UNSAFE");
assert_eq!(err.reason, WebhookUrlUnsafeReason::BlockedIpRange);
assert_eq!(err.url, "https://x.example/y");
assert_eq!(err.hostname, "x.example");
assert_eq!(err.resolved_ip.as_deref(), Some("10.0.0.1"));
}
}