use std::collections::HashSet;
use std::net::IpAddr;
use hyper::header::{HeaderName, HeaderValue};
use plecto_control::{Header, HttpRequest};
const HOP_BY_HOP: &[&str] = &[
"connection",
"keep-alive",
"proxy-connection",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"proxy-authorization",
"proxy-authenticate",
];
fn is_hop_by_hop(name: &str) -> bool {
HOP_BY_HOP.iter().any(|h| name.eq_ignore_ascii_case(h))
}
fn connection_named(map: &hyper::HeaderMap) -> HashSet<String> {
let mut named = HashSet::new();
for value in map.get_all(hyper::header::CONNECTION).iter() {
if let Ok(s) = value.to_str() {
for token in s.split(',') {
let token = token.trim();
if !token.is_empty()
&& !token.eq_ignore_ascii_case("close")
&& !token.eq_ignore_ascii_case("keep-alive")
{
named.insert(token.to_ascii_lowercase());
}
}
}
}
named
}
pub(crate) fn upgrade_request_header(map: &hyper::HeaderMap) -> Option<&str> {
if !connection_named(map).contains("upgrade") {
return None;
}
map.get(hyper::header::UPGRADE)?.to_str().ok()
}
pub(crate) fn te_requests_trailers(map: &hyper::HeaderMap) -> bool {
map.get_all(hyper::header::TE).iter().any(|value| {
value.to_str().is_ok_and(|s| {
s.split(',').any(|token| {
token
.split(';')
.next()
.is_some_and(|name| name.trim().eq_ignore_ascii_case("trailers"))
})
})
})
}
const FORWARDED_HEADERS: &[&str] = &[
"forwarded",
"x-forwarded-for",
"x-forwarded-proto",
"x-forwarded-host",
"x-real-ip",
"true-client-ip",
"cf-connecting-ip",
"fastly-client-ip",
"x-client-ip",
"x-cluster-client-ip",
];
pub(crate) fn set_forwarded(headers: &mut hyper::HeaderMap, peer: IpAddr, scheme: &str) {
for name in FORWARDED_HEADERS {
headers.remove(*name);
}
let client_ip = match peer {
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
Some(v4) => v4.to_string(),
None => v6.to_string(),
},
IpAddr::V4(v4) => v4.to_string(),
};
if let Ok(ip_value) = HeaderValue::from_str(&client_ip) {
headers.insert("x-forwarded-for", ip_value.clone());
headers.insert("x-real-ip", ip_value);
}
if let Ok(proto) = HeaderValue::from_str(scheme) {
headers.insert("x-forwarded-proto", proto);
}
}
pub(crate) fn to_http_request(parts: &hyper::http::request::Parts, scheme: &str) -> HttpRequest {
let path = parts
.uri
.path_and_query()
.map(|pq| pq.as_str().to_string())
.unwrap_or_else(|| "/".to_string());
HttpRequest {
method: parts.method.as_str().to_string(),
path,
authority: request_authority(parts),
scheme: scheme.to_string(),
headers: headers_to_vec(&parts.headers),
}
}
pub(crate) fn request_authority(parts: &hyper::http::request::Parts) -> String {
parts
.uri
.authority()
.map(|a| a.to_string())
.or_else(|| {
parts
.headers
.get(hyper::header::HOST)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
})
.unwrap_or_default()
}
pub(crate) fn headers_to_vec(map: &hyper::HeaderMap) -> Vec<Header> {
let named = connection_named(map);
map.iter()
.filter(|(name, _)| {
!is_hop_by_hop(name.as_str()) && (named.is_empty() || !named.contains(name.as_str()))
})
.map(|(name, value)| Header {
name: name.as_str().to_string(),
value: value.as_bytes().to_vec(),
})
.collect()
}
pub(crate) fn copy_headers(dst: Option<&mut hyper::HeaderMap>, headers: &[Header]) {
copy_headers_filtered(dst, headers, false);
}
pub(crate) fn copy_headers_synth(dst: Option<&mut hyper::HeaderMap>, headers: &[Header]) {
copy_headers_filtered(dst, headers, true);
}
fn copy_headers_filtered(
dst: Option<&mut hyper::HeaderMap>,
headers: &[Header],
strip_content_length: bool,
) {
let Some(dst) = dst else { return };
for h in headers {
if is_hop_by_hop(&h.name) {
continue;
}
if strip_content_length && h.name.eq_ignore_ascii_case("content-length") {
continue;
}
if let (Ok(name), Ok(value)) = (
HeaderName::from_bytes(h.name.as_bytes()),
HeaderValue::from_bytes(&h.value),
) {
dst.append(name, value);
}
}
}
pub(crate) fn copy_headers_direct(dst: Option<&mut hyper::HeaderMap>, src: &hyper::HeaderMap) {
let Some(dst) = dst else { return };
let named = connection_named(src);
for (name, value) in src {
if is_hop_by_hop(name.as_str()) || (!named.is_empty() && named.contains(name.as_str())) {
continue;
}
dst.append(name.clone(), value.clone());
}
}
#[cfg(test)]
mod tests {
use super::*;
use hyper::Request;
fn parts(authority_in_uri: bool) -> hyper::http::request::Parts {
let uri = if authority_in_uri {
"https://h2.example/api/x"
} else {
"/api/x"
};
Request::builder()
.method("GET")
.uri(uri)
.header("host", "h1.example")
.body(())
.unwrap()
.into_parts()
.0
}
#[test]
fn scheme_reflects_tls_termination_not_a_hardcoded_value() {
assert_eq!(to_http_request(&parts(false), "https").scheme, "https");
assert_eq!(to_http_request(&parts(false), "http").scheme, "http");
}
#[test]
fn authority_prefers_h2_uri_authority_then_falls_back_to_host() {
assert_eq!(
to_http_request(&parts(true), "https").authority,
"h2.example"
);
assert_eq!(
to_http_request(&parts(false), "http").authority,
"h1.example"
);
}
fn header(name: &str, value: &str) -> Header {
Header {
name: name.to_string(),
value: value.as_bytes().to_vec(),
}
}
#[test]
fn hop_by_hop_set_is_recognised_case_insensitively() {
for h in [
"connection",
"Keep-Alive",
"PROXY-CONNECTION",
"Transfer-Encoding",
"te",
"Trailer",
"upgrade",
"Proxy-Authorization",
"Proxy-Authenticate",
] {
assert!(is_hop_by_hop(h), "{h} must be treated as hop-by-hop");
}
assert!(!is_hop_by_hop("x-api-key"));
assert!(!is_hop_by_hop("content-type"));
}
#[test]
fn te_requests_trailers_matches_the_token_not_the_whole_value() {
for value in [
"trailers",
"Trailers",
"gzip, trailers",
"trailers;q=1",
"gzip;q=0.5, trailers",
] {
let mut map = hyper::HeaderMap::new();
map.insert(hyper::header::TE, HeaderValue::from_str(value).unwrap());
assert!(te_requests_trailers(&map), "{value:?} requests trailers");
}
for value in ["gzip", "compress, deflate", "trailersx"] {
let mut map = hyper::HeaderMap::new();
map.insert(hyper::header::TE, HeaderValue::from_str(value).unwrap());
assert!(!te_requests_trailers(&map), "{value:?} must not match");
}
assert!(
!te_requests_trailers(&hyper::HeaderMap::new()),
"no TE header, no trailers request"
);
}
#[test]
fn headers_to_vec_strips_hop_by_hop_on_ingress() {
let mut map = hyper::HeaderMap::new();
map.insert("x-keep", HeaderValue::from_static("1"));
map.insert(
hyper::header::CONNECTION,
HeaderValue::from_static("keep-alive"),
);
map.insert(
hyper::header::TRANSFER_ENCODING,
HeaderValue::from_static("chunked"),
);
map.insert(hyper::header::TE, HeaderValue::from_static("trailers"));
let out = headers_to_vec(&map);
assert!(
out.iter().all(|h| !is_hop_by_hop(&h.name)),
"no hop-by-hop header may survive the ingress conversion"
);
assert!(
out.iter().any(|h| h.name == "x-keep"),
"an end-to-end header is preserved"
);
}
#[test]
fn headers_to_vec_strips_connection_named_headers() {
let mut map = hyper::HeaderMap::new();
map.insert(
hyper::header::CONNECTION,
HeaderValue::from_static("X-Secret, close"),
);
map.append("x-secret", HeaderValue::from_static("leak"));
map.insert("x-keep", HeaderValue::from_static("1"));
let out = headers_to_vec(&map);
assert!(
!out.iter().any(|h| h.name.eq_ignore_ascii_case("x-secret")),
"a Connection-named header must be stripped"
);
assert!(
!out.iter()
.any(|h| h.name.eq_ignore_ascii_case("connection")),
"Connection itself is hop-by-hop"
);
assert!(
out.iter().any(|h| h.name == "x-keep"),
"an unrelated end-to-end header survives"
);
}
#[test]
fn copy_headers_forwards_non_utf8_bytes() {
let raw: &[u8] = &[0xC3, 0x28]; let contract = vec![Header {
name: "x-blob".to_string(),
value: raw.to_vec(),
}];
let mut dst = hyper::HeaderMap::new();
copy_headers(Some(&mut dst), &contract);
assert_eq!(
dst.get("x-blob").map(|v| v.as_bytes()),
Some(raw),
"a byte-valued header forwards byte-for-byte"
);
}
#[test]
fn set_forwarded_overwrites_spoofed_client_headers() {
let mut headers = hyper::HeaderMap::new();
headers.append("x-forwarded-for", HeaderValue::from_static("9.9.9.9"));
headers.append("x-forwarded-for", HeaderValue::from_static("8.8.4.4"));
headers.insert("forwarded", HeaderValue::from_static("for=10.0.0.1"));
headers.insert("x-real-ip", HeaderValue::from_static("9.9.9.9"));
headers.insert("cf-connecting-ip", HeaderValue::from_static("8.8.8.8"));
headers.insert("x-keep", HeaderValue::from_static("1"));
set_forwarded(&mut headers, "203.0.113.5".parse().unwrap(), "https");
let xff: Vec<&HeaderValue> = headers.get_all("x-forwarded-for").iter().collect();
assert_eq!(
xff,
vec!["203.0.113.5"],
"the spoofed XFF (all values) is replaced by the real peer (one value, not appended)"
);
assert_eq!(
headers.get("x-real-ip").map(HeaderValue::as_bytes),
Some(b"203.0.113.5".as_ref()),
"the spoofed X-Real-IP is replaced by the real peer (re-issued)"
);
assert!(
!headers.contains_key("forwarded"),
"a spoofed Forwarded header is stripped"
);
assert!(
!headers.contains_key("cf-connecting-ip"),
"a spoofed CDN client-IP header is stripped and not re-issued"
);
assert_eq!(
headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok()),
Some("https"),
"X-Forwarded-Proto reflects the connection scheme"
);
assert!(
headers.contains_key("x-keep"),
"an unrelated header is left intact"
);
}
#[test]
fn set_forwarded_normalises_ipv4_mapped_peer() {
let mut headers = hyper::HeaderMap::new();
set_forwarded(&mut headers, "::ffff:203.0.113.5".parse().unwrap(), "https");
for name in ["x-forwarded-for", "x-real-ip"] {
assert_eq!(
headers.get(name).and_then(|v| v.to_str().ok()),
Some("203.0.113.5"),
"an IPv4-mapped peer normalises to dotted IPv4 in {name}"
);
}
let mut headers = hyper::HeaderMap::new();
set_forwarded(&mut headers, "2001:db8::1".parse().unwrap(), "https");
assert_eq!(
headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()),
Some("2001:db8::1"),
"a real IPv6 peer is kept as-is"
);
}
#[test]
fn copy_headers_drops_hop_by_hop_crlf_and_malformed_names() {
let mut dst = hyper::HeaderMap::new();
copy_headers(
Some(&mut dst),
&[
header("x-ok", "fine"),
header("transfer-encoding", "chunked"), header("x-evil", "a\r\nInjected: pwned"), header("bad name", "x"), header("", "x"), ],
);
assert_eq!(
dst.get("x-ok").and_then(|v| v.to_str().ok()),
Some("fine"),
"a valid end-to-end header is copied"
);
assert!(
!dst.contains_key("transfer-encoding"),
"a filter cannot re-introduce a hop-by-hop header"
);
assert!(
!dst.contains_key("x-evil") && !dst.contains_key("injected"),
"a CRLF-bearing value is rejected, not split into a second header"
);
assert_eq!(dst.len(), 1, "only the one valid header survives");
}
}