use pingora_proxy::Session;
use praxis_filter::Rejection;
use tracing::{debug, warn};
use super::path_traversal::has_dot_dot_traversal;
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];
trait AdmissionRequest {
fn version(&self) -> http::Version;
fn path(&self) -> &str;
fn headers(&self) -> &http::HeaderMap;
fn set_header(&mut self, name: http::header::HeaderName, value: http::HeaderValue);
}
struct SessionAdmission<'a> {
session: &'a mut Session,
}
impl AdmissionRequest for SessionAdmission<'_> {
fn version(&self) -> http::Version {
self.session.req_header().version
}
fn path(&self) -> &str {
self.session.req_header().uri.path()
}
fn headers(&self) -> &http::HeaderMap {
&self.session.req_header().headers
}
fn set_header(&mut self, name: http::header::HeaderName, value: http::HeaderValue) {
let _remove = self.session.req_header_mut().remove_header(name.as_str());
let _insert = self.session.req_header_mut().insert_header(name, value);
}
}
pub(in crate::http) fn admit_inbound_session(session: &mut Session) -> Option<Rejection> {
admit_inbound(&mut SessionAdmission { session })
}
fn admit_inbound(request: &mut impl AdmissionRequest) -> Option<Rejection> {
reject_unsupported_transfer_coding(request.headers())
.or_else(|| validate_host_header(request))
.or_else(|| reject_dot_dot_path(request.path()))
.or_else(|| normalize_request_headers(request))
.or_else(|| reject_reserved_client_headers(request.headers()))
}
fn reject_unsupported_transfer_coding(headers: &http::HeaderMap) -> Option<Rejection> {
if !has_unsupported_transfer_coding(headers) {
return None;
}
debug!("rejecting request with unsupported transfer coding");
Some(Rejection::status(501))
}
fn has_unsupported_transfer_coding(headers: &http::HeaderMap) -> bool {
headers.get_all(http::header::TRANSFER_ENCODING).iter().any(|value| {
value
.as_bytes()
.split(|byte| *byte == b',')
.map(<[u8]>::trim_ascii)
.filter(|token| !token.is_empty())
.any(|token| !token.eq_ignore_ascii_case(b"chunked"))
})
}
fn validate_host_header(request: &mut impl AdmissionRequest) -> Option<Rejection> {
let version = request.version();
let hosts = request.headers().get_all(http::header::HOST);
apply_host_check(request, check_host_values(version, &hosts))
}
fn apply_host_check(request: &mut impl AdmissionRequest, check: HostCheck) -> Option<Rejection> {
match check {
HostCheck::Valid => None,
HostCheck::Reject(rejection) => Some(rejection),
HostCheck::Canonicalize(canonical) => {
debug!("canonicalizing duplicate identical Host headers");
request.set_header(http::header::HOST, canonical);
None
},
}
}
enum HostCheck {
Valid,
Canonicalize(http::HeaderValue),
Reject(Rejection),
}
fn check_host_values(version: http::Version, hosts: &http::header::GetAll<'_, http::HeaderValue>) -> HostCheck {
let mut iter = hosts.iter();
let Some(first) = iter.next() else {
if version == http::Version::HTTP_11 {
debug!("rejecting HTTP/1.1 request with missing Host header");
return HostCheck::Reject(Rejection::status(400));
}
return HostCheck::Valid;
};
if first.as_bytes().iter().all(u8::is_ascii_whitespace) {
debug!("rejecting request with empty or whitespace-only Host header");
return HostCheck::Reject(Rejection::status(400));
}
if !is_valid_host_grammar(first) {
debug!("rejecting request with malformed Host header");
return HostCheck::Reject(Rejection::status(400));
}
let Some(second) = iter.next() else {
return HostCheck::Valid;
};
if second.as_bytes() != first.as_bytes() {
debug!("rejecting request with conflicting Host headers");
return HostCheck::Reject(Rejection::status(400));
}
for value in iter {
if value.as_bytes() != first.as_bytes() {
debug!("rejecting request with conflicting Host headers");
return HostCheck::Reject(Rejection::status(400));
}
}
HostCheck::Canonicalize(first.clone())
}
fn is_valid_host_grammar(value: &http::HeaderValue) -> bool {
let Ok(authority) = http::uri::Authority::try_from(value.as_bytes()) else {
return false;
};
let text = authority.as_str();
if text.contains('@') {
return false;
}
let port = if text.starts_with('[') {
let Some((_, rest)) = text.split_once(']') else {
return false;
};
if rest.is_empty() {
return true;
}
let Some(port) = rest.strip_prefix(':') else {
return false;
};
port
} else if text.contains(['[', ']']) {
return false;
} else {
let Some((_, port)) = text.split_once(':') else {
return true;
};
port
};
port.is_empty() || (port.bytes().all(|byte| byte.is_ascii_digit()) && port.parse::<u16>().is_ok())
}
fn reject_dot_dot_path(path: &str) -> Option<Rejection> {
has_dot_dot_traversal(path).then(|| {
debug!("rejecting request path with dot-dot segment");
Rejection::status(400)
})
}
fn normalize_request_headers(request: &mut impl AdmissionRequest) -> Option<Rejection> {
if let Some(r) = reject_conflicting_single_value_headers(request) {
return Some(r);
}
if let Some(r) = reject_dual_content_length_transfer_encoding(request.headers()) {
return Some(r);
}
handle_obs_fold(request)
}
fn reject_conflicting_single_value_headers(request: &mut impl AdmissionRequest) -> Option<Rejection> {
for header_name in SINGLE_VALUE_HEADERS {
let mut values = request.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");
request.set_header(header_name.clone(), first.clone());
}
None
}
fn reject_dual_content_length_transfer_encoding(headers: &http::HeaderMap) -> Option<Rejection> {
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(request: &mut impl AdmissionRequest) -> Option<Rejection> {
if !matches!(
request.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) = request.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)> = request
.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");
request.set_header(name, new_value);
}
}
None
}
fn reject_reserved_client_headers(headers: &http::HeaderMap) -> Option<Rejection> {
let reserved_count = headers
.keys()
.filter(|name| praxis_core::reserved_headers::is_reserved(name.as_str()))
.count();
if reserved_count == 0 {
return None;
}
warn!(
count = reserved_count,
"rejecting request with client-supplied reserved internal headers"
);
Some(Rejection::status(400))
}
#[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::*;
struct HeaderMapAdmission<'a> {
version: http::Version,
path: &'a str,
headers: &'a mut http::HeaderMap,
}
impl AdmissionRequest for HeaderMapAdmission<'_> {
fn version(&self) -> http::Version {
self.version
}
fn path(&self) -> &str {
self.path
}
fn headers(&self) -> &http::HeaderMap {
self.headers
}
fn set_header(&mut self, name: http::header::HeaderName, value: http::HeaderValue) {
self.headers.remove(&name);
self.headers.insert(name, value);
}
}
fn admit(version: http::Version, path: &str, headers: &mut http::HeaderMap) -> Option<Rejection> {
admit_inbound(&mut HeaderMapAdmission { version, path, headers })
}
fn te_headers(values: &[&'static str]) -> http::HeaderMap {
let mut headers = http::HeaderMap::new();
for value in values {
headers.append(http::header::TRANSFER_ENCODING, http::HeaderValue::from_static(value));
}
headers
}
async fn session_for(raw: &str) -> Session {
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
}
fn admit_session(session: &mut Session) -> Option<Rejection> {
admit_inbound_session(session)
}
#[test]
fn transfer_coding_chunked_is_supported() {
assert!(
!has_unsupported_transfer_coding(&te_headers(&["chunked"])),
"plain chunked must be accepted"
);
}
#[test]
fn transfer_coding_chunked_is_case_insensitive() {
assert!(
!has_unsupported_transfer_coding(&te_headers(&["Chunked"])),
"transfer coding names are case-insensitive"
);
}
#[test]
fn transfer_coding_compound_value_is_unsupported() {
assert!(
has_unsupported_transfer_coding(&te_headers(&["gzip, chunked"])),
"gzip in a compound value must be rejected"
);
}
#[test]
fn transfer_coding_split_across_fields_is_unsupported() {
assert!(
has_unsupported_transfer_coding(&te_headers(&["gzip", "chunked"])),
"gzip in a separate field line must be rejected"
);
}
#[test]
fn transfer_coding_repeated_chunked_is_supported() {
assert!(
!has_unsupported_transfer_coding(&te_headers(&["chunked, chunked"])),
"repeated chunked is left to Pingora framing checks"
);
}
#[test]
fn transfer_coding_absent_is_supported() {
assert!(
!has_unsupported_transfer_coding(&http::HeaderMap::new()),
"no Transfer-Encoding must be accepted"
);
}
fn single_host_check(value: &'static str) -> HostCheck {
let mut headers = http::HeaderMap::new();
headers.insert(http::header::HOST, http::HeaderValue::from_static(value));
check_host_values(http::Version::HTTP_11, &headers.get_all(http::header::HOST))
}
#[test]
fn missing_host_http11_rejected() {
let headers = http::HeaderMap::new();
let result = check_host_values(http::Version::HTTP_11, &headers.get_all(http::header::HOST));
assert!(
matches!(result, HostCheck::Reject(_)),
"HTTP/1.1 without Host must be rejected"
);
}
#[test]
fn missing_host_http10_allowed() {
let headers = http::HeaderMap::new();
let result = check_host_values(http::Version::HTTP_10, &headers.get_all(http::header::HOST));
assert!(
matches!(result, HostCheck::Valid),
"HTTP/1.0 without Host should be allowed"
);
}
#[test]
fn missing_host_http2_allowed() {
let headers = http::HeaderMap::new();
let result = check_host_values(http::Version::HTTP_2, &headers.get_all(http::header::HOST));
assert!(
matches!(result, HostCheck::Valid),
"HTTP/2 without Host should be allowed"
);
}
#[test]
fn single_valid_host_accepted() {
let mut headers = http::HeaderMap::new();
headers.insert(http::header::HOST, "example.com".parse().unwrap());
let result = check_host_values(http::Version::HTTP_11, &headers.get_all(http::header::HOST));
assert!(
matches!(result, HostCheck::Valid),
"single valid Host should be accepted"
);
}
#[test]
fn whitespace_only_host_rejected() {
let mut headers = http::HeaderMap::new();
headers.insert(http::header::HOST, " ".parse().unwrap());
let result = check_host_values(http::Version::HTTP_11, &headers.get_all(http::header::HOST));
assert!(
matches!(result, HostCheck::Reject(_)),
"whitespace-only Host must be rejected"
);
}
#[test]
fn empty_host_rejected() {
let mut headers = http::HeaderMap::new();
headers.insert(http::header::HOST, "".parse().unwrap());
let result = check_host_values(http::Version::HTTP_11, &headers.get_all(http::header::HOST));
assert!(matches!(result, HostCheck::Reject(_)), "empty Host must be rejected");
}
#[test]
fn conflicting_duplicate_hosts_rejected() {
let mut headers = http::HeaderMap::new();
headers.append(http::header::HOST, "a.example.com".parse().unwrap());
headers.append(http::header::HOST, "b.example.com".parse().unwrap());
let result = check_host_values(http::Version::HTTP_11, &headers.get_all(http::header::HOST));
assert!(
matches!(result, HostCheck::Reject(_)),
"conflicting Host headers must be rejected"
);
}
#[test]
fn three_hosts_third_conflicts_rejected() {
let mut headers = http::HeaderMap::new();
headers.append(http::header::HOST, "same.com".parse().unwrap());
headers.append(http::header::HOST, "same.com".parse().unwrap());
headers.append(http::header::HOST, "different.com".parse().unwrap());
let result = check_host_values(http::Version::HTTP_11, &headers.get_all(http::header::HOST));
assert!(
matches!(result, HostCheck::Reject(_)),
"third conflicting Host must be rejected"
);
}
#[test]
fn identical_duplicate_hosts_canonicalized() {
let mut headers = http::HeaderMap::new();
headers.append(http::header::HOST, "example.com".parse().unwrap());
headers.append(http::header::HOST, "example.com".parse().unwrap());
let result = check_host_values(http::Version::HTTP_11, &headers.get_all(http::header::HOST));
assert!(
matches!(&result, HostCheck::Canonicalize(v) if v.as_bytes() == b"example.com"),
"identical duplicate Hosts should be canonicalized"
);
}
#[test]
fn malformed_host_grammar_rejected() {
for value in ["a b", "h/p", "h:abc", "h:99999", "h:+80", "user@h", "[::1]x", "a[::1]"] {
assert!(
matches!(single_host_check(value), HostCheck::Reject(_)),
"malformed Host {value:?} must be rejected"
);
}
}
#[test]
fn well_formed_host_grammar_accepted() {
for value in [
"example.com",
"example.com.",
"example.com:443",
"example.com:",
"[::1]",
"[::1]:8080",
"localhost",
] {
assert!(
matches!(single_host_check(value), HostCheck::Valid),
"well-formed Host {value:?} must be accepted"
);
}
}
#[test]
fn dot_dot_path_rejected() {
assert!(
reject_dot_dot_path("/a/../b").is_some_and(|r| r.status == 400),
"dot-dot segment should be rejected with 400"
);
}
#[test]
fn encoded_dot_dot_path_rejected() {
assert!(
reject_dot_dot_path("/a/%2e%2e/b").is_some_and(|r| r.status == 400),
"percent-encoded dot-dot segment should be rejected with 400"
);
}
#[test]
fn route_escape_path_rejected() {
assert!(
reject_dot_dot_path("/public/../admin").is_some(),
"path escaping a prefix route should be rejected"
);
}
#[test]
fn plain_and_double_slash_paths_accepted() {
assert!(
reject_dot_dot_path("/a/b..c/d").is_none(),
"dots inside a segment are allowed"
);
assert!(
reject_dot_dot_path("//etc/passwd").is_none(),
"double slash is left to routing"
);
assert!(reject_dot_dot_path("/a/./b").is_none(), "single-dot segment is allowed");
}
#[test]
fn missing_host_http11_rejected_via_admit() {
let mut headers = http::HeaderMap::new();
assert!(matches!(
admit(http::Version::HTTP_11, "/", &mut headers),
Some(r) if r.status == 400
));
}
#[test]
fn identical_duplicate_hosts_canonicalized_then_conflicting_cl_rejected() {
let mut headers = http::HeaderMap::new();
headers.append(http::header::HOST, "example.com".parse().unwrap());
headers.append(http::header::HOST, "example.com".parse().unwrap());
headers.append(http::header::CONTENT_LENGTH, "5".parse().unwrap());
headers.append(http::header::CONTENT_LENGTH, "6".parse().unwrap());
let rejection = admit(http::Version::HTTP_11, "/", &mut headers);
assert!(
rejection.is_some_and(|r| r.status == 400),
"conflicting Content-Length must reject after Host canonicalization"
);
assert_eq!(
headers.get_all(http::header::HOST).iter().count(),
1,
"duplicate Host must be collapsed before normalization runs"
);
}
#[test]
fn conflicting_duplicate_content_type_is_rejected() {
let mut headers = http::HeaderMap::new();
headers.insert(http::header::HOST, "x".parse().unwrap());
headers.append(http::header::CONTENT_TYPE, "text/plain".parse().unwrap());
headers.append(http::header::CONTENT_TYPE, "application/json".parse().unwrap());
assert!(
admit(http::Version::HTTP_11, "/", &mut headers).is_some_and(|r| r.status == 400),
"conflicting Content-Type duplicates must reject"
);
}
#[test]
fn reserved_client_header_rejected() {
let mut headers = http::HeaderMap::new();
headers.insert(http::header::HOST, "x".parse().unwrap());
headers.insert("x-praxis-test", "1".parse().unwrap());
assert!(
admit(http::Version::HTTP_11, "/", &mut headers).is_some_and(|r| r.status == 400),
"client reserved header must reject"
);
}
#[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");
}
#[test]
fn identical_duplicate_content_type_is_canonicalized() {
let mut headers = http::HeaderMap::new();
headers.insert(http::header::HOST, "x".parse().unwrap());
headers.append(http::header::CONTENT_TYPE, "text/plain".parse().unwrap());
headers.append(http::header::CONTENT_TYPE, "text/plain".parse().unwrap());
let rejection = admit(http::Version::HTTP_11, "/", &mut headers);
assert!(rejection.is_none(), "identical duplicates must not reject");
let count = headers.get_all(http::header::CONTENT_TYPE).iter().count();
assert_eq!(count, 1, "identical duplicates must collapse to one value");
}
#[test]
fn content_length_alone_passes_normalization() {
let mut headers = http::HeaderMap::new();
headers.insert(http::header::HOST, "x".parse().unwrap());
headers.insert(http::header::CONTENT_LENGTH, "5".parse().unwrap());
let rejection = admit(http::Version::HTTP_11, "/", &mut headers);
assert!(rejection.is_none(), "Content-Length alone must pass");
}
#[test]
fn clean_requests_pass_normalization() {
let mut headers = http::HeaderMap::new();
headers.insert(http::header::HOST, "x".parse().unwrap());
headers.insert(http::header::CONTENT_TYPE, "text/plain".parse().unwrap());
let rejection = admit(http::Version::HTTP_11, "/", &mut headers);
assert!(rejection.is_none(), "well-formed requests must pass");
}
#[test]
fn dual_content_length_and_transfer_encoding_is_rejected() {
let mut headers = http::HeaderMap::new();
headers.insert(http::header::HOST, "x".parse().unwrap());
headers.insert(http::header::CONTENT_LENGTH, "5".parse().unwrap());
headers.insert(http::header::TRANSFER_ENCODING, "chunked".parse().unwrap());
assert!(
admit(http::Version::HTTP_11, "/", &mut headers).is_some_and(|r| r.status == 400),
"CL and TE together must reject"
);
}
#[tokio::test]
async fn conflicting_third_host_value_rejected() {
let mut session = session_for("GET / HTTP/1.1\r\nHost: one.example\r\n\r\n").await;
session.req_header_mut().append_header("host", "one.example").unwrap();
session.req_header_mut().append_header("host", "two.example").unwrap();
let rejection = admit_session(&mut session);
assert!(
rejection.is_some_and(|r| r.status == 400),
"a conflicting third Host value must reject with 400"
);
}
#[tokio::test]
async fn session_unsupported_transfer_coding_before_host() {
let mut session = session_for("GET / HTTP/1.1\r\nTransfer-Encoding: gzip, chunked\r\n\r\n").await;
assert_eq!(
admit_session(&mut session).map(|r| r.status),
Some(501),
"unsupported transfer coding must reject before missing Host is checked"
);
}
#[tokio::test]
async fn session_host_canonicalized_before_conflicting_content_length() {
let mut session = session_for("GET / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\n\r\n").await;
let req = session.req_header_mut();
let _duplicate_host = req.append_header("host".to_owned(), "example.com".to_owned());
let _conflicting_cl = req.append_header("content-length".to_owned(), "6".to_owned());
assert_eq!(
admit_session(&mut session).map(|r| r.status),
Some(400),
"conflicting Content-Length must reject after Host canonicalization"
);
assert_eq!(
session.req_header().headers.get_all(http::header::HOST).iter().count(),
1,
"duplicate Host must be collapsed on the session path"
);
}
#[tokio::test]
async fn session_reserved_headers_checked_last() {
let mut session =
session_for("GET / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: gzip, chunked\r\nx-praxis-test: 1\r\n\r\n")
.await;
assert_eq!(
admit_session(&mut session).map(|r| r.status),
Some(501),
"transfer coding must win over reserved header when both are present"
);
let mut session = session_for("GET / HTTP/1.1\r\nHost: x\r\nx-praxis-test: 1\r\n\r\n").await;
assert_eq!(
admit_session(&mut session).map(|r| r.status),
Some(400),
"reserved header rejects only after earlier checks pass"
);
}
}