mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -44,7 +44,9 @@ pub fn build_http_client_with_headers(
|
||||
config: &HttpClientConfig,
|
||||
default_headers: HeaderMap,
|
||||
) -> Result<reqwest::Client, reqwest::Error> {
|
||||
let mut builder = apply_http_client_config(reqwest::Client::builder(), config);
|
||||
// Shared clients must not silently inherit HTTP(S)_PROXY. Callers that
|
||||
// need a proxy install the explicitly configured URL below.
|
||||
let mut builder = apply_http_client_config(reqwest::Client::builder().no_proxy(), config);
|
||||
if let Some(proxy_url) = config
|
||||
.proxy_url
|
||||
.as_deref()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Maximum number of addresses accepted from one hostname lookup.
|
||||
///
|
||||
/// A DNS response is attacker-controlled at the resolver boundary. Keeping
|
||||
/// the result bounded prevents a pathological answer from forcing an
|
||||
/// unbounded vector allocation or an unbounded connect fan-out. Callers still
|
||||
/// validate every returned address for their own network policy.
|
||||
pub const MAX_DNS_RESOLVED_ADDRESSES: usize = 32;
|
||||
|
||||
/// Upper bound used by callers that do not have a tighter request deadline.
|
||||
pub const DEFAULT_DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Resolve a host while bounding both resolver wait time and answer count.
|
||||
///
|
||||
/// The iterator is consumed one item past the allowed count so an answer set
|
||||
/// larger than the policy is rejected rather than silently truncated. This
|
||||
/// keeps validation and connection pinning based on the complete, bounded
|
||||
/// answer set.
|
||||
pub async fn lookup_host_with_limits(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
) -> io::Result<Vec<SocketAddr>> {
|
||||
if timeout.is_zero() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"DNS lookup timeout must be non-zero",
|
||||
));
|
||||
}
|
||||
|
||||
let mut resolved = tokio::time::timeout(timeout, tokio::net::lookup_host((host, port)))
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "DNS lookup timed out"))??;
|
||||
|
||||
collect_resolved_addresses_with_limit(&mut resolved)
|
||||
}
|
||||
|
||||
fn collect_resolved_addresses_with_limit(
|
||||
resolved: &mut impl Iterator<Item = SocketAddr>,
|
||||
) -> io::Result<Vec<SocketAddr>> {
|
||||
let mut addresses = Vec::with_capacity(MAX_DNS_RESOLVED_ADDRESSES.min(8));
|
||||
while let Some(address) = resolved.next() {
|
||||
if addresses.len() >= MAX_DNS_RESOLVED_ADDRESSES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"DNS lookup returned too many addresses",
|
||||
));
|
||||
}
|
||||
addresses.push(address);
|
||||
}
|
||||
Ok(addresses)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use super::{
|
||||
collect_resolved_addresses_with_limit, lookup_host_with_limits, DEFAULT_DNS_LOOKUP_TIMEOUT,
|
||||
MAX_DNS_RESOLVED_ADDRESSES,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_zero_dns_timeout_before_resolving() {
|
||||
let error = lookup_host_with_limits("localhost", 80, std::time::Duration::ZERO)
|
||||
.await
|
||||
.expect_err("zero timeout must be rejected");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolves_within_shared_address_bound() {
|
||||
let addresses = lookup_host_with_limits("localhost", 80, DEFAULT_DNS_LOOKUP_TIMEOUT)
|
||||
.await
|
||||
.expect("localhost should resolve in the test environment");
|
||||
assert!(!addresses.is_empty());
|
||||
assert!(addresses.len() <= MAX_DNS_RESOLVED_ADDRESSES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_answer_set_larger_than_the_shared_bound() {
|
||||
let mut resolved = (0..=MAX_DNS_RESOLVED_ADDRESSES)
|
||||
.map(|index| SocketAddr::from(([192, 0, 2, (index % 254 + 1) as u8], 443)));
|
||||
let error = collect_resolved_addresses_with_limit(&mut resolved)
|
||||
.expect_err("more than 32 DNS answers must be rejected");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use url::{Host, Url};
|
||||
|
||||
/// Parse the case-insensitive field names nominated by HTTP/1 `Connection`
|
||||
/// headers. Those fields are hop-by-hop even when their names are otherwise
|
||||
/// application-defined.
|
||||
pub fn connection_declared_header_names<'a>(
|
||||
values: impl IntoIterator<Item = &'a str>,
|
||||
) -> BTreeSet<String> {
|
||||
values
|
||||
.into_iter()
|
||||
.flat_map(|value| value.split(','))
|
||||
.map(str::trim)
|
||||
.filter(|value| valid_http_token(value))
|
||||
.map(str::to_ascii_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn valid_http_token(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric()
|
||||
|| matches!(
|
||||
byte,
|
||||
b'!' | b'#'
|
||||
| b'$'
|
||||
| b'%'
|
||||
| b'&'
|
||||
| b'\''
|
||||
| b'*'
|
||||
| b'+'
|
||||
| b'-'
|
||||
| b'.'
|
||||
| b'^'
|
||||
| b'_'
|
||||
| b'`'
|
||||
| b'|'
|
||||
| b'~'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Return true for addresses that an untrusted URL must not be allowed to
|
||||
/// reach, including private/link-local ranges and IPv6 transition formats
|
||||
/// that can embed an IPv4 destination.
|
||||
pub fn is_private_or_reserved_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => {
|
||||
let octets = ip.octets();
|
||||
ip.is_private()
|
||||
|| ip.is_loopback()
|
||||
|| ip.is_link_local()
|
||||
|| ip.is_broadcast()
|
||||
|| ip.is_documentation()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.is_multicast()
|
||||
// The complete 0.0.0.0/8 block is reserved for "this
|
||||
// network" destinations. `Ipv4Addr::is_unspecified()`
|
||||
// only covers the single 0.0.0.0 address.
|
||||
|| octets[0] == 0
|
||||
|| (octets[0] == 100 && (64..=127).contains(&octets[1]))
|
||||
|| (octets[0] == 192 && octets[1] == 0 && octets[2] == 0)
|
||||
|| (octets[0] == 192 && octets[1] == 88 && octets[2] == 99)
|
||||
|| (octets[0] == 198 && (18..=19).contains(&octets[1]))
|
||||
|| octets[0] >= 240
|
||||
}
|
||||
IpAddr::V6(ip) => {
|
||||
let segments = ip.segments();
|
||||
if let Some(mapped) = ip.to_ipv4_mapped() {
|
||||
return is_private_or_reserved_ip(IpAddr::V4(mapped));
|
||||
}
|
||||
ip.is_loopback()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.is_unique_local()
|
||||
|| ip.is_unicast_link_local()
|
||||
|| ip.is_multicast()
|
||||
|| (segments[0] & 0xffc0 == 0xfec0)
|
||||
|| (segments[0] == 0x2001 && segments[1] == 0x0db8)
|
||||
|| segments[..6] == [0x0064, 0xff9b, 0, 0, 0, 0]
|
||||
|| segments[..3] == [0x0064, 0xff9b, 0x0001]
|
||||
|| segments[0] == 0x2002
|
||||
|| segments[..2] == [0x2001, 0]
|
||||
|| segments[..6] == [0, 0, 0, 0, 0, 0]
|
||||
|| segments[..6] == [0, 0, 0, 0, 0xffff, 0]
|
||||
|| (matches!(segments[4], 0 | 0x0200) && segments[5] == 0x5efe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return true only when a URL names loopback without relying on DNS.
|
||||
///
|
||||
/// This is intentionally stricter than accepting names that currently resolve
|
||||
/// to loopback: DNS answers can change between validation and connection.
|
||||
pub fn url_has_literal_loopback_host(url: &Url) -> bool {
|
||||
match url.host() {
|
||||
Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
|
||||
Some(Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(Host::Ipv6(address)) => address.is_loopback(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sensitive HTTP traffic may use cleartext only for a literal loopback host.
|
||||
pub fn is_https_or_loopback_http_url(url: &Url) -> bool {
|
||||
url.scheme() == "https" || (url.scheme() == "http" && url_has_literal_loopback_host(url))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
connection_declared_header_names, is_https_or_loopback_http_url, is_private_or_reserved_ip,
|
||||
url_has_literal_loopback_host,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_connection_values_and_rejects_invalid_names() {
|
||||
let names = connection_declared_header_names([
|
||||
"keep-alive, X-Private",
|
||||
"x-accel-redirect, invalid name, x-private",
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
names.into_iter().collect::<Vec<_>>(),
|
||||
vec!["keep-alive", "x-accel-redirect", "x-private"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_private_and_transition_addresses_but_allows_public_addresses() {
|
||||
for address in [
|
||||
"127.0.0.1",
|
||||
"0.1.2.3",
|
||||
"169.254.169.254",
|
||||
"100.64.0.1",
|
||||
"::1",
|
||||
"::ffff:127.0.0.1",
|
||||
"64:ff9b::10.0.0.1",
|
||||
"2002:0a00:0001::1",
|
||||
"2001:0000:4136:e378:8000:63bf:3fff:fdd2",
|
||||
] {
|
||||
assert!(
|
||||
is_private_or_reserved_ip(address.parse().expect("IP address")),
|
||||
"address should be blocked: {address}"
|
||||
);
|
||||
}
|
||||
assert!(!is_private_or_reserved_ip("8.8.8.8".parse().unwrap()));
|
||||
assert!(!is_private_or_reserved_ip(
|
||||
"2606:4700:4700::1111".parse().unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitive_http_transport_allows_https_or_literal_loopback_only() {
|
||||
for allowed in [
|
||||
"https://api.example.test/v1",
|
||||
"http://localhost:8080/v1",
|
||||
"http://127.42.0.1:8080/v1",
|
||||
"http://[::1]:8080/v1",
|
||||
] {
|
||||
let url = url::Url::parse(allowed).unwrap();
|
||||
assert!(is_https_or_loopback_http_url(&url), "rejected {allowed}");
|
||||
}
|
||||
|
||||
for rejected in [
|
||||
"http://api.example.test/v1",
|
||||
"http://10.0.0.1/v1",
|
||||
"http://0.0.0.0:8080/v1",
|
||||
"http://[::ffff:127.0.0.1]:8080/v1",
|
||||
"ftp://localhost/resource",
|
||||
] {
|
||||
let url = url::Url::parse(rejected).unwrap();
|
||||
assert!(!is_https_or_loopback_http_url(&url), "accepted {rejected}");
|
||||
}
|
||||
|
||||
assert!(url_has_literal_loopback_host(
|
||||
&url::Url::parse("https://localhost/").unwrap()
|
||||
));
|
||||
assert!(!url_has_literal_loopback_host(
|
||||
&url::Url::parse("https://localhost.example/").unwrap()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
mod client;
|
||||
mod config;
|
||||
mod dns;
|
||||
mod header_security;
|
||||
mod response_body;
|
||||
mod retry;
|
||||
|
||||
pub use client::{apply_http_client_config, build_http_client, build_http_client_with_headers};
|
||||
pub use config::{HttpClientConfig, HttpRetryConfig};
|
||||
pub use dns::{lookup_host_with_limits, DEFAULT_DNS_LOOKUP_TIMEOUT, MAX_DNS_RESOLVED_ADDRESSES};
|
||||
pub use header_security::{
|
||||
connection_declared_header_names, is_https_or_loopback_http_url, is_private_or_reserved_ip,
|
||||
url_has_literal_loopback_host,
|
||||
};
|
||||
pub use response_body::{read_response_bytes_with_limit, ResponseBodyReadError};
|
||||
pub use retry::jittered_delay_for_retry;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
|
||||
pub enum ResponseBodyReadError {
|
||||
TooLarge { max_bytes: usize },
|
||||
Read(reqwest::Error),
|
||||
}
|
||||
|
||||
// Avoid trusting a remote Content-Length as an allocation hint. The stream
|
||||
// remains allowed to grow up to the caller's actual body limit, but the first
|
||||
// allocation stays modest when a peer advertises a very large response.
|
||||
const MAX_INITIAL_RESPONSE_BODY_CAPACITY_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
impl fmt::Debug for ResponseBodyReadError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let mut debug = formatter.debug_struct("ResponseBodyReadError");
|
||||
match self {
|
||||
Self::TooLarge { max_bytes } => {
|
||||
debug
|
||||
.field("kind", &"too_large")
|
||||
.field("max_bytes", max_bytes);
|
||||
}
|
||||
// Reqwest's Debug output may include the complete request URL,
|
||||
// including credentials embedded in a path or query. Keep the
|
||||
// underlying value available to explicit category helpers, but
|
||||
// never render it through this public error boundary.
|
||||
Self::Read(_) => {
|
||||
debug.field("kind", &"read");
|
||||
}
|
||||
}
|
||||
debug.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ResponseBodyReadError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::TooLarge { max_bytes } => {
|
||||
write!(formatter, "response body exceeds {max_bytes} bytes")
|
||||
}
|
||||
Self::Read(_) => write!(formatter, "failed to read response body"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ResponseBodyReadError {
|
||||
// Do not expose the reqwest error chain to generic reporters. Callers that
|
||||
// need a retry/telemetry category can still pattern-match `Read(error)`
|
||||
// and inspect the concrete reqwest value deliberately.
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a small control-plane response without trusting `Content-Length`.
|
||||
///
|
||||
/// The advertised length is rejected early when available, while the streamed
|
||||
/// byte count remains authoritative for missing or dishonest length headers.
|
||||
pub async fn read_response_bytes_with_limit(
|
||||
mut response: reqwest::Response,
|
||||
max_bytes: usize,
|
||||
) -> Result<Vec<u8>, ResponseBodyReadError> {
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > max_bytes as u64)
|
||||
{
|
||||
return Err(ResponseBodyReadError::TooLarge { max_bytes });
|
||||
}
|
||||
|
||||
let initial_capacity = initial_response_body_capacity(response.content_length(), max_bytes);
|
||||
let mut body = Vec::with_capacity(initial_capacity);
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(ResponseBodyReadError::Read)?
|
||||
{
|
||||
append_chunk_with_limit(&mut body, &chunk, max_bytes)?;
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn initial_response_body_capacity(content_length: Option<u64>, max_bytes: usize) -> usize {
|
||||
content_length
|
||||
.and_then(|length| usize::try_from(length).ok())
|
||||
.unwrap_or(0)
|
||||
.min(max_bytes)
|
||||
.min(MAX_INITIAL_RESPONSE_BODY_CAPACITY_BYTES)
|
||||
}
|
||||
|
||||
fn append_chunk_with_limit(
|
||||
body: &mut Vec<u8>,
|
||||
chunk: &[u8],
|
||||
max_bytes: usize,
|
||||
) -> Result<(), ResponseBodyReadError> {
|
||||
let Some(next_len) = body.len().checked_add(chunk.len()) else {
|
||||
return Err(ResponseBodyReadError::TooLarge { max_bytes });
|
||||
};
|
||||
if next_len > max_bytes {
|
||||
return Err(ResponseBodyReadError::TooLarge { max_bytes });
|
||||
}
|
||||
body.extend_from_slice(chunk);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{append_chunk_with_limit, initial_response_body_capacity, ResponseBodyReadError};
|
||||
use std::error::Error;
|
||||
|
||||
#[test]
|
||||
fn streamed_body_accepts_exact_limit() {
|
||||
let mut body = b"1234".to_vec();
|
||||
append_chunk_with_limit(&mut body, b"5678", 8).expect("exact limit should pass");
|
||||
assert_eq!(body, b"12345678");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_body_rejects_limit_plus_one_without_appending_chunk() {
|
||||
let mut body = b"1234".to_vec();
|
||||
let error = append_chunk_with_limit(&mut body, b"56789", 8)
|
||||
.expect_err("limit plus one should fail");
|
||||
assert!(matches!(
|
||||
error,
|
||||
ResponseBodyReadError::TooLarge { max_bytes: 8 }
|
||||
));
|
||||
assert_eq!(body, b"1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_error_rendering_does_not_include_read_error_details() {
|
||||
let error = ResponseBodyReadError::TooLarge { max_bytes: 64 };
|
||||
assert_eq!(error.to_string(), "response body exceeds 64 bytes");
|
||||
assert!(error.source().is_none());
|
||||
assert!(format!("{error:?}").contains("too_large"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_capacity_does_not_trust_giant_content_length() {
|
||||
assert_eq!(
|
||||
initial_response_body_capacity(Some(u64::MAX), usize::MAX),
|
||||
16 * 1024 * 1024
|
||||
);
|
||||
assert_eq!(initial_response_body_capacity(Some(1024), usize::MAX), 1024);
|
||||
assert_eq!(initial_response_body_capacity(None, usize::MAX), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user