mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-11 13:40:19 +08:00
Retry pre-response transport failures across candidates with an explicit stop policy, and propagate end-to-end timing into usage records and UI diagnostics. Remove legacy body, import, cookie, PII, and tunnel replay caps while preserving optional operator-configured gateway limits.
996 lines
33 KiB
Rust
996 lines
33 KiB
Rust
use std::{
|
|
borrow::Cow,
|
|
collections::BTreeMap,
|
|
fmt,
|
|
io::Read,
|
|
net::{IpAddr, SocketAddr},
|
|
sync::LazyLock,
|
|
};
|
|
|
|
use crate::constants::*;
|
|
use axum::body::Bytes;
|
|
use flate2::read::{DeflateDecoder, GzDecoder, ZlibDecoder};
|
|
use serde_json::{Map, Value};
|
|
use uuid::Uuid;
|
|
|
|
const MAX_REQUEST_BODY_MB_ENV: &str = "AETHER_MAX_REQUEST_BODY_MB";
|
|
const MAX_REDACTED_SYNC_RESPONSE_BODY_MB_ENV: &str = "AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB";
|
|
const MAX_INTERNAL_BUFFERED_BODY_MB_ENV: &str = "AETHER_MAX_INTERNAL_BUFFERED_BODY_MB";
|
|
const TRUSTED_PROXY_CIDRS_ENV: &str = "AETHER_TRUSTED_PROXY_CIDRS";
|
|
|
|
/// Optional operator cap applied after Content-Encoding decoding, and to
|
|
/// uncompressed bodies as-is. Unset, zero, or invalid values disable the cap.
|
|
static MAX_REQUEST_BODY_BYTES: LazyLock<u64> =
|
|
LazyLock::new(|| body_limit_bytes_from_env(MAX_REQUEST_BODY_MB_ENV));
|
|
|
|
static MAX_REDACTED_SYNC_RESPONSE_BODY_BYTES: LazyLock<u64> =
|
|
LazyLock::new(|| body_limit_bytes_from_env(MAX_REDACTED_SYNC_RESPONSE_BODY_MB_ENV));
|
|
|
|
static MAX_INTERNAL_BUFFERED_BODY_BYTES: LazyLock<u64> =
|
|
LazyLock::new(|| body_limit_bytes_from_env(MAX_INTERNAL_BUFFERED_BODY_MB_ENV));
|
|
|
|
fn body_limit_bytes_from_env(name: &str) -> u64 {
|
|
let value = std::env::var(name).ok();
|
|
body_limit_bytes(value.as_deref())
|
|
}
|
|
|
|
fn body_limit_bytes(value: Option<&str>) -> u64 {
|
|
value
|
|
.map(str::trim)
|
|
.and_then(|value| value.parse::<u64>().ok())
|
|
.filter(|value| *value > 0)
|
|
.map(|value| value.saturating_mul(1024 * 1024))
|
|
.unwrap_or(u64::MAX)
|
|
}
|
|
|
|
static TRUSTED_PROXY_CIDRS: LazyLock<Vec<String>> = LazyLock::new(|| {
|
|
std::env::var(TRUSTED_PROXY_CIDRS_ENV)
|
|
.unwrap_or_else(|_| "127.0.0.0/8,::1/128".to_string())
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty() && valid_ip_or_cidr(value))
|
|
.map(ToOwned::to_owned)
|
|
.collect()
|
|
});
|
|
|
|
pub(crate) fn max_request_body_bytes() -> u64 {
|
|
*MAX_REQUEST_BODY_BYTES
|
|
}
|
|
|
|
pub(crate) fn max_redacted_sync_response_body_bytes() -> u64 {
|
|
*MAX_REDACTED_SYNC_RESPONSE_BODY_BYTES
|
|
}
|
|
|
|
pub(crate) fn max_internal_buffered_body_bytes() -> usize {
|
|
usize::try_from(*MAX_INTERNAL_BUFFERED_BODY_BYTES).unwrap_or(usize::MAX)
|
|
}
|
|
|
|
pub(crate) fn extract_or_generate_trace_id(headers: &http::HeaderMap) -> String {
|
|
header_value_str(headers, TRACE_ID_HEADER).unwrap_or_else(|| Uuid::new_v4().to_string())
|
|
}
|
|
|
|
pub(crate) fn header_value_str(headers: &http::HeaderMap, key: &str) -> Option<String> {
|
|
headers
|
|
.get(key)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
pub(crate) fn header_value_u64(headers: &http::HeaderMap, key: &str) -> Option<u64> {
|
|
header_value_str(headers, key).and_then(|value| value.parse::<u64>().ok())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub(crate) struct RequestOrigin {
|
|
pub(crate) client_ip: Option<String>,
|
|
pub(crate) user_agent: Option<String>,
|
|
}
|
|
|
|
pub(crate) fn request_origin_from_headers(headers: &http::HeaderMap) -> RequestOrigin {
|
|
RequestOrigin {
|
|
client_ip: client_ip_from_headers(headers),
|
|
user_agent: header_value_str(headers, http::header::USER_AGENT.as_str())
|
|
.map(|value| truncate_chars(value.as_str(), 1_000)),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn request_origin_from_headers_and_remote_addr(
|
|
headers: &http::HeaderMap,
|
|
remote_addr: &SocketAddr,
|
|
) -> RequestOrigin {
|
|
RequestOrigin {
|
|
client_ip: Some(effective_client_ip(headers, remote_addr).to_string()),
|
|
user_agent: header_value_str(headers, http::header::USER_AGENT.as_str())
|
|
.map(|value| truncate_chars(value.as_str(), 1_000)),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn effective_client_ip(headers: &http::HeaderMap, remote_addr: &SocketAddr) -> IpAddr {
|
|
let remote_ip = remote_addr.ip();
|
|
if !trusted_proxy_ip(remote_ip) {
|
|
return remote_ip;
|
|
}
|
|
|
|
if let Some(real_ip) =
|
|
header_value_str(headers, "x-real-ip").and_then(|value| value.parse::<IpAddr>().ok())
|
|
{
|
|
return real_ip;
|
|
}
|
|
|
|
let forwarded_ips = header_value_str(headers, "x-forwarded-for")
|
|
.map(|value| {
|
|
value
|
|
.split(',')
|
|
.filter_map(|segment| segment.trim().parse::<IpAddr>().ok())
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
forwarded_ips
|
|
.iter()
|
|
.rev()
|
|
.copied()
|
|
.find(|ip| !trusted_proxy_ip(*ip))
|
|
.or_else(|| forwarded_ips.first().copied())
|
|
.unwrap_or(remote_ip)
|
|
}
|
|
|
|
fn trusted_proxy_ip(ip: IpAddr) -> bool {
|
|
TRUSTED_PROXY_CIDRS
|
|
.iter()
|
|
.any(|pattern| ip_or_cidr_matches(pattern, ip))
|
|
}
|
|
|
|
fn valid_ip_or_cidr(value: &str) -> bool {
|
|
if value.parse::<IpAddr>().is_ok() {
|
|
return true;
|
|
}
|
|
let Some((network, prefix)) = value.split_once('/') else {
|
|
return false;
|
|
};
|
|
let Ok(network) = network.trim().parse::<IpAddr>() else {
|
|
return false;
|
|
};
|
|
let Ok(prefix) = prefix.trim().parse::<u8>() else {
|
|
return false;
|
|
};
|
|
match network {
|
|
IpAddr::V4(_) => prefix <= 32,
|
|
IpAddr::V6(_) => prefix <= 128,
|
|
}
|
|
}
|
|
|
|
fn ip_or_cidr_matches(pattern: &str, ip: IpAddr) -> bool {
|
|
if let Ok(expected) = pattern.parse::<IpAddr>() {
|
|
return expected == ip;
|
|
}
|
|
let Some((network, prefix)) = pattern.split_once('/') else {
|
|
return false;
|
|
};
|
|
let Ok(prefix) = prefix.trim().parse::<u8>() else {
|
|
return false;
|
|
};
|
|
match (network.trim().parse::<IpAddr>(), ip) {
|
|
(Ok(IpAddr::V4(network)), IpAddr::V4(ip)) if prefix <= 32 => {
|
|
let mask = if prefix == 0 {
|
|
0
|
|
} else {
|
|
u32::MAX << (32 - prefix)
|
|
};
|
|
(u32::from(network) & mask) == (u32::from(ip) & mask)
|
|
}
|
|
(Ok(IpAddr::V6(network)), IpAddr::V6(ip)) if prefix <= 128 => {
|
|
let mask = if prefix == 0 {
|
|
0
|
|
} else {
|
|
u128::MAX << (128 - prefix)
|
|
};
|
|
(u128::from(network) & mask) == (u128::from(ip) & mask)
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn request_origin_from_parts(parts: &http::request::Parts) -> RequestOrigin {
|
|
parts
|
|
.extensions
|
|
.get::<RequestOrigin>()
|
|
.cloned()
|
|
.unwrap_or_else(|| request_origin_from_headers(&parts.headers))
|
|
}
|
|
|
|
pub(crate) fn tls_fingerprint_from_headers(headers: &http::HeaderMap) -> Option<Value> {
|
|
let mut object = Map::new();
|
|
|
|
copy_tls_header(headers, &mut object, "x-aether-tls-ja3", "ja3");
|
|
copy_tls_header(headers, &mut object, "x-aether-tls-ja3-hash", "ja3_hash");
|
|
copy_tls_header(headers, &mut object, "x-aether-tls-ja4", "ja4");
|
|
copy_tls_header(headers, &mut object, "x-aether-tls-protocol", "protocol");
|
|
copy_tls_header(headers, &mut object, "x-aether-tls-version", "tls_version");
|
|
copy_tls_header(headers, &mut object, "x-aether-tls-cipher", "cipher");
|
|
copy_tls_header(headers, &mut object, "x-aether-tls-sni", "sni");
|
|
copy_tls_header(headers, &mut object, "x-aether-tls-alpn", "alpn");
|
|
|
|
if object.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let source = header_value_str(headers, "x-aether-tls-source")
|
|
.unwrap_or_else(|| "forwarded_header".to_string());
|
|
object.insert("source".to_string(), Value::String(source));
|
|
|
|
Some(Value::Object(object))
|
|
}
|
|
|
|
fn copy_tls_header(
|
|
headers: &http::HeaderMap,
|
|
object: &mut Map<String, Value>,
|
|
header_name: &str,
|
|
field_name: &str,
|
|
) {
|
|
let Some(value) = header_value_str(headers, header_name) else {
|
|
return;
|
|
};
|
|
object.insert(
|
|
field_name.to_string(),
|
|
Value::String(truncate_chars(&value, 512)),
|
|
);
|
|
}
|
|
|
|
fn client_ip_from_headers(headers: &http::HeaderMap) -> Option<String> {
|
|
header_value_str(headers, "x-forwarded-for")
|
|
.and_then(|value| {
|
|
value
|
|
.split(',')
|
|
.map(str::trim)
|
|
.find(|segment| !segment.is_empty() && !segment.eq_ignore_ascii_case("unknown"))
|
|
.map(|segment| truncate_chars(segment, 45))
|
|
})
|
|
.or_else(|| {
|
|
header_value_str(headers, "x-real-ip").and_then(|value| {
|
|
let value = value.trim();
|
|
(!value.is_empty() && !value.eq_ignore_ascii_case("unknown"))
|
|
.then(|| truncate_chars(value, 45))
|
|
})
|
|
})
|
|
}
|
|
|
|
fn truncate_chars(value: &str, max_chars: usize) -> String {
|
|
value.chars().take(max_chars).collect()
|
|
}
|
|
|
|
pub(crate) fn should_skip_request_header(name: &str) -> bool {
|
|
crate::provider_transport::should_skip_request_header(name)
|
|
}
|
|
|
|
pub(crate) fn should_skip_upstream_passthrough_header(name: &str) -> bool {
|
|
crate::provider_transport::should_skip_upstream_passthrough_header(name)
|
|
}
|
|
|
|
pub(crate) fn should_skip_response_header(name: &str) -> bool {
|
|
matches!(
|
|
name.to_ascii_lowercase().as_str(),
|
|
"connection"
|
|
| "keep-alive"
|
|
| "proxy-authenticate"
|
|
| "proxy-authorization"
|
|
| "proxy-connection"
|
|
| "te"
|
|
| "trailer"
|
|
| "transfer-encoding"
|
|
| "upgrade"
|
|
| "x-aether-control-executed"
|
|
| "x-aether-control-action"
|
|
)
|
|
}
|
|
|
|
pub(crate) fn collect_control_headers(headers: &http::HeaderMap) -> BTreeMap<String, String> {
|
|
headers
|
|
.iter()
|
|
.filter_map(|(name, value)| {
|
|
value
|
|
.to_str()
|
|
.ok()
|
|
.map(|value| (name.as_str().to_ascii_lowercase(), value.trim().to_string()))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn is_json_request(headers: &http::HeaderMap) -> bool {
|
|
header_value_str(headers, http::header::CONTENT_TYPE.as_str())
|
|
.map(|value| value.to_ascii_lowercase().contains("application/json"))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) enum RequestBodyNormalizationError {
|
|
UnsupportedContentEncoding(String),
|
|
DecodeFailed { encoding: String, reason: String },
|
|
DecompressedBodyTooLarge { encoding: String, limit_bytes: u64 },
|
|
RequestBodyTooLarge { limit_bytes: u64 },
|
|
}
|
|
|
|
impl RequestBodyNormalizationError {
|
|
pub(crate) fn client_message(&self) -> String {
|
|
match self {
|
|
Self::UnsupportedContentEncoding(encoding) => {
|
|
format!("Unsupported request Content-Encoding: {encoding}")
|
|
}
|
|
Self::DecodeFailed { encoding, .. } => {
|
|
format!("Failed to decode request body with Content-Encoding: {encoding}")
|
|
}
|
|
Self::DecompressedBodyTooLarge {
|
|
encoding,
|
|
limit_bytes,
|
|
} => format!(
|
|
"Decoded request body with Content-Encoding {encoding} exceeds {limit_bytes} bytes"
|
|
),
|
|
Self::RequestBodyTooLarge { limit_bytes } => {
|
|
format!("Request body exceeds {limit_bytes} bytes")
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn http_status(&self) -> http::StatusCode {
|
|
match self {
|
|
Self::DecompressedBodyTooLarge { .. } | Self::RequestBodyTooLarge { .. } => {
|
|
http::StatusCode::PAYLOAD_TOO_LARGE
|
|
}
|
|
Self::UnsupportedContentEncoding(_) | Self::DecodeFailed { .. } => {
|
|
http::StatusCode::BAD_REQUEST
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for RequestBodyNormalizationError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::UnsupportedContentEncoding(encoding) => {
|
|
write!(f, "unsupported request Content-Encoding: {encoding}")
|
|
}
|
|
Self::DecodeFailed { encoding, reason } => {
|
|
write!(
|
|
f,
|
|
"failed to decode request body with Content-Encoding {encoding}: {reason}"
|
|
)
|
|
}
|
|
Self::DecompressedBodyTooLarge {
|
|
encoding,
|
|
limit_bytes,
|
|
} => write!(
|
|
f,
|
|
"decoded request body with Content-Encoding {encoding} exceeds {limit_bytes} bytes"
|
|
),
|
|
Self::RequestBodyTooLarge { limit_bytes } => {
|
|
write!(f, "request body exceeds {limit_bytes} bytes")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for RequestBodyNormalizationError {}
|
|
|
|
pub(crate) fn normalize_request_body_headers_and_bytes(
|
|
headers: &mut http::HeaderMap,
|
|
body_bytes: Bytes,
|
|
) -> Result<Bytes, RequestBodyNormalizationError> {
|
|
normalize_request_body_headers_and_bytes_with_limit(
|
|
headers,
|
|
body_bytes,
|
|
max_request_body_bytes(),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn normalize_request_body_headers_and_bytes_with_limit(
|
|
headers: &mut http::HeaderMap,
|
|
body_bytes: Bytes,
|
|
limit_bytes: u64,
|
|
) -> Result<Bytes, RequestBodyNormalizationError> {
|
|
let body_was_encoded = !request_content_encodings(headers).is_empty();
|
|
let decoded = decoded_request_body_bytes_with_limit(headers, body_bytes.as_ref(), limit_bytes)?;
|
|
if !body_was_encoded {
|
|
return Ok(body_bytes);
|
|
}
|
|
|
|
headers.remove(http::header::CONTENT_ENCODING);
|
|
headers.remove(http::header::CONTENT_LENGTH);
|
|
Ok(Bytes::from(decoded.into_owned()))
|
|
}
|
|
|
|
/// Rejects a request whose declared `Content-Length` already exceeds the body
|
|
/// limit, before the body is buffered into memory. Chunked or length-less
|
|
/// requests pass this check and stay bounded by the post-decode guard instead.
|
|
pub(crate) fn check_request_content_length(
|
|
headers: &http::HeaderMap,
|
|
) -> Result<(), RequestBodyNormalizationError> {
|
|
check_request_content_length_with_limit(headers, max_request_body_bytes())
|
|
}
|
|
|
|
pub(crate) fn check_request_content_length_with_limit(
|
|
headers: &http::HeaderMap,
|
|
limit: u64,
|
|
) -> Result<(), RequestBodyNormalizationError> {
|
|
let declared = header_value_str(headers, http::header::CONTENT_LENGTH.as_str())
|
|
.and_then(|value| value.trim().parse::<u64>().ok());
|
|
if declared.is_some_and(|value| value > limit) {
|
|
return Err(RequestBodyNormalizationError::RequestBodyTooLarge { limit_bytes: limit });
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn decoded_request_body_bytes<'a>(
|
|
headers: &http::HeaderMap,
|
|
body_bytes: &'a [u8],
|
|
) -> Result<Cow<'a, [u8]>, RequestBodyNormalizationError> {
|
|
decoded_request_body_bytes_with_limit(headers, body_bytes, max_request_body_bytes())
|
|
}
|
|
|
|
pub(crate) fn decoded_request_body_bytes_with_limit<'a>(
|
|
headers: &http::HeaderMap,
|
|
body_bytes: &'a [u8],
|
|
limit: u64,
|
|
) -> Result<Cow<'a, [u8]>, RequestBodyNormalizationError> {
|
|
let encodings = request_content_encodings(headers);
|
|
if encodings.is_empty() {
|
|
if body_bytes.len() as u64 > limit {
|
|
return Err(RequestBodyNormalizationError::RequestBodyTooLarge { limit_bytes: limit });
|
|
}
|
|
return Ok(Cow::Borrowed(body_bytes));
|
|
}
|
|
|
|
let mut decoded = body_bytes.to_vec();
|
|
for encoding in encodings.iter().rev() {
|
|
decoded = decode_single_request_body_with_limit(encoding, decoded.as_slice(), limit)?;
|
|
}
|
|
Ok(Cow::Owned(decoded))
|
|
}
|
|
|
|
fn request_content_encodings(headers: &http::HeaderMap) -> Vec<String> {
|
|
header_value_str(headers, http::header::CONTENT_ENCODING.as_str())
|
|
.map(|value| {
|
|
value
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(str::to_ascii_lowercase)
|
|
.filter(|value| value != "identity")
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn decode_single_request_body(
|
|
encoding: &str,
|
|
body_bytes: &[u8],
|
|
) -> Result<Vec<u8>, RequestBodyNormalizationError> {
|
|
decode_single_request_body_with_limit(encoding, body_bytes, max_request_body_bytes())
|
|
}
|
|
|
|
fn decode_single_request_body_with_limit(
|
|
encoding: &str,
|
|
body_bytes: &[u8],
|
|
limit: u64,
|
|
) -> Result<Vec<u8>, RequestBodyNormalizationError> {
|
|
match encoding {
|
|
"gzip" | "x-gzip" => decode_gzip_body_with_limit(encoding, body_bytes, limit),
|
|
"deflate" => decode_deflate_body_with_limit(encoding, body_bytes, limit),
|
|
"zstd" => decode_zstd_body_with_limit(encoding, body_bytes, limit),
|
|
_ => Err(RequestBodyNormalizationError::UnsupportedContentEncoding(
|
|
encoding.to_string(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn decode_gzip_body(
|
|
encoding: &str,
|
|
body_bytes: &[u8],
|
|
) -> Result<Vec<u8>, RequestBodyNormalizationError> {
|
|
decode_gzip_body_with_limit(encoding, body_bytes, max_request_body_bytes())
|
|
}
|
|
|
|
fn decode_gzip_body_with_limit(
|
|
encoding: &str,
|
|
body_bytes: &[u8],
|
|
limit: u64,
|
|
) -> Result<Vec<u8>, RequestBodyNormalizationError> {
|
|
let mut decoder = GzDecoder::new(body_bytes);
|
|
read_request_decoder_to_end_with_limit(encoding, &mut decoder, limit)
|
|
}
|
|
|
|
fn decode_deflate_body(
|
|
encoding: &str,
|
|
body_bytes: &[u8],
|
|
) -> Result<Vec<u8>, RequestBodyNormalizationError> {
|
|
decode_deflate_body_with_limit(encoding, body_bytes, max_request_body_bytes())
|
|
}
|
|
|
|
fn decode_deflate_body_with_limit(
|
|
encoding: &str,
|
|
body_bytes: &[u8],
|
|
limit: u64,
|
|
) -> Result<Vec<u8>, RequestBodyNormalizationError> {
|
|
let mut zlib_decoder = ZlibDecoder::new(body_bytes);
|
|
match read_request_decoder_to_end_with_limit(encoding, &mut zlib_decoder, limit) {
|
|
Ok(decoded) => Ok(decoded),
|
|
Err(err @ RequestBodyNormalizationError::DecompressedBodyTooLarge { .. }) => Err(err),
|
|
Err(zlib_error) => {
|
|
let mut raw_decoder = DeflateDecoder::new(body_bytes);
|
|
read_request_decoder_to_end_with_limit(encoding, &mut raw_decoder, limit).map_err(
|
|
|raw_error| RequestBodyNormalizationError::DecodeFailed {
|
|
encoding: encoding.to_string(),
|
|
reason: format!("{zlib_error}; raw deflate fallback failed: {raw_error}"),
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn decode_zstd_body(
|
|
encoding: &str,
|
|
body_bytes: &[u8],
|
|
) -> Result<Vec<u8>, RequestBodyNormalizationError> {
|
|
decode_zstd_body_with_limit(encoding, body_bytes, max_request_body_bytes())
|
|
}
|
|
|
|
fn decode_zstd_body_with_limit(
|
|
encoding: &str,
|
|
body_bytes: &[u8],
|
|
limit: u64,
|
|
) -> Result<Vec<u8>, RequestBodyNormalizationError> {
|
|
let mut decoder = zstd::stream::read::Decoder::new(body_bytes).map_err(|err| {
|
|
RequestBodyNormalizationError::DecodeFailed {
|
|
encoding: encoding.to_string(),
|
|
reason: err.to_string(),
|
|
}
|
|
})?;
|
|
read_request_decoder_to_end_with_limit(encoding, &mut decoder, limit)
|
|
}
|
|
|
|
fn read_request_decoder_to_end(
|
|
encoding: &str,
|
|
decoder: &mut impl Read,
|
|
) -> Result<Vec<u8>, RequestBodyNormalizationError> {
|
|
read_request_decoder_to_end_with_limit(encoding, decoder, max_request_body_bytes())
|
|
}
|
|
|
|
fn read_request_decoder_to_end_with_limit(
|
|
encoding: &str,
|
|
decoder: &mut impl Read,
|
|
limit: u64,
|
|
) -> Result<Vec<u8>, RequestBodyNormalizationError> {
|
|
let mut limited = decoder.take(limit.saturating_add(1));
|
|
let mut out = Vec::new();
|
|
limited
|
|
.read_to_end(&mut out)
|
|
.map_err(|err| RequestBodyNormalizationError::DecodeFailed {
|
|
encoding: encoding.to_string(),
|
|
reason: err.to_string(),
|
|
})?;
|
|
if out.len() as u64 > limit {
|
|
return Err(RequestBodyNormalizationError::DecompressedBodyTooLarge {
|
|
encoding: encoding.to_string(),
|
|
limit_bytes: limit,
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
pub(crate) fn header_equals(
|
|
headers: &reqwest::header::HeaderMap,
|
|
key: &'static str,
|
|
expected: &str,
|
|
) -> bool {
|
|
headers
|
|
.get(key)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(|value| value.eq_ignore_ascii_case(expected))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
decoded_request_body_bytes, effective_client_ip, normalize_request_body_headers_and_bytes,
|
|
request_origin_from_headers, request_origin_from_headers_and_remote_addr,
|
|
tls_fingerprint_from_headers, RequestBodyNormalizationError, RequestOrigin,
|
|
};
|
|
use flate2::{
|
|
write::{DeflateEncoder, GzEncoder, ZlibEncoder},
|
|
Compression,
|
|
};
|
|
use http::{HeaderMap, HeaderValue};
|
|
use serde_json::json;
|
|
use std::{
|
|
io::Write,
|
|
net::{IpAddr, Ipv4Addr, SocketAddr},
|
|
};
|
|
|
|
#[test]
|
|
fn request_origin_prefers_first_forwarded_for_ip() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"x-forwarded-for",
|
|
HeaderValue::from_static(" 203.0.113.8, 10.0.0.1 "),
|
|
);
|
|
headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.4"));
|
|
headers.insert(
|
|
http::header::USER_AGENT,
|
|
HeaderValue::from_static("Claude-Code/1.0"),
|
|
);
|
|
|
|
assert_eq!(
|
|
request_origin_from_headers(&headers),
|
|
RequestOrigin {
|
|
client_ip: Some("203.0.113.8".to_string()),
|
|
user_agent: Some("Claude-Code/1.0".to_string()),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn effective_client_ip_ignores_forwarded_headers_from_untrusted_peers() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.4"));
|
|
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.8"));
|
|
let remote_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)), 443);
|
|
|
|
assert_eq!(
|
|
effective_client_ip(&headers, &remote_addr),
|
|
remote_addr.ip()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn effective_client_ip_accepts_real_ip_from_trusted_loopback_proxy() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.4"));
|
|
let remote_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
|
|
|
|
assert_eq!(
|
|
effective_client_ip(&headers, &remote_addr),
|
|
IpAddr::V4(Ipv4Addr::new(198, 51, 100, 4))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn effective_client_ip_walks_forwarded_chain_from_trusted_proxy() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"x-forwarded-for",
|
|
HeaderValue::from_static("203.0.113.8, 127.0.0.2"),
|
|
);
|
|
let remote_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
|
|
|
|
assert_eq!(
|
|
effective_client_ip(&headers, &remote_addr),
|
|
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 8))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decoded_request_body_bytes_decodes_zstd() {
|
|
let payload = br#"{"model":"gpt-5.4"}"#;
|
|
let encoded =
|
|
zstd::stream::encode_all(payload.as_slice(), 0).expect("zstd body should encode");
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
http::header::CONTENT_ENCODING,
|
|
HeaderValue::from_static("zstd"),
|
|
);
|
|
|
|
let decoded =
|
|
decoded_request_body_bytes(&headers, encoded.as_slice()).expect("body should decode");
|
|
|
|
assert_eq!(decoded.as_ref(), payload);
|
|
}
|
|
|
|
#[test]
|
|
fn decoded_request_body_bytes_decodes_x_gzip() {
|
|
let payload = br#"{"model":"gpt-5.4"}"#;
|
|
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
|
encoder.write_all(payload).expect("gzip body should write");
|
|
let encoded = encoder.finish().expect("gzip body should finish");
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
http::header::CONTENT_ENCODING,
|
|
HeaderValue::from_static("x-gzip"),
|
|
);
|
|
|
|
let decoded =
|
|
decoded_request_body_bytes(&headers, encoded.as_slice()).expect("body should decode");
|
|
|
|
assert_eq!(decoded.as_ref(), payload);
|
|
}
|
|
|
|
#[test]
|
|
fn decoded_request_body_bytes_decodes_zlib_wrapped_deflate() {
|
|
let payload = br#"{"model":"gpt-5.4"}"#;
|
|
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
|
|
encoder
|
|
.write_all(payload)
|
|
.expect("deflate body should write");
|
|
let encoded = encoder.finish().expect("deflate body should finish");
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
http::header::CONTENT_ENCODING,
|
|
HeaderValue::from_static("deflate"),
|
|
);
|
|
|
|
let decoded =
|
|
decoded_request_body_bytes(&headers, encoded.as_slice()).expect("body should decode");
|
|
|
|
assert_eq!(decoded.as_ref(), payload);
|
|
}
|
|
|
|
#[test]
|
|
fn decoded_request_body_bytes_decodes_raw_deflate_fallback() {
|
|
let payload = br#"{"model":"gpt-5.4"}"#;
|
|
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
|
|
encoder
|
|
.write_all(payload)
|
|
.expect("deflate body should write");
|
|
let encoded = encoder.finish().expect("deflate body should finish");
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
http::header::CONTENT_ENCODING,
|
|
HeaderValue::from_static("deflate"),
|
|
);
|
|
|
|
let decoded =
|
|
decoded_request_body_bytes(&headers, encoded.as_slice()).expect("body should decode");
|
|
|
|
assert_eq!(decoded.as_ref(), payload);
|
|
}
|
|
|
|
#[test]
|
|
fn decoded_request_body_bytes_decodes_multiple_chained_encodings() {
|
|
let payload = br#"{"model":"gpt-5.4"}"#;
|
|
let mut gzip_encoder = GzEncoder::new(Vec::new(), Compression::default());
|
|
gzip_encoder
|
|
.write_all(payload)
|
|
.expect("gzip body should write");
|
|
let gzipped = gzip_encoder.finish().expect("gzip body should finish");
|
|
let encoded =
|
|
zstd::stream::encode_all(gzipped.as_slice(), 0).expect("zstd body should encode");
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
http::header::CONTENT_ENCODING,
|
|
HeaderValue::from_static("gzip, zstd"),
|
|
);
|
|
|
|
let decoded =
|
|
decoded_request_body_bytes(&headers, encoded.as_slice()).expect("body should decode");
|
|
|
|
assert_eq!(decoded.as_ref(), payload);
|
|
}
|
|
|
|
#[test]
|
|
fn decoded_request_body_bytes_rejects_corrupt_encoded_body() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
http::header::CONTENT_ENCODING,
|
|
HeaderValue::from_static("zstd"),
|
|
);
|
|
|
|
let err = decoded_request_body_bytes(&headers, br#"{"model":"gpt-5.4"}"#.as_slice())
|
|
.expect_err("corrupt body should fail");
|
|
|
|
assert!(matches!(
|
|
err,
|
|
RequestBodyNormalizationError::DecodeFailed { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_request_body_headers_and_bytes_clears_encoding_headers() {
|
|
let payload = br#"{"model":"gpt-5.4"}"#;
|
|
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
|
encoder.write_all(payload).expect("gzip body should write");
|
|
let encoded = encoder.finish().expect("gzip body should finish");
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
http::header::CONTENT_ENCODING,
|
|
HeaderValue::from_static("x-gzip"),
|
|
);
|
|
headers.insert(
|
|
http::header::CONTENT_LENGTH,
|
|
HeaderValue::from_static("999"),
|
|
);
|
|
|
|
let decoded = normalize_request_body_headers_and_bytes(
|
|
&mut headers,
|
|
axum::body::Bytes::from(encoded),
|
|
)
|
|
.expect("body should normalize");
|
|
|
|
assert_eq!(decoded.as_ref(), payload);
|
|
assert!(!headers.contains_key(http::header::CONTENT_ENCODING));
|
|
assert!(!headers.contains_key(http::header::CONTENT_LENGTH));
|
|
}
|
|
|
|
#[test]
|
|
fn decoded_request_body_bytes_rejects_unsupported_encoding() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
http::header::CONTENT_ENCODING,
|
|
HeaderValue::from_static("br"),
|
|
);
|
|
|
|
let err = decoded_request_body_bytes(&headers, br#"{"model":"gpt-5.4"}"#.as_slice())
|
|
.expect_err("unsupported encoding should fail");
|
|
|
|
assert_eq!(
|
|
err,
|
|
RequestBodyNormalizationError::UnsupportedContentEncoding("br".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_limit_rejects_oversized_uncompressed_body() {
|
|
let limit = 4;
|
|
let oversized = vec![b'a'; limit as usize + 1];
|
|
let headers = HeaderMap::new();
|
|
|
|
let err =
|
|
super::decoded_request_body_bytes_with_limit(&headers, oversized.as_slice(), limit)
|
|
.expect_err("oversized uncompressed body should fail");
|
|
|
|
assert_eq!(
|
|
err,
|
|
RequestBodyNormalizationError::RequestBodyTooLarge { limit_bytes: limit }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_limit_rejects_oversized_declared_length() {
|
|
let limit = 4;
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
http::header::CONTENT_LENGTH,
|
|
HeaderValue::from_str(&(limit + 1).to_string()).expect("length header should build"),
|
|
);
|
|
|
|
let err = super::check_request_content_length_with_limit(&headers, limit)
|
|
.expect_err("oversized declared length should fail");
|
|
|
|
assert_eq!(
|
|
err,
|
|
RequestBodyNormalizationError::RequestBodyTooLarge { limit_bytes: limit }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn body_limits_default_to_unlimited() {
|
|
assert_eq!(super::body_limit_bytes(None), u64::MAX);
|
|
assert_eq!(super::body_limit_bytes(Some("0")), u64::MAX);
|
|
assert_eq!(super::body_limit_bytes(Some("invalid")), u64::MAX);
|
|
}
|
|
|
|
#[test]
|
|
fn positive_body_limit_is_converted_from_mibibytes() {
|
|
assert_eq!(super::body_limit_bytes(Some(" 8 ")), 8 * 1024 * 1024);
|
|
}
|
|
|
|
#[test]
|
|
fn unlimited_limit_accepts_declared_and_buffered_body() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
http::header::CONTENT_LENGTH,
|
|
HeaderValue::from_static("18446744073709551615"),
|
|
);
|
|
super::check_request_content_length_with_limit(&headers, u64::MAX)
|
|
.expect("unlimited mode should accept every representable content length");
|
|
|
|
let body = b"body larger than the former default is admitted by the unlimited sentinel";
|
|
let decoded =
|
|
super::decoded_request_body_bytes_with_limit(&HeaderMap::new(), body, u64::MAX)
|
|
.expect("unlimited mode should accept buffered bytes");
|
|
assert_eq!(decoded.as_ref(), body);
|
|
}
|
|
|
|
#[test]
|
|
fn request_body_normalization_error_maps_http_status() {
|
|
assert_eq!(
|
|
RequestBodyNormalizationError::RequestBodyTooLarge { limit_bytes: 1 }.http_status(),
|
|
http::StatusCode::PAYLOAD_TOO_LARGE
|
|
);
|
|
assert_eq!(
|
|
RequestBodyNormalizationError::DecompressedBodyTooLarge {
|
|
encoding: "zstd".to_string(),
|
|
limit_bytes: 1,
|
|
}
|
|
.http_status(),
|
|
http::StatusCode::PAYLOAD_TOO_LARGE
|
|
);
|
|
assert_eq!(
|
|
RequestBodyNormalizationError::UnsupportedContentEncoding("br".to_string())
|
|
.http_status(),
|
|
http::StatusCode::BAD_REQUEST
|
|
);
|
|
assert_eq!(
|
|
RequestBodyNormalizationError::DecodeFailed {
|
|
encoding: "gzip".to_string(),
|
|
reason: "bad".to_string(),
|
|
}
|
|
.http_status(),
|
|
http::StatusCode::BAD_REQUEST
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn check_request_content_length_allows_missing_or_within_limit() {
|
|
let empty = HeaderMap::new();
|
|
assert!(super::check_request_content_length(&empty).is_ok());
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
http::header::CONTENT_LENGTH,
|
|
HeaderValue::from_static("1024"),
|
|
);
|
|
assert!(super::check_request_content_length(&headers).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn request_origin_uses_real_ip_after_empty_forwarded_for_segments() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-forwarded-for", HeaderValue::from_static(" , unknown "));
|
|
headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.4"));
|
|
|
|
assert_eq!(
|
|
request_origin_from_headers(&headers).client_ip.as_deref(),
|
|
Some("198.51.100.4")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn request_origin_falls_back_to_remote_addr() {
|
|
let headers = HeaderMap::new();
|
|
let remote_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)), 443);
|
|
|
|
assert_eq!(
|
|
request_origin_from_headers_and_remote_addr(&headers, &remote_addr)
|
|
.client_ip
|
|
.as_deref(),
|
|
Some("192.0.2.10")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tls_fingerprint_from_headers_collects_forwarded_tls_fields() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-aether-tls-ja3", HeaderValue::from_static("ja3-value"));
|
|
headers.insert(
|
|
"x-aether-tls-ja3-hash",
|
|
HeaderValue::from_static("ja3-hash"),
|
|
);
|
|
headers.insert("x-aether-tls-ja4", HeaderValue::from_static("ja4-value"));
|
|
headers.insert("x-aether-tls-protocol", HeaderValue::from_static("TLSv1.3"));
|
|
headers.insert(
|
|
"x-aether-tls-cipher",
|
|
HeaderValue::from_static("TLS_AES_128_GCM_SHA256"),
|
|
);
|
|
headers.insert(
|
|
"x-aether-tls-sni",
|
|
HeaderValue::from_static("api.example.com"),
|
|
);
|
|
headers.insert("x-aether-tls-alpn", HeaderValue::from_static("h2"));
|
|
headers.insert("x-aether-tls-source", HeaderValue::from_static("nginx"));
|
|
|
|
assert_eq!(
|
|
tls_fingerprint_from_headers(&headers),
|
|
Some(json!({
|
|
"source": "nginx",
|
|
"ja3": "ja3-value",
|
|
"ja3_hash": "ja3-hash",
|
|
"ja4": "ja4-value",
|
|
"protocol": "TLSv1.3",
|
|
"cipher": "TLS_AES_128_GCM_SHA256",
|
|
"sni": "api.example.com",
|
|
"alpn": "h2"
|
|
}))
|
|
);
|
|
}
|
|
}
|