use http::HeaderMap;
use tracing::debug;
pub(crate) const REQUEST_HOP_BY_HOP: &[&str] = praxis_core::reserved_headers::HOP_BY_HOP_HEADERS;
pub(crate) const RESPONSE_HOP_BY_HOP: &[&str] = praxis_core::reserved_headers::RESPONSE_HOP_BY_HOP_HEADERS;
pub(crate) fn preserve_for_upgrade(name: &str, is_websocket_upgrade: bool) -> bool {
is_websocket_upgrade && (name == "upgrade" || name == "connection")
}
pub(crate) fn is_websocket_upgrade(value: &str) -> bool {
value.trim().eq_ignore_ascii_case("websocket")
}
pub(crate) fn has_websocket_upgrade(headers: &HeaderMap) -> bool {
let mut values = headers.get_all(http::header::UPGRADE).iter();
match (values.next(), values.next()) {
(Some(value), None) => value.to_str().is_ok_and(is_websocket_upgrade),
_ => false,
}
}
pub(crate) fn declares_chunked_framing(headers: &HeaderMap) -> bool {
headers
.get_all(http::header::TRANSFER_ENCODING)
.iter()
.next_back()
.and_then(|value| value.as_bytes().rsplit(|&b| b == b',').next())
.is_some_and(|token| trim_ascii(token).eq_ignore_ascii_case(b"chunked"))
}
fn trim_ascii(bytes: &[u8]) -> &[u8] {
let start = bytes
.iter()
.position(|b| !b.is_ascii_whitespace())
.unwrap_or(bytes.len());
let end = bytes
.iter()
.rposition(|b| !b.is_ascii_whitespace())
.map_or(start, |i| i + 1);
bytes.get(start..end).unwrap_or(&[])
}
pub(crate) fn should_restore_chunked_framing(headers: &HeaderMap, was_chunked: bool) -> bool {
was_chunked && !headers.contains_key(http::header::CONTENT_LENGTH)
}
pub(crate) fn snapshot_connection_values(headers: &HeaderMap) -> Vec<http::HeaderValue> {
headers.get_all("connection").iter().cloned().collect()
}
pub(crate) fn strip_connection_tokens<R: RemoveHeader>(
msg: &mut R,
values: &[http::HeaderValue],
static_list: &[&str],
) {
for val in values {
for trimmed in praxis_core::reserved_headers::connection_tokens(val) {
if static_list.iter().any(|h| trimmed.eq_ignore_ascii_case(h)) {
continue;
}
if praxis_core::reserved_headers::is_connection_token_protected(trimmed) {
debug!(
header = trimmed,
"refusing to strip proxy-owned or essential header named in Connection token"
);
continue;
}
msg.remove_header_by_name(trimmed);
}
}
}
pub(crate) fn strip_hop_by_hop_header_map(headers: &mut HeaderMap, static_list: &[&str]) {
let connection_values = snapshot_connection_values(headers);
for name in static_list {
headers.remove(*name);
}
for value in &connection_values {
for token in praxis_core::reserved_headers::connection_tokens(value) {
if !static_list.iter().any(|name| token.eq_ignore_ascii_case(name))
&& !praxis_core::reserved_headers::is_connection_token_protected(token)
{
headers.remove(token);
}
}
}
}
pub(crate) fn strip_reserved_internal_header_map(headers: &mut HeaderMap) {
let to_remove: Vec<http::HeaderName> = headers
.keys()
.filter(|name| super::reserved_headers::is_reserved_internal_header(name))
.cloned()
.collect();
for name in &to_remove {
headers.remove(name);
}
if !to_remove.is_empty() {
debug!(
count = to_remove.len(),
direction = "response",
"stripped reserved internal headers from client-bound response"
);
}
}
pub(crate) trait RemoveHeader {
const DIRECTION: &'static str;
fn headers(&self) -> &HeaderMap;
fn remove_header_by_name(&mut self, name: &str);
fn strip_reserved_internal(&mut self) {
let to_remove: Vec<http::HeaderName> = self
.headers()
.keys()
.filter(|name| super::reserved_headers::is_reserved_internal_header(name))
.cloned()
.collect();
for name in &to_remove {
self.remove_header_by_name(name.as_str());
}
if !to_remove.is_empty() {
debug!(
count = to_remove.len(),
direction = Self::DIRECTION,
"stripped reserved internal headers"
);
}
}
}
impl RemoveHeader for pingora_http::RequestHeader {
const DIRECTION: &'static str = "request";
fn headers(&self) -> &HeaderMap {
&self.headers
}
fn remove_header_by_name(&mut self, name: &str) {
drop(self.remove_header(name));
}
}
impl RemoveHeader for pingora_http::ResponseHeader {
const DIRECTION: &'static str = "response";
fn headers(&self) -> &HeaderMap {
&self.headers
}
fn remove_header_by_name(&mut self, name: &str) {
drop(self.remove_header(name));
}
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::unwrap_used, reason = "tests")]
mod tests {
use super::*;
#[test]
fn request_hop_by_hop_matches_canonical_core_set() {
assert_eq!(
REQUEST_HOP_BY_HOP,
praxis_core::reserved_headers::HOP_BY_HOP_HEADERS,
"request hop-by-hop list must be the canonical core set"
);
}
#[test]
fn response_hop_by_hop_matches_canonical_core_set() {
assert_eq!(
RESPONSE_HOP_BY_HOP,
praxis_core::reserved_headers::RESPONSE_HOP_BY_HOP_HEADERS,
"response hop-by-hop list must be the canonical core set"
);
}
#[test]
fn strip_reserved_internal_header_map_removes_reserved_keeps_others() {
let mut headers = HeaderMap::new();
headers.insert("x-praxis-route", http::HeaderValue::from_static("internal-cluster"));
headers.insert("x-ext-protocol-foo", http::HeaderValue::from_static("meta"));
headers.insert("content-type", http::HeaderValue::from_static("text/plain"));
strip_reserved_internal_header_map(&mut headers);
assert!(
!headers.contains_key("x-praxis-route"),
"reserved x-praxis-* header must be stripped from a terminal response"
);
assert!(
!headers.contains_key("x-ext-protocol-foo"),
"reserved x-ext-* header must be stripped from a terminal response"
);
assert_eq!(
headers.get("content-type").map(http::HeaderValue::as_bytes),
Some(b"text/plain".as_slice()),
"non-reserved headers must be preserved"
);
}
#[test]
fn strip_reserved_internal_header_map_cleans_response_trailers() {
let mut trailers = HeaderMap::new();
trailers.insert("x-praxis-foo", http::HeaderValue::from_static("leak"));
trailers.insert("x-ext-agent-x", http::HeaderValue::from_static("leak"));
trailers.insert("grpc-status", http::HeaderValue::from_static("0"));
strip_reserved_internal_header_map(&mut trailers);
assert_eq!(trailers.len(), 1, "only the non-reserved trailer must remain");
assert_eq!(
trailers.get("grpc-status").map(http::HeaderValue::as_bytes),
Some(b"0".as_slice()),
"grpc-status trailer must be preserved"
);
}
#[test]
fn declares_chunked_framing_matches_plain_and_compound() {
let mut plain = HeaderMap::new();
plain.insert(
http::header::TRANSFER_ENCODING,
http::HeaderValue::from_static("chunked"),
);
assert!(declares_chunked_framing(&plain));
let mut compound = HeaderMap::new();
compound.insert(
http::header::TRANSFER_ENCODING,
http::HeaderValue::from_static("gzip, chunked"),
);
assert!(declares_chunked_framing(&compound));
}
#[test]
fn declares_chunked_framing_rejects_non_chunked() {
let mut gzip = HeaderMap::new();
gzip.insert(http::header::TRANSFER_ENCODING, http::HeaderValue::from_static("gzip"));
assert!(!declares_chunked_framing(&gzip));
assert!(!declares_chunked_framing(&HeaderMap::new()));
}
#[test]
fn declares_chunked_framing_handles_obs_text_bytes() {
let mut obs = HeaderMap::new();
obs.insert(
http::header::TRANSFER_ENCODING,
http::HeaderValue::from_bytes(b"\xa0x, chunked").unwrap(),
);
assert!(
declares_chunked_framing(&obs),
"obs-text in an earlier token must not hide the trailing chunked token"
);
}
#[test]
fn websocket_lowercase_is_upgrade() {
assert!(
is_websocket_upgrade("websocket"),
"lowercase 'websocket' should be recognized"
);
}
#[test]
fn websocket_uppercase_is_upgrade() {
assert!(
is_websocket_upgrade("WEBSOCKET"),
"uppercase 'WEBSOCKET' should be recognized"
);
}
#[test]
fn websocket_mixed_case_is_upgrade() {
assert!(
is_websocket_upgrade("WebSocket"),
"mixed-case 'WebSocket' should be recognized per RFC 6455"
);
}
#[test]
fn websocket_with_whitespace_is_upgrade() {
assert!(
is_websocket_upgrade(" websocket "),
"whitespace-padded 'websocket' should be recognized"
);
}
#[test]
fn h2c_is_not_websocket_upgrade() {
assert!(
!is_websocket_upgrade("h2c"),
"h2c upgrade must be rejected to prevent smuggling"
);
}
#[test]
fn mixed_h2c_websocket_is_not_upgrade() {
assert!(
!is_websocket_upgrade("h2c, websocket"),
"mixed upgrade values must be rejected"
);
}
#[test]
fn empty_value_is_not_upgrade() {
assert!(
!is_websocket_upgrade(""),
"empty upgrade value should not be recognized"
);
}
#[test]
fn arbitrary_protocol_is_not_upgrade() {
assert!(
!is_websocket_upgrade("SMTP"),
"arbitrary protocol should not be recognized"
);
}
#[test]
fn has_websocket_upgrade_case_insensitive() {
let mut headers = HeaderMap::new();
headers.insert("upgrade", "WebSocket".parse().unwrap());
assert!(
has_websocket_upgrade(&headers),
"should detect mixed-case WebSocket in header map"
);
}
#[test]
fn has_websocket_upgrade_missing_header() {
let headers = HeaderMap::new();
assert!(
!has_websocket_upgrade(&headers),
"should return false when upgrade header is missing"
);
}
#[test]
fn has_websocket_upgrade_non_websocket() {
let mut headers = HeaderMap::new();
headers.insert("upgrade", "h2c".parse().unwrap());
assert!(
!has_websocket_upgrade(&headers),
"should return false for non-websocket upgrade"
);
}
#[test]
fn duplicate_upgrade_headers_are_not_websocket() {
let mut headers = HeaderMap::new();
headers.append("upgrade", "websocket".parse().unwrap());
headers.append("upgrade", "h2c".parse().unwrap());
assert!(
!has_websocket_upgrade(&headers),
"duplicate Upgrade headers must not be recognized as a WebSocket upgrade (h2c smuggling)"
);
}
#[test]
fn duplicate_upgrade_headers_websocket_first_or_last() {
let mut headers = HeaderMap::new();
headers.append("upgrade", "h2c".parse().unwrap());
headers.append("upgrade", "websocket".parse().unwrap());
assert!(
!has_websocket_upgrade(&headers),
"duplicate Upgrade headers must not be recognized regardless of order"
);
}
#[test]
fn strip_removes_custom_but_keeps_proxy_owned_and_essential() {
let mut rec = Recorder {
removed: vec![],
headers: HeaderMap::new(),
};
let values = vec![http::HeaderValue::from_static(
"x-app-state, x-forwarded-for, forwarded, x-praxis-route, host, content-length",
)];
strip_connection_tokens(&mut rec, &values, REQUEST_HOP_BY_HOP);
assert!(
rec.removed.contains(&"x-app-state".to_owned()),
"custom header should be stripped"
);
for protected in [
"x-forwarded-for",
"forwarded",
"x-praxis-route",
"host",
"content-length",
] {
assert!(
!rec.removed.iter().any(|h| h == protected),
"{protected} must not be strippable via a Connection token"
);
}
}
#[test]
fn connection_token_survives_obs_text_sibling() {
let mut headers = HeaderMap::new();
headers.insert(
http::header::CONNECTION,
http::HeaderValue::from_bytes(b"x-backend-internal, \x80").unwrap(),
);
let mut recorder = Recorder {
removed: Vec::new(),
headers: headers.clone(),
};
let values = snapshot_connection_values(&headers);
strip_connection_tokens(&mut recorder, &values, REQUEST_HOP_BY_HOP);
assert_eq!(
recorder.removed,
["x-backend-internal"],
"a non-UTF-8 sibling token must not keep a nominated header"
);
headers.insert("x-backend-internal", http::HeaderValue::from_static("secret"));
strip_hop_by_hop_header_map(&mut headers, RESPONSE_HOP_BY_HOP);
assert!(
!headers.contains_key("x-backend-internal"),
"terminal responses must strip the nominated header too"
);
}
struct Recorder {
removed: Vec<String>,
headers: HeaderMap,
}
impl RemoveHeader for Recorder {
const DIRECTION: &'static str = "request";
fn headers(&self) -> &HeaderMap {
&self.headers
}
fn remove_header_by_name(&mut self, name: &str) {
self.removed.push(name.to_ascii_lowercase());
}
}
}