#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::future::Future;
use std::pin::Pin;
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method};
use crate::shadow::diff::ResponseFacts;
use crate::shadow::sample::{SHADOW_HEADER, SHADOW_HEADER_VALUE};
const STRIPPED_HEADERS: [&str; 16] = [
"connection",
"proxy-connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"content-length",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"x-forwarded-port",
"forwarded",
"x-real-ip",
];
#[derive(Clone, Debug)]
pub struct ShadowRequest {
pub method: Method,
pub url: String,
pub headers: HeaderMap,
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum ShadowError {
#[error("shadow request timed out")]
Timeout,
#[error("shadow response body exceeded the capture budget")]
Oversize,
#[error("shadow request failed: {0}")]
Transport(String),
}
impl ShadowError {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Timeout => "timeout",
Self::Oversize => "oversize",
Self::Transport(_) => "error",
}
}
}
pub type ShadowFuture =
Pin<Box<dyn Future<Output = Result<ResponseFacts, ShadowError>> + Send + 'static>>;
pub trait ShadowTransport: std::fmt::Debug + Send + Sync + 'static {
fn send(&self, request: ShadowRequest) -> ShadowFuture;
}
#[must_use]
pub fn forwarded_headers(
source: &HeaderMap,
resolved: Option<&crate::security::ResolvedClientIdentity>,
) -> HeaderMap {
let connection_named = connection_named_headers(source);
let mut out = HeaderMap::with_capacity(source.len().saturating_add(1));
for (name, value) in source {
let lower = name.as_str().to_ascii_lowercase();
if STRIPPED_HEADERS.contains(&lower.as_str())
|| lower == SHADOW_HEADER
|| connection_named.iter().any(|named| named == &lower)
{
continue;
}
out.append(name.clone(), value.clone());
}
if let Some(identity) = resolved {
if let Some(host) = identity.host.as_deref()
&& let Ok(value) = HeaderValue::from_str(host)
{
out.insert(axum::http::header::HOST, value);
}
if let Some(addr) = identity.addr
&& let Ok(value) = HeaderValue::from_str(&addr.to_string())
{
out.insert("x-forwarded-for", value);
}
if let Some(scheme) = identity.scheme.as_deref()
&& let Ok(value) = HeaderValue::from_str(scheme)
{
out.insert("x-forwarded-proto", value);
}
}
if let Ok(name) = HeaderName::from_bytes(SHADOW_HEADER.as_bytes()) {
out.insert(name, HeaderValue::from_static(SHADOW_HEADER_VALUE));
}
out
}
fn connection_named_headers(source: &HeaderMap) -> Vec<String> {
source
.get_all(axum::http::header::CONNECTION)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.map(|name| name.trim().to_ascii_lowercase())
.filter(|name| !name.is_empty())
.collect()
}
#[must_use]
pub fn shadow_url(target_base: &str, request_target: &str) -> String {
let base = target_base.trim_end_matches('/');
if request_target.starts_with('/') {
format!("{base}{request_target}")
} else {
format!("{base}/{request_target}")
}
}
pub(crate) fn decode_body(
content_encoding: Option<&str>,
body: bytes::Bytes,
max_body_bytes: usize,
) -> Result<bytes::Bytes, ShadowError> {
if body.is_empty() {
return Ok(body);
}
let Some(encoding) = content_encoding else {
return Ok(body);
};
let codings: Vec<&str> = encoding
.split(',')
.map(str::trim)
.filter(|coding| !coding.is_empty() && *coding != "identity")
.collect();
if codings.is_empty() {
return Ok(body);
}
if !codings.iter().all(|coding| is_known_coding(coding)) {
return Ok(body);
}
let mut current = body;
for coding in codings.iter().rev() {
current = decode_one(coding, ¤t, max_body_bytes)?;
}
Ok(current)
}
#[must_use]
pub(crate) fn joined_content_encoding(headers: &HeaderMap) -> Option<String> {
let codings: Vec<String> = headers
.get_all(axum::http::header::CONTENT_ENCODING)
.iter()
.filter_map(|value| value.to_str().ok())
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
.collect();
(!codings.is_empty()).then(|| codings.join(", "))
}
fn is_known_coding(coding: &str) -> bool {
matches!(coding, "gzip" | "x-gzip" | "deflate" | "br")
}
fn decode_one(
coding: &str,
body: &bytes::Bytes,
max_body_bytes: usize,
) -> Result<bytes::Bytes, ShadowError> {
use std::io::Read as _;
let limit = u64::try_from(max_body_bytes)
.unwrap_or(u64::MAX)
.saturating_add(1);
let mut decoded = Vec::new();
let read = match coding {
"gzip" | "x-gzip" => flate2::read::GzDecoder::new(body.as_ref())
.take(limit)
.read_to_end(&mut decoded),
"deflate" => flate2::read::ZlibDecoder::new(body.as_ref())
.take(limit)
.read_to_end(&mut decoded),
"br" => brotli::Decompressor::new(body.as_ref(), 4096)
.take(limit)
.read_to_end(&mut decoded),
_ => return Ok(body.clone()),
};
read.map_err(|error| {
ShadowError::Transport(format!("could not decode a {coding} body: {error}"))
})?;
if decoded.len() > max_body_bytes {
return Err(ShadowError::Oversize);
}
Ok(bytes::Bytes::from(decoded))
}
#[cfg(feature = "http-client")]
#[derive(Debug, Clone)]
pub struct HttpShadowTransport {
client: reqwest::Client,
max_body_bytes: usize,
}
#[cfg(feature = "http-client")]
impl HttpShadowTransport {
pub fn new(
timeout: std::time::Duration,
max_body_bytes: usize,
) -> Result<Self, reqwest::Error> {
Ok(Self {
client: reqwest::Client::builder()
.timeout(timeout)
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.build()?,
max_body_bytes,
})
}
}
#[cfg(feature = "http-client")]
impl ShadowTransport for HttpShadowTransport {
fn send(&self, request: ShadowRequest) -> ShadowFuture {
let client = self.client.clone();
let max_body_bytes = self.max_body_bytes;
Box::pin(async move {
let response = client
.request(request.method, &request.url)
.headers(request.headers)
.send()
.await
.map_err(|error| {
if error.is_timeout() {
ShadowError::Timeout
} else {
ShadowError::Transport(error.to_string())
}
})?;
let status = response.status().as_u16();
let content_type = response
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
let content_encoding = joined_content_encoding(response.headers());
let mut collected = bytes::BytesMut::new();
let mut response = response;
loop {
let chunk = response.chunk().await.map_err(|error| {
if error.is_timeout() {
ShadowError::Timeout
} else {
ShadowError::Transport(error.to_string())
}
})?;
let Some(chunk) = chunk else { break };
if collected.len().saturating_add(chunk.len()) > max_body_bytes {
return Err(ShadowError::Oversize);
}
collected.extend_from_slice(&chunk);
}
Ok(ResponseFacts::encoded(
status,
content_type,
content_encoding,
collected.freeze(),
))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{HeaderMap, HeaderName, HeaderValue};
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut map = HeaderMap::new();
for (name, value) in pairs {
map.insert(
HeaderName::from_bytes(name.as_bytes()).expect("valid header name"),
HeaderValue::from_str(value).expect("valid header value"),
);
}
map
}
#[test]
fn the_loop_guard_header_is_always_added() {
let forwarded = forwarded_headers(&HeaderMap::new(), None);
assert_eq!(
forwarded
.get(crate::shadow::sample::SHADOW_HEADER)
.and_then(|v| v.to_str().ok()),
Some(crate::shadow::sample::SHADOW_HEADER_VALUE)
);
}
#[test]
fn hop_by_hop_headers_are_stripped() {
let forwarded = forwarded_headers(
&headers(&[
("connection", "keep-alive"),
("keep-alive", "timeout=5"),
("proxy-authorization", "secret"),
("te", "trailers"),
("trailer", "Expires"),
("transfer-encoding", "chunked"),
("upgrade", "websocket"),
]),
None,
);
for stripped in [
"connection",
"keep-alive",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
] {
assert!(
!forwarded.contains_key(stripped),
"{stripped} must not be forwarded"
);
}
}
#[test]
fn framing_headers_are_stripped() {
let forwarded = forwarded_headers(&headers(&[("content-length", "0")]), None);
assert!(!forwarded.contains_key("content-length"));
}
#[test]
fn the_accepted_host_is_preserved() {
let forwarded = forwarded_headers(&headers(&[("host", "app.example.com")]), None);
assert_eq!(
forwarded.get("host").and_then(|v| v.to_str().ok()),
Some("app.example.com")
);
}
#[test]
fn accept_encoding_travels_so_both_stacks_answer_the_same_request() {
let forwarded = forwarded_headers(&headers(&[("accept-encoding", "gzip, br")]), None);
assert_eq!(
forwarded
.get("accept-encoding")
.and_then(|v| v.to_str().ok()),
Some("gzip, br")
);
}
#[test]
fn an_encoded_candidate_body_is_decoded_before_comparison() {
use std::io::Write as _;
let plain = b"{\"ok\":true}";
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(plain).expect("encode");
let gzipped = bytes::Bytes::from(encoder.finish().expect("finish"));
assert_ne!(
gzipped.as_ref(),
plain,
"the fixture must really be encoded"
);
let decoded = decode_body(Some("gzip"), gzipped, 4096).expect("decode");
assert_eq!(decoded.as_ref(), plain);
}
#[test]
fn repeated_content_encoding_fields_join_into_one_chain() {
let mut headers = HeaderMap::new();
headers.append(
axum::http::header::CONTENT_ENCODING,
HeaderValue::from_static("gzip"),
);
headers.append(
axum::http::header::CONTENT_ENCODING,
HeaderValue::from_static("br"),
);
assert_eq!(
joined_content_encoding(&headers).as_deref(),
Some("gzip, br")
);
}
#[test]
fn no_content_encoding_field_yields_none() {
assert_eq!(joined_content_encoding(&HeaderMap::new()), None);
}
#[test]
fn a_stacked_content_encoding_is_unwound_in_reverse() {
use std::io::Write as _;
let plain = br#"{"ok":true}"#;
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(plain).expect("gzip");
let gzipped = gz.finish().expect("finish");
let mut brotlied = Vec::new();
{
let mut writer = brotli::CompressorWriter::new(&mut brotlied, 4096, 5, 22);
writer.write_all(&gzipped).expect("br");
}
let decoded = decode_body(Some("gzip, br"), bytes::Bytes::from(brotlied), 4096)
.expect("decode chain");
assert_eq!(decoded.as_ref(), plain);
}
#[test]
fn identity_inside_a_chain_is_ignored() {
use std::io::Write as _;
let plain = b"hello";
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(plain).expect("gzip");
let gzipped = bytes::Bytes::from(gz.finish().expect("finish"));
let decoded = decode_body(Some("identity, gzip"), gzipped, 4096).expect("decode");
assert_eq!(decoded.as_ref(), plain);
}
#[test]
fn an_unknown_coding_anywhere_leaves_the_whole_chain_alone() {
let body = bytes::Bytes::from_static(b"opaque");
for encoding in ["exotic-v2", "gzip, exotic-v2", "exotic-v2, gzip"] {
assert_eq!(
decode_body(Some(encoding), body.clone(), 4096).expect("pass through"),
body,
"{encoding}"
);
}
}
#[test]
fn an_empty_body_needs_no_decoding_whatever_it_declares() {
for encoding in [
Some("gzip"),
Some("br"),
Some("deflate"),
Some("identity"),
None,
] {
assert_eq!(
decode_body(encoding, bytes::Bytes::new(), 4096),
Ok(bytes::Bytes::new()),
"{encoding:?}"
);
}
}
#[test]
fn an_unencoded_or_unknown_body_passes_through() {
let body = bytes::Bytes::from_static(b"plain");
for encoding in [None, Some("identity"), Some("exotic-v2")] {
let out = decode_body(encoding, body.clone(), 4096).expect("pass through");
assert_eq!(out, body, "{encoding:?}");
}
}
#[test]
fn a_decompression_bomb_is_refused_by_the_output_budget() {
use std::io::Write as _;
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
encoder.write_all(&vec![b'a'; 512 * 1024]).expect("encode");
let bomb = bytes::Bytes::from(encoder.finish().expect("finish"));
assert!(bomb.len() < 4096, "the fixture must be small on the wire");
assert_eq!(
decode_body(Some("gzip"), bomb, 4096),
Err(ShadowError::Oversize)
);
}
#[test]
fn the_forwarding_family_is_stripped() {
let forwarded = forwarded_headers(
&headers(&[
("x-forwarded-for", "10.0.0.1"),
("x-forwarded-host", "evil.example"),
("x-forwarded-proto", "https"),
("x-forwarded-port", "443"),
("forwarded", "for=10.0.0.1;host=evil.example"),
("x-real-ip", "10.0.0.1"),
]),
None,
);
for stripped in [
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"x-forwarded-port",
"forwarded",
"x-real-ip",
] {
assert!(
!forwarded.contains_key(stripped),
"{stripped} must not be forwarded to the candidate"
);
}
}
#[test]
fn headers_named_by_connection_are_stripped() {
let forwarded = forwarded_headers(
&headers(&[
("connection", "X-Internal-Auth, Keep-Alive"),
("x-internal-auth", "hop-scoped"),
("proxy-connection", "keep-alive"),
("accept", "application/json"),
]),
None,
);
assert!(!forwarded.contains_key("x-internal-auth"));
assert!(!forwarded.contains_key("proxy-connection"));
assert!(
forwarded.contains_key("accept"),
"unrelated headers survive"
);
}
fn identity(host: &str, addr: &str, scheme: &str) -> crate::security::ResolvedClientIdentity {
crate::security::ResolvedClientIdentity {
addr: Some(addr.parse().expect("valid ip")),
host: Some(host.to_owned()),
scheme: Some(scheme.to_owned()),
}
}
#[test]
fn the_validated_identity_replaces_the_clients_own_claims() {
let forwarded = forwarded_headers(
&headers(&[
("host", "10.0.0.7:3000"),
("x-forwarded-host", "evil.example"),
("x-forwarded-for", "203.0.113.9"),
("x-forwarded-proto", "http"),
]),
Some(&identity("app.example.com", "198.51.100.4", "https")),
);
assert_eq!(
forwarded.get("host").and_then(|v| v.to_str().ok()),
Some("app.example.com")
);
assert_eq!(
forwarded
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok()),
Some("198.51.100.4"),
"the resolved client address, not the one the client claimed"
);
assert_eq!(
forwarded
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok()),
Some("https"),
"the resolved scheme, not the one the client claimed"
);
assert!(
!forwarded.contains_key("x-forwarded-host"),
"the unvalidated header itself must never travel"
);
}
#[test]
fn no_forwarding_headers_are_synthesized_without_a_resolved_identity() {
let forwarded = forwarded_headers(
&headers(&[("x-forwarded-for", "203.0.113.9"), ("host", "app.local")]),
None,
);
assert!(!forwarded.contains_key("x-forwarded-for"));
assert_eq!(
forwarded.get("host").and_then(|v| v.to_str().ok()),
Some("app.local")
);
}
#[test]
fn without_a_resolved_host_the_raw_one_is_used() {
let forwarded = forwarded_headers(&headers(&[("host", "app.example.com")]), None);
assert_eq!(
forwarded.get("host").and_then(|v| v.to_str().ok()),
Some("app.example.com")
);
}
#[test]
fn an_inbound_loop_guard_is_replaced_not_duplicated() {
let forwarded = forwarded_headers(
&headers(&[(crate::shadow::sample::SHADOW_HEADER, "spoofed")]),
None,
);
let values: Vec<_> = forwarded
.get_all(crate::shadow::sample::SHADOW_HEADER)
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
assert_eq!(values, vec![crate::shadow::sample::SHADOW_HEADER_VALUE]);
}
#[test]
fn application_headers_including_credentials_are_forwarded() {
let forwarded = forwarded_headers(
&headers(&[
("cookie", "session=abc"),
("authorization", "Bearer t"),
("accept", "application/json"),
("x-request-id", "r-1"),
]),
None,
);
for kept in ["cookie", "authorization", "accept", "x-request-id"] {
assert!(forwarded.contains_key(kept), "{kept} must be forwarded");
}
}
#[test]
fn shadow_urls_join_without_doubling_slashes() {
assert_eq!(
shadow_url("http://127.0.0.1:9091", "/api/orders?page=2"),
"http://127.0.0.1:9091/api/orders?page=2"
);
assert_eq!(
shadow_url("http://127.0.0.1:9091", "api/orders"),
"http://127.0.0.1:9091/api/orders"
);
}
#[test]
fn shadow_errors_have_stable_metric_labels() {
assert_eq!(ShadowError::Timeout.as_str(), "timeout");
assert_eq!(ShadowError::Oversize.as_str(), "oversize");
assert_eq!(ShadowError::Transport(String::new()).as_str(), "error");
}
}