use pingora_proxy::Session;
use praxis_filter::Rejection;
use tracing::debug;
const SINGLE_VALUE_HEADERS: &[http::header::HeaderName] = &[http::header::CONTENT_LENGTH, http::header::CONTENT_TYPE];
const OBS_FOLD_REJECT_HEADERS: &[http::header::HeaderName] = &[http::header::HOST, http::header::CONTENT_LENGTH];
pub(in crate::http) fn normalize_request_headers(session: &mut Session) -> Option<Rejection> {
if let Some(r) = reject_conflicting_single_value_headers(session) {
return Some(r);
}
if let Some(r) = reject_dual_content_length_transfer_encoding(session) {
return Some(r);
}
if let Some(r) = handle_obs_fold(session) {
return Some(r);
}
None
}
fn reject_conflicting_single_value_headers(session: &mut Session) -> Option<Rejection> {
for header_name in SINGLE_VALUE_HEADERS {
let mut values = session.req_header().headers.get_all(header_name).iter();
let Some(first) = values.next() else {
continue;
};
let first_bytes = first.as_bytes();
let mut saw_duplicate = false;
for value in values {
saw_duplicate = true;
if value.as_bytes() != first_bytes {
debug!(header = %header_name, "rejecting request with conflicting duplicate header");
return Some(Rejection::status(400));
}
}
if !saw_duplicate {
continue;
}
debug!(header = %header_name, "canonicalizing duplicate identical header");
let canonical = first.clone();
let _remove = session.req_header_mut().remove_header(header_name.as_str());
let _insert = session.req_header_mut().insert_header(header_name.clone(), canonical);
}
None
}
fn reject_dual_content_length_transfer_encoding(session: &Session) -> Option<Rejection> {
let headers = &session.req_header().headers;
if headers.contains_key(http::header::CONTENT_LENGTH) && headers.contains_key(http::header::TRANSFER_ENCODING) {
debug!("rejecting request with both Content-Length and Transfer-Encoding");
return Some(Rejection::status(400));
}
None
}
fn contains_obs_fold(value: &[u8]) -> bool {
value.windows(3).any(|w| matches!(w, [b'\r', b'\n', b' ' | b'\t']))
}
fn unfold_obs_fold(value: &[u8]) -> Vec<u8> {
let mut result = Vec::with_capacity(value.len());
let mut i = 0;
while i < value.len() {
let is_obs_fold = value.get(i) == Some(&b'\r')
&& value.get(i + 1) == Some(&b'\n')
&& matches!(value.get(i + 2), Some(b' ' | b'\t'));
if is_obs_fold {
result.push(b' ');
i += 3;
while matches!(value.get(i), Some(b' ' | b'\t')) {
i += 1;
}
} else {
if let Some(&b) = value.get(i) {
result.push(b);
}
i += 1;
}
}
result
}
fn handle_obs_fold(session: &mut Session) -> Option<Rejection> {
if !matches!(
session.req_header().version,
http::Version::HTTP_09 | http::Version::HTTP_10 | http::Version::HTTP_11
) {
return None;
}
for name in OBS_FOLD_REJECT_HEADERS {
if let Some(value) = session.req_header().headers.get(name)
&& contains_obs_fold(value.as_bytes())
{
debug!(header = %name, "rejecting request with obs-fold in security-sensitive header");
return Some(Rejection::status(400));
}
}
let headers_snapshot: Vec<(http::header::HeaderName, http::header::HeaderValue)> = session
.req_header()
.headers
.iter()
.filter(|(name, value)| !OBS_FOLD_REJECT_HEADERS.contains(name) && contains_obs_fold(value.as_bytes()))
.map(|(name, value)| (name.clone(), value.clone()))
.collect();
for (name, value) in headers_snapshot {
let unfolded = unfold_obs_fold(value.as_bytes());
if let Ok(new_value) = http::header::HeaderValue::from_bytes(&unfolded) {
debug!(header = %name, "replacing obs-fold with single SP");
let _insert = session.req_header_mut().insert_header(name, new_value);
}
}
None
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, reason = "tests")]
mod tests {
use super::*;
#[test]
fn contains_obs_fold_detects_crlf_sp() {
assert!(
contains_obs_fold(b"value\r\n continuation"),
"CRLF followed by SP is obs-fold"
);
}
#[test]
fn contains_obs_fold_detects_crlf_htab() {
assert!(
contains_obs_fold(b"value\r\n\tcontinuation"),
"CRLF followed by HTAB is obs-fold"
);
}
#[test]
fn contains_obs_fold_ignores_bare_crlf() {
assert!(
!contains_obs_fold(b"value\r\nno-fold"),
"CRLF without following whitespace is not obs-fold"
);
}
#[test]
fn contains_obs_fold_false_for_normal_value() {
assert!(
!contains_obs_fold(b"plain header value"),
"normal value has no obs-fold"
);
}
#[test]
fn contains_obs_fold_false_for_empty() {
assert!(!contains_obs_fold(b""), "empty value has no obs-fold");
}
#[test]
fn contains_obs_fold_false_for_trailing_crlf() {
assert!(
!contains_obs_fold(b"value\r\n"),
"trailing CRLF without whitespace is not obs-fold"
);
}
#[test]
fn unfold_replaces_crlf_sp_with_single_sp() {
let input = b"value\r\n continuation";
let result = unfold_obs_fold(input);
assert_eq!(result, b"value continuation", "obs-fold should become single SP");
}
#[test]
fn unfold_replaces_crlf_htab_with_single_sp() {
let input = b"value\r\n\tcontinuation";
let result = unfold_obs_fold(input);
assert_eq!(result, b"value continuation", "CRLF+HTAB should become single SP");
}
#[test]
fn unfold_collapses_multiple_whitespace_after_fold() {
let input = b"value\r\n continuation";
let result = unfold_obs_fold(input);
assert_eq!(
result, b"value continuation",
"obs-fold with extra whitespace should collapse to single SP"
);
}
#[test]
fn unfold_handles_multiple_folds() {
let input = b"a\r\n b\r\n c";
let result = unfold_obs_fold(input);
assert_eq!(result, b"a b c", "multiple obs-folds should each become single SP");
}
#[test]
fn unfold_preserves_normal_value() {
let input = b"plain value";
let result = unfold_obs_fold(input);
assert_eq!(result, b"plain value", "value without obs-fold should be unchanged");
}
#[test]
fn unfold_preserves_empty() {
let result = unfold_obs_fold(b"");
assert!(result.is_empty(), "empty input should produce empty output");
}
#[test]
fn contains_obs_fold_single_crlf_sp() {
assert!(
contains_obs_fold(b"\r\n value"),
"CRLF+SP at the very start of the value is obs-fold"
);
}
#[test]
fn contains_obs_fold_multiple_folds() {
assert!(
contains_obs_fold(b"a\r\n b\r\n c"),
"value with multiple obs-fold sequences should be detected"
);
}
#[test]
fn contains_obs_fold_only_cr_no_lf() {
assert!(
!contains_obs_fold(b"value\r continuation"),
"bare CR followed by space is not obs-fold"
);
}
#[test]
fn contains_obs_fold_only_lf_sp() {
assert!(
!contains_obs_fold(b"value\n continuation"),
"bare LF followed by space is not obs-fold"
);
}
#[test]
fn unfold_at_start_of_value() {
let result = unfold_obs_fold(b"\r\n continuation");
assert_eq!(
result, b" continuation",
"obs-fold at the very start should become single SP"
);
}
#[test]
fn unfold_consecutive_folds() {
let result = unfold_obs_fold(b"a\r\n \r\n b");
assert_eq!(
result, b"a b",
"two back-to-back obs-folds should each become single SP"
);
}
#[test]
fn unfold_mixed_whitespace_after_fold() {
let result = unfold_obs_fold(b"val\r\n\t rest");
assert_eq!(
result, b"val rest",
"CRLF followed by tab then spaces should collapse to single SP"
);
}
#[test]
fn unfold_preserves_internal_crlf_without_continuation() {
let result = unfold_obs_fold(b"before\r\nafter");
assert_eq!(
result, b"before\r\nafter",
"bare CRLF without following whitespace should be kept as-is"
);
}
#[test]
fn unfold_single_byte_values() {
assert_eq!(unfold_obs_fold(b"x"), b"x", "single byte input unchanged");
assert_eq!(unfold_obs_fold(b"ab"), b"ab", "two byte input unchanged");
}
async fn session_for(raw: &str) -> (Session, tokio::io::DuplexStream) {
use tokio::io::AsyncWriteExt as _;
let (mut client, server) = tokio::io::duplex(1_048_576);
client.write_all(raw.as_bytes()).await.unwrap();
let mut session = Session::new_h1(Box::new(server));
let read = session.read_request().await.unwrap();
assert!(read, "the session must parse the request header");
(session, client)
}
#[tokio::test]
async fn identical_duplicate_content_type_is_canonicalized() {
let (mut session, _client) = session_for("GET / HTTP/1.1\r\nHost: x\r\n\r\n").await;
session
.req_header_mut()
.append_header("content-type", "text/plain")
.unwrap();
session
.req_header_mut()
.append_header("content-type", "text/plain")
.unwrap();
let rejection = normalize_request_headers(&mut session);
assert!(rejection.is_none(), "identical duplicates must not reject");
let count = session
.req_header()
.headers
.get_all(http::header::CONTENT_TYPE)
.iter()
.count();
assert_eq!(count, 1, "identical duplicates must collapse to one value");
}
#[tokio::test]
async fn conflicting_duplicate_content_type_is_rejected() {
let (mut session, _client) = session_for("GET / HTTP/1.1\r\nHost: x\r\n\r\n").await;
session
.req_header_mut()
.append_header("content-type", "text/plain")
.unwrap();
session
.req_header_mut()
.append_header("content-type", "application/json")
.unwrap();
let rejection = normalize_request_headers(&mut session);
assert!(
rejection.is_some_and(|r| r.status == 400),
"conflicting duplicates must reject with 400"
);
}
#[tokio::test]
async fn dual_content_length_and_transfer_encoding_is_rejected() {
let (mut session, _client) = session_for("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello").await;
session
.req_header_mut()
.insert_header("transfer-encoding", "chunked")
.unwrap();
let rejection = normalize_request_headers(&mut session);
assert!(
rejection.is_some_and(|r| r.status == 400),
"requests with both Content-Length and Transfer-Encoding must be rejected"
);
}
#[tokio::test]
async fn content_length_alone_passes_normalization() {
let (mut session, _client) = session_for("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello").await;
let rejection = normalize_request_headers(&mut session);
assert!(rejection.is_none(), "Content-Length alone must pass");
}
#[tokio::test]
async fn clean_requests_pass_normalization() {
let (mut session, _client) = session_for("GET / HTTP/1.1\r\nHost: x\r\nContent-Type: text/plain\r\n\r\n").await;
let rejection = normalize_request_headers(&mut session);
assert!(rejection.is_none(), "well-formed requests must pass");
}
}