use std::{borrow::Cow, io::Write, str::from_utf8};
use kawa::{
Block, BodySize, Flags, Kind, Pair, ParsingPhase, StatusLine, Store, Version,
h1::ParserCallbacks, repr::Slice,
};
use sozu_command::logging::ansi_palette;
use crate::metrics::names;
use crate::{
pool::Checkout,
protocol::{
http::parser::compare_no_case,
mux::{
GenericHttpStream, StreamId,
h2::Prioriser,
parser::{H2Error, PriorityPart},
},
},
};
macro_rules! log_module_context {
() => {{
let (open, reset, _, _, _) = ansi_palette();
format!("{open}MUX-PKAWA{reset}\t >>>", open = open, reset = reset)
}};
}
macro_rules! store_or_reject {
($field:expr, $regular:expr, $kawa:expr, $value:expr, $invalid:expr) => {
match store_pseudo_header(&$field, $regular, $kawa, &$value) {
Ok(s) => $field = s,
Err(reason) => {
metric_reject(reason);
*$invalid = true;
}
}
};
}
pub const MAX_TRAILER_BYTES: usize = 8 * 1024;
#[cfg(test)]
fn has_uppercase_ascii(name: &[u8]) -> bool {
name.iter().any(|b| b.is_ascii_uppercase())
}
fn is_tchar(b: u8) -> bool {
matches!(
b,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'0'..=b'9'
| b'A'..=b'Z'
| b'^'
| b'_'
| b'`'
| b'a'..=b'z'
| b'|'
| b'~'
)
}
fn has_invalid_name_byte(name: &[u8]) -> bool {
name.iter().any(|&b| b.is_ascii_uppercase() || !is_tchar(b))
}
pub(super) fn is_connection_specific_header(name: &[u8]) -> bool {
match name.first() {
Some(b'c' | b'C') => compare_no_case(name, b"connection"),
Some(b'p' | b'P') => compare_no_case(name, b"proxy-connection"),
Some(b't' | b'T') => compare_no_case(name, b"transfer-encoding"),
Some(b'u' | b'U') => compare_no_case(name, b"upgrade"),
Some(b'k' | b'K') => compare_no_case(name, b"keep-alive"),
_ => false,
}
}
fn is_invalid_te_value(value: &[u8]) -> bool {
!compare_no_case(value, b"trailers")
}
fn strip_port(value: &[u8]) -> &[u8] {
let in_len = value.len();
if value.contains(&b'[') {
let stripped = match value.iter().rposition(|&b| b == b']') {
Some(bracket) => {
if value.get(bracket + 1) == Some(&b':')
&& bracket + 2 < value.len()
&& value[bracket + 2..].iter().all(|b| b.is_ascii_digit())
{
&value[..bracket + 1]
} else {
value
}
}
None => value,
};
debug_assert!(
stripped.len() <= in_len,
"strip_port must not grow its input (IPv6 path)"
);
debug_assert!(
stripped.as_ptr() == value.as_ptr(),
"strip_port returns a prefix of value (shares its start)"
);
return stripped;
}
let stripped = match value.iter().rposition(|&b| b == b':') {
Some(i) if i + 1 < value.len() && value[i + 1..].iter().all(|b| b.is_ascii_digit()) => {
&value[..i]
}
_ => value,
};
debug_assert!(
stripped.len() <= in_len,
"strip_port must not grow its input"
);
debug_assert!(
stripped.len() == in_len || stripped.len() + 2 <= in_len,
"a real port strip removes at least a colon and one digit"
);
stripped
}
fn host_matches_authority(host: &[u8], authority: &[u8]) -> bool {
if compare_no_case(host, authority) {
return true;
}
let host_stripped = strip_port(host);
let auth_stripped = strip_port(authority);
debug_assert!(
host_stripped.len() <= host.len(),
"stripped host cannot grow"
);
debug_assert!(
auth_stripped.len() <= authority.len(),
"stripped authority cannot grow"
);
let host_has_port = host_stripped.len() != host.len();
let auth_has_port = auth_stripped.len() != authority.len();
if host_has_port && auth_has_port {
return false;
}
compare_no_case(host_stripped, auth_stripped)
}
fn has_invalid_pseudo_value_byte(value: &[u8]) -> bool {
value.iter().any(|&b| matches!(b, 0x00..=0x1F | 0x7F))
}
#[cfg(test)]
fn has_invalid_value_byte(value: &[u8]) -> bool {
value
.iter()
.any(|&b| matches!(b, 0x00..=0x08 | 0x0A..=0x1F | 0x7F))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum RejectReason {
InvalidNameByte,
ConnectionSpecificHeader,
TeNotTrailers,
CrlfInValue,
NulInValue,
OversizedPseudoValue,
InvalidMethod,
InvalidScheme,
InvalidPath,
InvalidStatus,
ClTeConflict,
DuplicateCl,
DuplicatePseudo,
PseudoAfterRegular,
UnknownPseudo,
EmptyPseudo,
HeaderListOverBudget,
TooManyHeaderFields,
}
macro_rules! reject_metric_key {
($prefix:literal, $reason:expr) => {
match $reason {
RejectReason::InvalidNameByte => concat!($prefix, ".invalid_name_byte"),
RejectReason::ConnectionSpecificHeader => {
concat!($prefix, ".connection_specific_header")
}
RejectReason::TeNotTrailers => concat!($prefix, ".te_not_trailers"),
RejectReason::CrlfInValue => concat!($prefix, ".crlf_in_value"),
RejectReason::NulInValue => concat!($prefix, ".nul_in_value"),
RejectReason::OversizedPseudoValue => concat!($prefix, ".oversized_pseudo_value"),
RejectReason::InvalidMethod => concat!($prefix, ".invalid_method"),
RejectReason::InvalidScheme => concat!($prefix, ".invalid_scheme"),
RejectReason::InvalidPath => concat!($prefix, ".invalid_path"),
RejectReason::InvalidStatus => concat!($prefix, ".invalid_status"),
RejectReason::ClTeConflict => concat!($prefix, ".cl_te_conflict"),
RejectReason::DuplicateCl => concat!($prefix, ".duplicate_cl"),
RejectReason::DuplicatePseudo => concat!($prefix, ".duplicate_pseudo"),
RejectReason::PseudoAfterRegular => concat!($prefix, ".pseudo_after_regular"),
RejectReason::UnknownPseudo => concat!($prefix, ".unknown_pseudo"),
RejectReason::EmptyPseudo => concat!($prefix, ".empty_pseudo"),
RejectReason::HeaderListOverBudget => concat!($prefix, ".header_list_size"),
RejectReason::TooManyHeaderFields => concat!($prefix, ".header_fields"),
}
};
}
fn metric_reject(reason: RejectReason) {
incr!(names::h2::HEADERS_REJECTED_TOTAL);
incr!(reject_metric_key!("h2.headers.rejected", reason));
}
fn classify_invalid_h2_header(name: &[u8], value: &[u8]) -> Option<RejectReason> {
if name.is_empty() {
return Some(RejectReason::InvalidNameByte);
}
if name[0] != b':' && has_invalid_name_byte(name) {
return Some(RejectReason::InvalidNameByte);
}
if is_connection_specific_header(name) {
return Some(RejectReason::ConnectionSpecificHeader);
}
if compare_no_case(name, b"te") && is_invalid_te_value(value) {
return Some(RejectReason::TeNotTrailers);
}
if let Some(reason) = classify_invalid_value_byte(value) {
return Some(reason);
}
None
}
fn classify_invalid_value_byte(value: &[u8]) -> Option<RejectReason> {
let mut saw_crlf = false;
let mut saw_nul = false;
for &b in value {
match b {
0x00 => saw_nul = true,
0x0A | 0x0D => saw_crlf = true,
0x01..=0x08 | 0x0B | 0x0C | 0x0E..=0x1F | 0x7F => {
return Some(RejectReason::CrlfInValue);
}
_ => {}
}
}
let result = if saw_crlf {
Some(RejectReason::CrlfInValue)
} else if saw_nul {
Some(RejectReason::NulInValue)
} else {
None
};
debug_assert!(
result.is_some() == (saw_crlf || saw_nul),
"classify must reject iff a CR/LF or NUL byte was observed"
);
debug_assert!(
!(saw_crlf && result != Some(RejectReason::CrlfInValue)),
"CR/LF must classify as CrlfInValue even when NUL is also present"
);
result
}
#[cfg(test)]
fn is_invalid_h2_header(name: &[u8], value: &[u8]) -> bool {
classify_invalid_h2_header(name, value).is_some()
}
fn set_content_length(body_size: &mut BodySize, length: usize) -> bool {
let before = *body_size;
let accepted = match *body_size {
BodySize::Length(existing) if existing != length => false,
_ => {
*body_size = BodySize::Length(length);
true
}
};
debug_assert!(
accepted || *body_size == before,
"rejected content-length must leave body_size unchanged"
);
debug_assert!(
!accepted || *body_size == BodySize::Length(length),
"accepted content-length must set body_size to exactly Length(length)"
);
accepted
}
fn store_pseudo_header(
dest: &Store,
regular_headers: bool,
kawa: &mut GenericHttpStream,
value: &[u8],
) -> Result<Store, RejectReason> {
if !dest.is_empty() {
return Err(RejectReason::DuplicatePseudo);
}
if regular_headers {
return Err(RejectReason::PseudoAfterRegular);
}
if value.is_empty() {
return Err(RejectReason::EmptyPseudo);
}
if has_invalid_pseudo_value_byte(value) {
return Err(RejectReason::OversizedPseudoValue);
}
let start = kawa.storage.end as u32;
let end_before = kawa.storage.end;
if kawa.storage.write_all(value).is_err() {
return Err(RejectReason::OversizedPseudoValue);
}
debug_assert_eq!(
kawa.storage.end,
end_before + value.len(),
"pseudo store must advance storage.end by exactly value.len()"
);
debug_assert!(
start as usize + value.len() <= kawa.storage.end,
"pseudo slice must stay within the written storage region"
);
debug_assert!(
!value.is_empty(),
"empty pseudo value must already be rejected"
);
Ok(Store::Slice(Slice {
start,
len: value.len() as u32,
}))
}
fn write_regular_header(
kawa: &mut GenericHttpStream,
key: &[u8],
value: &[u8],
) -> Result<(), RejectReason> {
let len_key = key.len() as u32;
let len_val = value.len() as u32;
let start = kawa.storage.end as u32;
let end_before_val = kawa.storage.end;
if kawa.storage.write_all(value).is_err() {
return Err(RejectReason::OversizedPseudoValue);
}
debug_assert_eq!(
kawa.storage.end,
end_before_val + value.len(),
"regular header value write must advance storage.end by value.len()"
);
let val = Store::Slice(Slice {
start,
len: len_val,
});
if compare_no_case(key, b"content-length") {
if value.is_empty() || !value.iter().all(|b| b.is_ascii_digit()) {
return Err(RejectReason::DuplicateCl);
}
if let Some(length) = from_utf8(value).ok().and_then(|v| v.parse::<usize>().ok()) {
if !set_content_length(&mut kawa.body_size, length) {
return Err(RejectReason::ClTeConflict);
}
} else {
return Err(RejectReason::DuplicateCl);
}
}
let end_before_key = kawa.storage.end;
if kawa.storage.write_all(key).is_err() {
return Err(RejectReason::OversizedPseudoValue);
}
debug_assert_eq!(
kawa.storage.end,
end_before_key + key.len(),
"regular header key write must advance storage.end by key.len()"
);
debug_assert_eq!(
end_before_key as u32,
start + len_val,
"key must begin exactly where the value ended (contiguous, no gap)"
);
let key = Store::Slice(Slice {
start: start + len_val,
len: len_key,
});
debug_assert!(
(start + len_val + len_key) as usize <= kawa.storage.end,
"key+val slices must stay within the written storage region"
);
kawa.push_block(Block::Header(Pair { key, val }));
Ok(())
}
fn trim_ows(input: &[u8]) -> &[u8] {
let start = input
.iter()
.position(|&b| b != b' ' && b != b'\t')
.unwrap_or(input.len());
let end = input
.iter()
.rposition(|&b| b != b' ' && b != b'\t')
.map_or(start, |p| p + 1);
&input[start..end]
}
pub(super) fn parse_rfc9218_priority(value: &[u8]) -> (u8, bool) {
let mut urgency: u8 = 3; let mut incremental = false;
for token in value.split(|&b| b == b',') {
let token = trim_ows(token);
if token.is_empty() {
continue;
}
if token.len() >= 3 && token[0] == b'u' && token[1] == b'=' {
if let Ok(s) = std::str::from_utf8(&token[2..]) {
if let Ok(n) = s.parse::<u8>() {
urgency = n.min(7);
debug_assert!(urgency <= 7, "clamped urgency must stay within 0..=7");
}
}
} else if token == b"i" || token == b"i=?1" {
incremental = true;
} else if token == b"i=?0" {
incremental = false;
}
}
debug_assert!(
urgency <= 7,
"parse_rfc9218_priority must yield an urgency within the RFC 9218 range"
);
(urgency, incremental)
}
fn decode_headers_with_budget<F>(
decoder: &mut loona_hpack::Decoder<'static>,
input: &[u8],
max_decoded_bytes: usize,
max_header_fields: u32,
mut per_header: F,
) -> Result<bool, (H2Error, bool)>
where
F: FnMut(Cow<[u8]>, Cow<[u8]>, &mut bool, &mut usize),
{
let max_header_fields = max_header_fields as usize;
let mut invalid_headers = false;
let mut budget_exceeded = false;
let mut field_limit_exceeded = false;
let mut field_count: usize = 0;
let mut decoded_bytes: usize = 0;
let decode_status = decoder.decode_with_cb(input, |k, v| {
if invalid_headers || budget_exceeded || field_limit_exceeded {
return;
}
let bytes_before = decoded_bytes;
decoded_bytes = decoded_bytes
.saturating_add(k.len())
.saturating_add(v.len())
.saturating_add(crate::protocol::mux::h2::HEADER_FIELD_SIZE_OVERHEAD);
debug_assert!(
decoded_bytes >= bytes_before,
"decoded header-list size must be monotonic non-decreasing"
);
if decoded_bytes > max_decoded_bytes {
budget_exceeded = true;
return;
}
debug_assert!(
decoded_bytes <= max_decoded_bytes,
"admitted pairs must keep the running size within the budget"
);
field_count += 1;
if field_count > max_header_fields {
field_limit_exceeded = true;
return;
}
if let Some(reason) = classify_invalid_h2_header(&k, &v) {
metric_reject(reason);
invalid_headers = true;
return;
}
per_header(k, v, &mut invalid_headers, &mut field_count);
if field_count > max_header_fields {
field_limit_exceeded = true;
}
});
if let Err(error) = decode_status {
error!("{} INVALID FRAGMENT: {:?}", log_module_context!(), error);
return Err((H2Error::CompressionError, true));
}
debug_assert!(
budget_exceeded || decoded_bytes <= max_decoded_bytes,
"a non-overflowing decode must end at or below the budget"
);
if budget_exceeded {
metric_reject(RejectReason::HeaderListOverBudget);
error!(
"{} HPACK decoded header size {} exceeds MAX_HEADER_LIST_SIZE {}",
log_module_context!(),
decoded_bytes,
max_decoded_bytes
);
return Err((H2Error::EnhanceYourCalm, false));
}
if field_limit_exceeded {
metric_reject(RejectReason::TooManyHeaderFields);
error!(
"{} HPACK header field count {} exceeds max_header_fields {}",
log_module_context!(),
field_count,
max_header_fields
);
return Err((H2Error::EnhanceYourCalm, false));
}
debug_assert!(
decoded_bytes <= max_decoded_bytes,
"successful decode must report a header-list size within the budget"
);
Ok(invalid_headers)
}
#[allow(clippy::too_many_arguments)]
pub fn handle_header<C>(
decoder: &mut loona_hpack::Decoder<'static>,
prioriser: &mut Prioriser,
stream_id: StreamId,
kawa: &mut GenericHttpStream,
input: &[u8],
end_stream: bool,
callbacks: &mut C,
max_header_list_size: u32,
max_header_fields: u32,
elide_x_real_ip: bool,
) -> Result<(), (H2Error, bool)>
where
C: ParserCallbacks<Checkout>,
{
if !kawa.is_initial() {
return handle_trailer(
kawa,
input,
end_stream,
decoder,
max_header_list_size,
max_header_fields,
elide_x_real_ip,
);
}
kawa.push_block(Block::StatusLine);
let max_decoded_bytes = max_header_list_size as usize;
kawa.detached.status_line = match kawa.kind {
Kind::Request => {
let mut method = Store::Empty;
let mut authority = Store::Empty;
let mut path = Store::Empty;
let mut scheme = Store::Empty;
let mut regular_headers = false;
let mut cookies_added = false;
let mut host_value: Option<Vec<u8>> = None;
let mut host_conflict = false;
let invalid_headers = decode_headers_with_budget(
decoder,
input,
max_decoded_bytes,
max_header_fields,
|k, v, invalid_headers, field_count| {
if compare_no_case(&k, b":method") {
if !v.iter().all(|&b| is_tchar(b)) {
metric_reject(RejectReason::InvalidMethod);
*invalid_headers = true;
return;
}
store_or_reject!(method, regular_headers, kawa, v, invalid_headers);
} else if compare_no_case(&k, b":scheme") {
if v.as_ref() != b"http" && v.as_ref() != b"https" {
metric_reject(RejectReason::InvalidScheme);
*invalid_headers = true;
return;
}
store_or_reject!(scheme, regular_headers, kawa, v, invalid_headers);
} else if compare_no_case(&k, b":path") {
if v.contains(&b'#') {
metric_reject(RejectReason::InvalidPath);
*invalid_headers = true;
return;
}
store_or_reject!(path, regular_headers, kawa, v, invalid_headers);
} else if compare_no_case(&k, b":authority") {
store_or_reject!(authority, regular_headers, kawa, v, invalid_headers);
} else if k.starts_with(b":") {
metric_reject(RejectReason::UnknownPseudo);
*invalid_headers = true;
} else if compare_no_case(&k, b"cookie") {
regular_headers = true;
let mut first_crumb = true;
for cookie_pair in v.split(|&b| b == b';') {
let trimmed = trim_ows(cookie_pair);
if trimmed.is_empty() {
continue;
}
let (cookie_key, cookie_val) =
match trimmed.iter().position(|&b| b == b'=') {
Some(eq_pos) => (&trimmed[..eq_pos], &trimmed[eq_pos + 1..]),
None => (trimmed, &b""[..]),
};
if let Some(reason) = classify_invalid_value_byte(cookie_key)
.or_else(|| classify_invalid_value_byte(cookie_val))
{
metric_reject(reason);
*invalid_headers = true;
return;
}
if first_crumb {
first_crumb = false;
} else {
*field_count += 1;
if *field_count > max_header_fields as usize {
return;
}
}
let key_start = kawa.storage.end as u32;
if kawa.storage.write_all(cookie_key).is_err() {
metric_reject(RejectReason::OversizedPseudoValue);
*invalid_headers = true;
return;
}
let key_len = cookie_key.len() as u32;
let val_start = kawa.storage.end as u32;
if kawa.storage.write_all(cookie_val).is_err() {
metric_reject(RejectReason::OversizedPseudoValue);
*invalid_headers = true;
return;
}
let val_len = cookie_val.len() as u32;
if !cookies_added {
kawa.push_block(Block::Cookies);
cookies_added = true;
}
kawa.detached.jar.push_back(Pair {
key: Store::Slice(Slice {
start: key_start,
len: key_len,
}),
val: Store::Slice(Slice {
start: val_start,
len: val_len,
}),
});
}
} else if compare_no_case(&k, b"host") {
regular_headers = true;
if let Some(reason) = classify_invalid_value_byte(&v) {
metric_reject(reason);
*invalid_headers = true;
return;
}
match host_value {
Some(ref existing) if existing.as_slice() != v.as_ref() => {
host_conflict = true;
}
Some(_) => {
host_conflict = true;
}
None => host_value = Some(v.to_vec()),
}
} else {
regular_headers = true;
if let Err(reason) = write_regular_header(kawa, &k, &v) {
metric_reject(reason);
*invalid_headers = true;
return;
}
if compare_no_case(&k, b"priority") {
let (urgency, incremental) = parse_rfc9218_priority(&v);
prioriser.push_priority(
stream_id,
PriorityPart::Rfc9218 {
urgency,
incremental,
},
);
}
}
},
)?;
if let Some(path_data) = path.data_opt(kawa.storage.buffer()) {
let is_asterisk = path_data == b"*";
let starts_with_slash = path_data.first() == Some(&b'/');
let method_is_options = method
.data_opt(kawa.storage.buffer())
.is_some_and(|m| m == b"OPTIONS");
if !(starts_with_slash || (is_asterisk && method_is_options)) {
return Err((H2Error::ProtocolError, false));
}
}
if invalid_headers
|| method.is_empty()
|| authority.is_empty()
|| path.is_empty()
|| scheme.is_empty()
{
error!("{} INVALID HEADERS", log_module_context!());
return Err((H2Error::ProtocolError, false));
}
if host_conflict {
error!(
"{} H2 host header: multiple disagreeing values",
log_module_context!()
);
return Err((H2Error::ProtocolError, false));
}
if let Some(ref host) = host_value {
let authority_bytes = authority.data_opt(kawa.storage.buffer()).unwrap_or(&[]);
if !host_matches_authority(host, authority_bytes) {
error!(
"{} H2 host header does not match :authority",
log_module_context!()
);
return Err((H2Error::ProtocolError, false));
}
}
debug_assert!(
!method.is_empty()
&& !authority.is_empty()
&& !path.is_empty()
&& !scheme.is_empty(),
"all four request pseudo-headers must be present after validation"
);
StatusLine::Request {
version: Version::V20,
method,
uri: path.clone(),
authority,
path,
}
}
Kind::Response => {
let mut code = 0;
let mut status = Store::Empty;
let mut regular_headers = false;
let invalid_headers = decode_headers_with_budget(
decoder,
input,
max_decoded_bytes,
max_header_fields,
|k, v, invalid_headers, _field_count| {
if compare_no_case(&k, b":status") {
if v.len() != 3 || !v.iter().all(|b| b.is_ascii_digit()) {
metric_reject(RejectReason::InvalidStatus);
*invalid_headers = true;
return;
}
match store_pseudo_header(&status, regular_headers, kawa, &v) {
Ok(s) => {
status = s;
debug_assert!(
v.len() == 3 && v.iter().all(|b| b.is_ascii_digit()),
":status reaching the decode must be three ASCII digits"
);
code = (v[0] - b'0') as u16 * 100
+ (v[1] - b'0') as u16 * 10
+ (v[2] - b'0') as u16;
debug_assert!(
(100..=999).contains(&code),
"decoded :status code must be in 100..=999"
);
}
Err(reason) => {
metric_reject(reason);
*invalid_headers = true;
}
}
} else if k.starts_with(b":") {
metric_reject(RejectReason::UnknownPseudo);
*invalid_headers = true;
} else {
regular_headers = true;
if let Err(reason) = write_regular_header(kawa, &k, &v) {
metric_reject(reason);
*invalid_headers = true;
}
}
},
)?;
if invalid_headers || status.is_empty() {
error!("{} INVALID HEADERS", log_module_context!());
return Err((H2Error::ProtocolError, false));
}
debug_assert!(
(100..=999).contains(&code),
"response :status code must be a three-digit value in 100..=999"
);
debug_assert!(
!status.is_empty(),
"validated response must carry a non-empty :status pseudo"
);
StatusLine::Response {
version: Version::V20,
code,
status,
reason: Store::Static(b"FromH2"),
}
}
};
kawa.storage.head = kawa.storage.end;
debug!(
"{} index: {}/{}/{}",
log_module_context!(),
kawa.storage.start,
kawa.storage.head,
kawa.storage.end
);
callbacks.on_headers(kawa);
if end_stream {
if let BodySize::Length(n) = kawa.body_size {
let body_exempt = matches!(kawa.kind, Kind::Response)
&& matches!(
kawa.detached.status_line,
StatusLine::Response { code, .. } if (100..200).contains(&code) || code == 204 || code == 304
);
if n > 0 && !body_exempt {
error!(
"{} END_STREAM with non-zero Content-Length: {} (RFC 9113 §8.1.1)",
log_module_context!(),
n
);
return Err((H2Error::ProtocolError, false));
}
}
if let BodySize::Empty = kawa.body_size {
let skip_content_length = matches!(kawa.kind, Kind::Response)
&& matches!(
kawa.detached.status_line,
StatusLine::Response { code, .. } if (100..200).contains(&code) || code == 204 || code == 304
);
if !skip_content_length {
kawa.body_size = BodySize::Length(0);
kawa.push_block(Block::Header(Pair {
key: Store::Static(b"Content-Length"),
val: Store::Static(b"0"),
}));
}
}
}
if !end_stream && kawa.body_size == BodySize::Empty {
kawa.body_size = BodySize::Chunked;
kawa.push_block(Block::Header(Pair {
key: Store::Static(b"Transfer-Encoding"),
val: Store::Static(b"chunked"),
}));
}
kawa.push_block(Block::Flags(Flags {
end_body: end_stream,
end_chunk: false,
end_header: true,
end_stream,
}));
if kawa.parsing_phase == ParsingPhase::Terminated {
return Ok(());
}
debug_assert!(
end_stream || kawa.body_size != BodySize::Empty,
"a continuing stream must have a resolved body framing before phasing"
);
kawa.parsing_phase = match kawa.body_size {
BodySize::Chunked => ParsingPhase::Chunks { first: true },
BodySize::Length(0) => ParsingPhase::Terminated,
BodySize::Length(_) => ParsingPhase::Body,
BodySize::Empty => ParsingPhase::Chunks { first: true },
};
debug_assert!(
!matches!(kawa.body_size, BodySize::Length(n) if n > 0)
|| kawa.parsing_phase == ParsingPhase::Body,
"a non-empty Content-Length body must transition to ParsingPhase::Body"
);
Ok(())
}
pub fn handle_trailer(
kawa: &mut GenericHttpStream,
input: &[u8],
end_stream: bool,
decoder: &mut loona_hpack::Decoder<'static>,
max_header_list_size: u32,
max_header_fields: u32,
elide_x_real_ip: bool,
) -> Result<(), (H2Error, bool)> {
let _ = elide_x_real_ip;
if !end_stream {
return Err((H2Error::ProtocolError, false));
}
let max_header_fields = max_header_fields as usize;
let mut invalid_trailers = false;
let mut budget_exceeded = false;
let mut field_limit_exceeded = false;
let mut field_count: usize = 0;
let mut decoded_bytes: usize = 0;
let max_decoded = (max_header_list_size as usize).min(MAX_TRAILER_BYTES);
debug_assert!(
max_decoded <= max_header_list_size as usize && max_decoded <= MAX_TRAILER_BYTES,
"trailer budget is the min of MAX_HEADER_LIST_SIZE and the carve-out cap"
);
let decode_status = decoder.decode_with_cb(input, |k, v| {
if invalid_trailers || budget_exceeded || field_limit_exceeded {
return;
}
let bytes_before = decoded_bytes;
decoded_bytes = decoded_bytes
.saturating_add(k.len())
.saturating_add(v.len())
.saturating_add(crate::protocol::mux::h2::HEADER_FIELD_SIZE_OVERHEAD);
debug_assert!(
decoded_bytes >= bytes_before,
"decoded trailer size must be monotonic non-decreasing"
);
if decoded_bytes > max_decoded {
budget_exceeded = true;
return;
}
debug_assert!(
decoded_bytes <= max_decoded,
"admitted trailers must stay within the trailer budget"
);
field_count += 1;
if field_count > max_header_fields {
field_limit_exceeded = true;
return;
}
if k.starts_with(b":") {
metric_reject(RejectReason::UnknownPseudo);
invalid_trailers = true;
return;
}
if let Some(reason) = classify_invalid_h2_header(&k, &v) {
metric_reject(reason);
invalid_trailers = true;
return;
}
if matches!(
k.as_ref(),
b"x-real-ip" | b"x-forwarded-for" | b"forwarded" | b"x-request-id"
) {
incr!(names::h2::TRAILER_SPOOF_VECTOR_ELIDED);
return;
}
let start = kawa.storage.end as u32;
let end_before = kawa.storage.end;
if kawa.storage.write_all(&k).is_err() || kawa.storage.write_all(&v).is_err() {
metric_reject(RejectReason::OversizedPseudoValue);
invalid_trailers = true;
return;
}
debug_assert_eq!(
kawa.storage.end,
end_before + k.len() + v.len(),
"trailer write must advance storage.end by key.len() + value.len()"
);
let len_key = k.len() as u32;
let len_val = v.len() as u32;
debug_assert!(
(start + len_key + len_val) as usize <= kawa.storage.end,
"trailer key+val slices must stay within the written storage region"
);
let key = Store::Slice(Slice {
start,
len: len_key,
});
let val = Store::Slice(Slice {
start: start + len_key,
len: len_val,
});
kawa.push_block(Block::Header(Pair { key, val }));
});
if let Err(error) = decode_status {
error!("{} INVALID FRAGMENT: {:?}", log_module_context!(), error);
return Err((H2Error::CompressionError, true));
}
if budget_exceeded {
metric_reject(RejectReason::HeaderListOverBudget);
error!(
"{} HPACK decoded trailer size {} exceeds MAX_HEADER_LIST_SIZE {}",
log_module_context!(),
decoded_bytes,
max_decoded
);
return Err((H2Error::EnhanceYourCalm, false));
}
if field_limit_exceeded {
metric_reject(RejectReason::TooManyHeaderFields);
error!(
"{} HPACK trailer field count {} exceeds max_header_fields {}",
log_module_context!(),
field_count,
max_header_fields
);
return Err((H2Error::EnhanceYourCalm, false));
}
if invalid_trailers {
error!("{} INVALID TRAILERS", log_module_context!());
return Err((H2Error::ProtocolError, false));
}
debug_assert!(
!budget_exceeded && !invalid_trailers && decoded_bytes <= max_decoded,
"an accepted trailer block must fit the budget and pass validation"
);
if matches!(kawa.body_size, BodySize::Length(_)) {
warn!(
"{} H2 trailers arrived on a Content-Length-framed stream; \
trailers will be silently dropped by the H1 serializer \
(RFC 9110 §6.5). Peer should omit Content-Length for trailer \
delivery to upgrade framing to chunked.",
log_module_context!()
);
incr!(names::h2::TRAILERS_DROPPED_CONTENT_LENGTH);
}
kawa.push_block(Block::Flags(Flags {
end_body: false,
end_chunk: false,
end_header: true,
end_stream: true,
}));
kawa.parsing_phase = ParsingPhase::Terminated;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_rfc9218_priority_defaults() {
let (u, i) = parse_rfc9218_priority(b"");
assert_eq!(u, 3);
assert!(!i);
}
#[test]
fn test_parse_rfc9218_priority_urgency_only() {
assert_eq!(parse_rfc9218_priority(b"u=0"), (0, false));
assert_eq!(parse_rfc9218_priority(b"u=3"), (3, false));
assert_eq!(parse_rfc9218_priority(b"u=7"), (7, false));
}
#[test]
fn test_parse_rfc9218_priority_urgency_clamped() {
assert_eq!(parse_rfc9218_priority(b"u=9"), (7, false));
assert_eq!(parse_rfc9218_priority(b"u=255"), (7, false));
}
#[test]
fn test_parse_rfc9218_priority_incremental_only() {
assert_eq!(parse_rfc9218_priority(b"i"), (3, true));
}
#[test]
fn test_parse_rfc9218_priority_incremental_boolean_form() {
assert_eq!(parse_rfc9218_priority(b"i=?1"), (3, true));
assert_eq!(parse_rfc9218_priority(b"i=?0"), (3, false));
}
#[test]
fn test_parse_rfc9218_priority_combined() {
assert_eq!(parse_rfc9218_priority(b"u=3, i"), (3, true));
assert_eq!(parse_rfc9218_priority(b"u=0, i"), (0, true));
assert_eq!(parse_rfc9218_priority(b"u=7, i=?1"), (7, true));
assert_eq!(parse_rfc9218_priority(b"u=5, i=?0"), (5, false));
}
#[test]
fn test_parse_rfc9218_priority_whitespace_tolerance() {
assert_eq!(parse_rfc9218_priority(b"u=3, i"), (3, true));
assert_eq!(parse_rfc9218_priority(b" u=3 , i "), (3, true));
assert_eq!(parse_rfc9218_priority(b"\tu=2\t,\ti\t"), (2, true));
}
#[test]
fn test_parse_rfc9218_priority_malformed_ignored() {
assert_eq!(parse_rfc9218_priority(b"x=5"), (3, false));
assert_eq!(parse_rfc9218_priority(b"u=3, x=5"), (3, false));
assert_eq!(parse_rfc9218_priority(b"u=abc"), (3, false));
}
#[test]
fn test_parse_rfc9218_priority_order_independent() {
assert_eq!(parse_rfc9218_priority(b"i, u=1"), (1, true));
}
#[test]
fn test_is_connection_specific_header_positive() {
assert!(is_connection_specific_header(b"connection"));
assert!(is_connection_specific_header(b"Connection"));
assert!(is_connection_specific_header(b"proxy-connection"));
assert!(is_connection_specific_header(b"Proxy-Connection"));
assert!(is_connection_specific_header(b"transfer-encoding"));
assert!(is_connection_specific_header(b"Transfer-Encoding"));
assert!(is_connection_specific_header(b"upgrade"));
assert!(is_connection_specific_header(b"Upgrade"));
assert!(is_connection_specific_header(b"keep-alive"));
assert!(is_connection_specific_header(b"Keep-Alive"));
}
#[test]
fn test_is_connection_specific_header_negative() {
assert!(!is_connection_specific_header(b"content-type"));
assert!(!is_connection_specific_header(b"accept"));
assert!(!is_connection_specific_header(b"host"));
assert!(!is_connection_specific_header(b"te"));
assert!(!is_connection_specific_header(b"cookie"));
assert!(!is_connection_specific_header(b""));
}
#[test]
fn test_is_invalid_h2_header_uppercase_name() {
assert!(is_invalid_h2_header(b"Content-Type", b"text/html"));
assert!(is_invalid_h2_header(b"X-Custom", b"value"));
}
#[test]
fn test_is_invalid_h2_header_connection_specific() {
assert!(is_invalid_h2_header(b"connection", b"close"));
assert!(is_invalid_h2_header(b"transfer-encoding", b"chunked"));
assert!(is_invalid_h2_header(b"upgrade", b"h2c"));
assert!(is_invalid_h2_header(b"keep-alive", b"timeout=5"));
assert!(is_invalid_h2_header(b"proxy-connection", b"keep-alive"));
}
#[test]
fn test_is_invalid_h2_header_te_trailers_ok() {
assert!(!is_invalid_h2_header(b"te", b"trailers"));
assert!(!is_invalid_h2_header(b"te", b"Trailers")); }
#[test]
fn test_is_invalid_h2_header_te_other_invalid() {
assert!(is_invalid_h2_header(b"te", b"gzip"));
assert!(is_invalid_h2_header(b"te", b"deflate"));
assert!(is_invalid_h2_header(b"te", b"chunked"));
}
#[test]
fn test_is_invalid_h2_header_valid() {
assert!(!is_invalid_h2_header(b"content-type", b"text/html"));
assert!(!is_invalid_h2_header(b"accept", b"*/*"));
assert!(!is_invalid_h2_header(b"x-custom", b"value"));
}
#[test]
fn test_has_uppercase_ascii() {
assert!(has_uppercase_ascii(b"Content-Type"));
assert!(has_uppercase_ascii(b"X"));
assert!(!has_uppercase_ascii(b"content-type"));
assert!(!has_uppercase_ascii(b""));
assert!(!has_uppercase_ascii(b"123-header"));
}
#[test]
fn test_trim_ows() {
assert_eq!(trim_ows(b" hello "), b"hello");
assert_eq!(trim_ows(b"\thello\t"), b"hello");
assert_eq!(trim_ows(b" \t hello \t "), b"hello");
assert_eq!(trim_ows(b"hello"), b"hello");
assert_eq!(trim_ows(b""), b"" as &[u8]);
assert_eq!(trim_ows(b" "), b"" as &[u8]);
}
fn make_generic_kawa(pool: &mut crate::pool::Pool, kind: Kind) -> GenericHttpStream {
let checkout = pool.checkout().expect("pool checkout should succeed");
kawa::Kawa::new(kind, kawa::Buffer::new(checkout))
}
#[test]
fn test_store_pseudo_header_rejects_duplicate() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = make_generic_kawa(&mut pool, Kind::Request);
let dest = Store::Empty;
let result = store_pseudo_header(&dest, false, &mut kawa, b"GET");
assert!(result.is_ok());
let dest = result.unwrap();
let result = store_pseudo_header(&dest, false, &mut kawa, b"POST");
assert!(result.is_err());
}
#[test]
fn test_store_pseudo_header_rejects_after_regular_headers() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = make_generic_kawa(&mut pool, Kind::Request);
let dest = Store::Empty;
let result = store_pseudo_header(&dest, true, &mut kawa, b"GET");
assert!(result.is_err());
}
#[test]
fn test_is_connection_specific_header_mixed_case() {
assert!(is_connection_specific_header(b"CONNECTION"));
assert!(is_connection_specific_header(b"CoNnEcTiOn"));
assert!(is_connection_specific_header(b"PROXY-CONNECTION"));
assert!(is_connection_specific_header(b"Proxy-connection"));
assert!(is_connection_specific_header(b"TRANSFER-ENCODING"));
assert!(is_connection_specific_header(b"transfer-Encoding"));
assert!(is_connection_specific_header(b"UPGRADE"));
assert!(is_connection_specific_header(b"KEEP-ALIVE"));
assert!(is_connection_specific_header(b"Keep-alive"));
}
#[test]
fn test_is_connection_specific_header_partial_match() {
assert!(!is_connection_specific_header(b"connection-extra"));
assert!(!is_connection_specific_header(b"my-connection"));
assert!(!is_connection_specific_header(b"upgrade-insecure-requests"));
assert!(!is_connection_specific_header(b"keep-alive-timeout"));
assert!(!is_connection_specific_header(b"x-keep-alive"));
}
#[test]
fn test_is_invalid_te_value_trailers_ok() {
assert!(!is_invalid_te_value(b"trailers"));
assert!(!is_invalid_te_value(b"Trailers"));
assert!(!is_invalid_te_value(b"TRAILERS"));
}
#[test]
fn test_is_invalid_te_value_other_rejected() {
assert!(is_invalid_te_value(b"gzip"));
assert!(is_invalid_te_value(b"deflate"));
assert!(is_invalid_te_value(b"chunked"));
assert!(is_invalid_te_value(b"compress"));
assert!(is_invalid_te_value(b""));
assert!(is_invalid_te_value(b"trailers, gzip"));
}
#[test]
fn test_is_invalid_h2_header_empty_name() {
assert!(is_invalid_h2_header(b"", b""));
}
#[test]
fn test_is_invalid_h2_header_single_uppercase() {
assert!(is_invalid_h2_header(b"X", b""));
assert!(is_invalid_h2_header(b"hostA", b"value"));
}
#[test]
fn test_has_uppercase_ascii_non_ascii_bytes() {
assert!(!has_uppercase_ascii(&[0x80, 0xFF, 0xC0]));
assert!(!has_uppercase_ascii(b"\xc3\xa9")); }
#[test]
fn test_has_uppercase_ascii_mixed_with_numbers() {
assert!(!has_uppercase_ascii(b"content-type-2"));
assert!(has_uppercase_ascii(b"content-Type-2"));
}
#[test]
fn test_trim_ows_single_char() {
assert_eq!(trim_ows(b"x"), b"x");
assert_eq!(trim_ows(b" "), b"" as &[u8]);
assert_eq!(trim_ows(b"\t"), b"" as &[u8]);
}
#[test]
fn test_trim_ows_preserves_internal_whitespace() {
assert_eq!(trim_ows(b" hello world "), b"hello world");
assert_eq!(trim_ows(b"\ta\tb\t"), b"a\tb");
}
fn decode_request_headers(
pool: &mut crate::pool::Pool,
headers: &[(&[u8], &[u8])],
end_stream: bool,
) -> GenericHttpStream {
let mut encoder = loona_hpack::Encoder::new();
let mut encoded = Vec::new();
for &(name, value) in headers {
encoder
.encode_header_into((name, value), &mut encoded)
.unwrap();
}
let mut decoder = loona_hpack::Decoder::new();
let mut prioriser = Prioriser::default();
let mut kawa = make_generic_kawa(pool, Kind::Request);
struct NoOpCallbacks;
impl kawa::h1::ParserCallbacks<crate::pool::Checkout> for NoOpCallbacks {
fn on_headers(&mut self, _kawa: &mut GenericHttpStream) {}
}
let mut callbacks = NoOpCallbacks;
let result = handle_header(
&mut decoder,
&mut prioriser,
1,
&mut kawa,
&encoded,
end_stream,
&mut callbacks,
crate::protocol::mux::h2::MAX_HEADER_LIST_SIZE as u32,
u32::MAX,
false,
);
assert!(result.is_ok(), "handle_header failed: {:?}", result.err());
kawa
}
fn base_request_pseudo() -> Vec<(Vec<u8>, Vec<u8>)> {
vec![
(b":method".to_vec(), b"GET".to_vec()),
(b":scheme".to_vec(), b"https".to_vec()),
(b":path".to_vec(), b"/".to_vec()),
(b":authority".to_vec(), b"example.com".to_vec()),
]
}
fn try_decode_request_capped(
pool: &mut crate::pool::Pool,
headers: &[(Vec<u8>, Vec<u8>)],
max_header_list_size: u32,
max_header_fields: u32,
) -> Result<GenericHttpStream, (H2Error, bool)> {
let mut encoder = loona_hpack::Encoder::new();
let mut encoded = Vec::new();
for (name, value) in headers {
encoder
.encode_header_into((name.as_slice(), value.as_slice()), &mut encoded)
.unwrap();
}
let mut decoder = loona_hpack::Decoder::new();
let mut prioriser = Prioriser::default();
let mut kawa = make_generic_kawa(pool, Kind::Request);
struct NoOpCallbacks;
impl kawa::h1::ParserCallbacks<crate::pool::Checkout> for NoOpCallbacks {
fn on_headers(&mut self, _kawa: &mut GenericHttpStream) {}
}
let mut callbacks = NoOpCallbacks;
handle_header(
&mut decoder,
&mut prioriser,
1,
&mut kawa,
&encoded,
true,
&mut callbacks,
max_header_list_size,
max_header_fields,
false,
)
.map(|()| kawa)
}
#[test]
fn test_h2_header_field_count_cap_rejects_bomb() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 1 << 20);
let mut headers = base_request_pseudo();
for _ in 0..200 {
headers.push((b"x-bomb".to_vec(), b"".to_vec()));
}
let result = try_decode_request_capped(&mut pool, &headers, u32::MAX, 128);
assert!(
matches!(result, Err((H2Error::EnhanceYourCalm, _))),
"expected EnhanceYourCalm for >128 header fields, got {:?}",
result.map(|_| ())
);
}
#[test]
fn test_h2_header_list_size_includes_32_octet_overhead() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 1 << 20);
let mut headers = base_request_pseudo();
for _ in 0..120 {
headers.push((b"a".to_vec(), b"".to_vec())); }
let result = try_decode_request_capped(&mut pool, &headers, 4096, u32::MAX);
assert!(
matches!(result, Err((H2Error::EnhanceYourCalm, _))),
"expected EnhanceYourCalm once 32-octet/field overhead is counted, got {:?}",
result.map(|_| ())
);
}
#[test]
fn test_h2_cookie_crumb_count_cap_rejects_bomb() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 1 << 20);
let mut headers = base_request_pseudo();
let mut cookie = Vec::new();
for i in 0..200 {
if i > 0 {
cookie.extend_from_slice(b"; ");
}
cookie.extend_from_slice(b"a=1");
}
headers.push((b"cookie".to_vec(), cookie));
let result = try_decode_request_capped(&mut pool, &headers, u32::MAX, 128);
assert!(
matches!(result, Err((H2Error::EnhanceYourCalm, _))),
"expected EnhanceYourCalm for >128 cookie crumbs, got {:?}",
result.map(|_| ())
);
}
#[test]
fn test_h2_cookie_crumb_count_exact_n_boundary() {
let cap: u32 = 4 + 128;
let build = |crumbs: usize| -> Vec<(Vec<u8>, Vec<u8>)> {
let mut headers = base_request_pseudo();
let mut cookie = Vec::new();
for i in 0..crumbs {
if i > 0 {
cookie.extend_from_slice(b"; ");
}
cookie.extend_from_slice(b"a=1");
}
headers.push((b"cookie".to_vec(), cookie));
headers
};
let mut pool = crate::pool::Pool::with_capacity(1, 1, 1 << 20);
let at_cap = build(128);
let result = try_decode_request_capped(&mut pool, &at_cap, u32::MAX, cap);
assert!(
result.is_ok(),
"128-crumb cookie must pass under exact-N (cap {cap}), got {:?}",
result.err()
);
let mut pool = crate::pool::Pool::with_capacity(1, 1, 1 << 20);
let over_cap = build(129);
let result = try_decode_request_capped(&mut pool, &over_cap, u32::MAX, cap);
assert!(
matches!(result, Err((H2Error::EnhanceYourCalm, _))),
"129-crumb cookie must reject under exact-N (cap {cap}), got {:?}",
result.map(|_| ())
);
}
#[test]
fn test_h2_legit_request_within_caps_accepted() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut headers = base_request_pseudo();
headers.push((b"user-agent".to_vec(), b"curl/8".to_vec()));
headers.push((b"accept".to_vec(), b"*/*".to_vec()));
headers.push((b"cookie".to_vec(), b"a=1; b=2; c=3".to_vec()));
let kawa = try_decode_request_capped(
&mut pool,
&headers,
crate::protocol::mux::h2::MAX_HEADER_LIST_SIZE as u32,
128,
)
.expect("legit request must be accepted");
assert_eq!(kawa.detached.jar.len(), 3);
}
#[test]
fn test_h2_cookies_stored_in_jar() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let kawa = decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"cookie", b"a=1; b=2; c=3"),
],
true,
);
assert_eq!(kawa.detached.jar.len(), 3);
let buf = kawa.storage.buffer();
assert_eq!(kawa.detached.jar[0].key.data(buf), b"a");
assert_eq!(kawa.detached.jar[0].val.data(buf), b"1");
assert_eq!(kawa.detached.jar[1].key.data(buf), b"b");
assert_eq!(kawa.detached.jar[1].val.data(buf), b"2");
assert_eq!(kawa.detached.jar[2].key.data(buf), b"c");
assert_eq!(kawa.detached.jar[2].val.data(buf), b"3");
let cookie_blocks: Vec<_> = kawa
.blocks
.iter()
.filter(|b| matches!(b, Block::Cookies))
.collect();
assert_eq!(cookie_blocks.len(), 1);
}
#[test]
fn test_h2_multiple_cookie_headers_merged_in_jar() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let kawa = decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"cookie", b"a=1"),
(b"cookie", b"b=2"),
(b"cookie", b"c=3"),
],
true,
);
assert_eq!(kawa.detached.jar.len(), 3);
let buf = kawa.storage.buffer();
assert_eq!(kawa.detached.jar[0].key.data(buf), b"a");
assert_eq!(kawa.detached.jar[0].val.data(buf), b"1");
assert_eq!(kawa.detached.jar[1].key.data(buf), b"b");
assert_eq!(kawa.detached.jar[1].val.data(buf), b"2");
assert_eq!(kawa.detached.jar[2].key.data(buf), b"c");
assert_eq!(kawa.detached.jar[2].val.data(buf), b"3");
let cookie_blocks: Vec<_> = kawa
.blocks
.iter()
.filter(|b| matches!(b, Block::Cookies))
.collect();
assert_eq!(cookie_blocks.len(), 1);
}
#[test]
fn test_h2_cookie_empty_value() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let kawa = decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"cookie", b"session=; PHPSESSID=abc123"),
],
true,
);
assert_eq!(kawa.detached.jar.len(), 2);
let buf = kawa.storage.buffer();
assert_eq!(kawa.detached.jar[0].key.data(buf), b"session");
assert_eq!(kawa.detached.jar[0].val.data(buf), b"");
assert_eq!(kawa.detached.jar[1].key.data(buf), b"PHPSESSID");
assert_eq!(kawa.detached.jar[1].val.data(buf), b"abc123");
}
#[test]
fn test_h2_cookie_without_equals() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let kawa = decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"cookie", b"bare_name"),
],
true,
);
assert_eq!(kawa.detached.jar.len(), 1);
let buf = kawa.storage.buffer();
assert_eq!(kawa.detached.jar[0].key.data(buf), b"bare_name");
assert_eq!(kawa.detached.jar[0].val.data(buf), b"");
}
#[test]
fn test_has_invalid_value_byte_ctl_and_del() {
assert!(has_invalid_value_byte(b"a\rb"));
assert!(has_invalid_value_byte(b"a\nb"));
assert!(has_invalid_value_byte(b"a\r\nb"));
assert!(has_invalid_value_byte(b"a\x00b"));
assert!(has_invalid_value_byte(b"a\x01b"));
assert!(has_invalid_value_byte(b"a\x7fb"));
assert!(has_invalid_value_byte(b"a\x1fb"));
}
#[test]
fn test_has_invalid_value_byte_permits_tab_sp_vchar() {
assert!(!has_invalid_value_byte(b"a\tb"));
assert!(!has_invalid_value_byte(b"a b"));
assert!(!has_invalid_value_byte(b"normal-value"));
assert!(!has_invalid_value_byte(&[0x80, 0xFF]));
assert!(!has_invalid_value_byte(b""));
}
#[test]
fn test_is_invalid_h2_header_rejects_ctl_in_value() {
assert!(is_invalid_h2_header(b"x-evil", b"a\r\nb"));
assert!(is_invalid_h2_header(b"x-evil", b"a\nb"));
assert!(is_invalid_h2_header(b"x-evil", b"a\rb"));
assert!(is_invalid_h2_header(b"x-evil", b"a\x00b"));
assert!(is_invalid_h2_header(b"x-evil", b"a\x7fb"));
assert!(is_invalid_h2_header(b"x-evil", b"a\x01b"));
assert!(!is_invalid_h2_header(b"x-evil", b"a\tb"));
assert!(!is_invalid_h2_header(b"x-evil", b"a b"));
}
#[test]
fn test_store_pseudo_header_rejects_empty_value() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = make_generic_kawa(&mut pool, Kind::Request);
let result = store_pseudo_header(&Store::Empty, false, &mut kawa, b"");
assert!(result.is_err());
}
#[test]
fn test_store_pseudo_header_rejects_ctl_in_value() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = make_generic_kawa(&mut pool, Kind::Request);
let result = store_pseudo_header(&Store::Empty, false, &mut kawa, b"/foo\r\n");
assert!(result.is_err());
let result = store_pseudo_header(&Store::Empty, false, &mut kawa, b"a\x00b");
assert!(result.is_err());
}
fn try_decode_request_headers(
pool: &mut crate::pool::Pool,
headers: &[(&[u8], &[u8])],
end_stream: bool,
) -> Result<(), (H2Error, bool)> {
let mut encoder = loona_hpack::Encoder::new();
let mut encoded = Vec::new();
for &(name, value) in headers {
encoder
.encode_header_into((name, value), &mut encoded)
.unwrap();
}
let mut decoder = loona_hpack::Decoder::new();
let mut prioriser = Prioriser::default();
let mut kawa = make_generic_kawa(pool, Kind::Request);
struct NoOpCallbacks;
impl kawa::h1::ParserCallbacks<crate::pool::Checkout> for NoOpCallbacks {
fn on_headers(&mut self, _kawa: &mut GenericHttpStream) {}
}
let mut callbacks = NoOpCallbacks;
handle_header(
&mut decoder,
&mut prioriser,
1,
&mut kawa,
&encoded,
end_stream,
&mut callbacks,
crate::protocol::mux::h2::MAX_HEADER_LIST_SIZE as u32,
u32::MAX,
false,
)
}
fn try_decode_response_headers(
pool: &mut crate::pool::Pool,
headers: &[(&[u8], &[u8])],
end_stream: bool,
) -> Result<(), (H2Error, bool)> {
let mut encoder = loona_hpack::Encoder::new();
let mut encoded = Vec::new();
for &(name, value) in headers {
encoder
.encode_header_into((name, value), &mut encoded)
.unwrap();
}
let mut decoder = loona_hpack::Decoder::new();
let mut prioriser = Prioriser::default();
let mut kawa = make_generic_kawa(pool, Kind::Response);
struct NoOpCallbacks;
impl kawa::h1::ParserCallbacks<crate::pool::Checkout> for NoOpCallbacks {
fn on_headers(&mut self, _kawa: &mut GenericHttpStream) {}
}
let mut callbacks = NoOpCallbacks;
handle_header(
&mut decoder,
&mut prioriser,
1,
&mut kawa,
&encoded,
end_stream,
&mut callbacks,
crate::protocol::mux::h2::MAX_HEADER_LIST_SIZE as u32,
u32::MAX,
false,
)
}
#[test]
fn test_handle_header_rejects_crlf_in_regular_header_value() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let err = try_decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"x-evil", b"normal\r\nX-Smuggled: yes"),
],
true,
);
assert!(err.is_err(), "CRLF-laden header value must be rejected");
}
#[test]
fn test_handle_header_rejects_empty_path() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let err = try_decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b""),
(b":authority", b"example.com"),
],
true,
);
assert!(err.is_err(), "empty :path must be rejected");
}
#[test]
fn test_handle_header_rejects_non_origin_form_path() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let err = try_decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"http://attacker/"),
(b":authority", b"example.com"),
],
true,
);
assert!(err.is_err());
let err = try_decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"*"),
(b":authority", b"example.com"),
],
true,
);
assert!(err.is_err());
}
#[test]
fn test_handle_header_accepts_options_asterisk() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let ok = try_decode_request_headers(
&mut pool,
&[
(b":method", b"OPTIONS"),
(b":scheme", b"https"),
(b":path", b"*"),
(b":authority", b"example.com"),
],
true,
);
assert!(ok.is_ok(), "OPTIONS * must be accepted, got {ok:?}");
}
#[test]
fn test_handle_header_rejects_bad_scheme() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let err = try_decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"javascript"),
(b":path", b"/"),
(b":authority", b"example.com"),
],
true,
);
assert!(err.is_err());
let err = try_decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"file"),
(b":path", b"/"),
(b":authority", b"example.com"),
],
true,
);
assert!(err.is_err());
}
#[test]
fn test_handle_header_rejects_crlf_in_path_and_authority() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let err = try_decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/foo\r\nHost: attacker"),
(b":authority", b"example.com"),
],
true,
);
assert!(err.is_err(), "CRLF in :path must be rejected");
let err = try_decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com\r\nX: y"),
],
true,
);
assert!(err.is_err(), "CRLF in :authority must be rejected");
}
#[test]
fn test_handle_header_rejects_bad_status() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
for bad in [&b"20"[..], b"abc", b"+200", b" 200", b"00200", b"2000"] {
let err = try_decode_response_headers(
&mut pool,
&[(b":status", bad), (b"content-type", b"text/html")],
true,
);
assert!(err.is_err(), ":status {bad:?} must be rejected");
}
}
#[test]
fn test_handle_header_rejects_empty_status() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let err = try_decode_response_headers(
&mut pool,
&[(b":status", b""), (b"content-type", b"text/html")],
true,
);
assert!(err.is_err(), "empty :status must be rejected");
}
#[test]
fn test_handle_header_rejects_missing_status() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let err = try_decode_response_headers(&mut pool, &[(b"content-type", b"text/html")], true);
assert!(err.is_err(), "response without :status must be rejected");
}
#[test]
fn test_handle_header_rejects_ctl_in_cookie_value() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let err = try_decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"cookie", b"a=1\r\nX-Smuggled: yes"),
],
true,
);
assert!(err.is_err(), "CRLF in cookie value must be rejected");
}
#[test]
fn test_handle_header_rejects_ctl_in_cookie_key() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let err = try_decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"cookie", b"a\rb=1"),
],
true,
);
assert!(err.is_err(), "CR in cookie key must be rejected");
}
#[test]
fn test_write_regular_header_content_length_digit_only() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let reject_cases: &[(&[u8], &str)] = &[
(b"", "empty"),
(b" 42", "leading whitespace"),
(b"42 ", "trailing whitespace"),
(b"+42", "unary plus"),
(b"-42", "negative sign"),
(b"4\xFF2", "non-ASCII byte"),
(b"0x10", "non-digit hex chars"),
];
for (val, label) in reject_cases {
let mut kawa = make_generic_kawa(&mut pool, Kind::Request);
let result = write_regular_header(&mut kawa, b"content-length", val);
assert!(
matches!(result, Err(RejectReason::DuplicateCl)),
"content-length {label:?} ({val:?}) must yield DuplicateCl, got {result:?}",
);
}
let mut kawa = make_generic_kawa(&mut pool, Kind::Request);
let result = write_regular_header(&mut kawa, b"content-length", b"42");
assert!(
result.is_ok(),
"valid content-length '42' was rejected: {result:?}"
);
assert_eq!(
kawa.body_size,
kawa::BodySize::Length(42),
"body_size must be Length(42) after parsing '42'",
);
}
#[test]
fn test_handle_header_rejects_bad_content_length() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
for bad in [
&b" 42"[..], b"+42", b"42 ", b"-1", b"", b"0x10", b"4\xFF2", ] {
let err = try_decode_request_headers(
&mut pool,
&[
(b":method", b"POST"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"content-length", bad),
],
true,
);
assert!(err.is_err(), "content-length {bad:?} must be rejected");
}
let ok = try_decode_request_headers(
&mut pool,
&[
(b":method", b"POST"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"content-length", b"42"),
],
false,
);
assert!(ok.is_ok(), "valid content-length must be accepted: {ok:?}");
}
#[test]
fn test_handle_trailer_rejects_ctl_in_value() {
use kawa::Buffer;
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let checkout = pool.checkout().expect("checkout");
let mut kawa: GenericHttpStream = kawa::Kawa::new(Kind::Request, Buffer::new(checkout));
kawa.push_block(Block::StatusLine);
kawa.detached.status_line = StatusLine::Request {
version: Version::V20,
method: Store::Static(b"GET"),
uri: Store::Static(b"/"),
authority: Store::Static(b"example.com"),
path: Store::Static(b"/"),
};
let mut encoder = loona_hpack::Encoder::new();
let mut encoded = Vec::new();
encoder
.encode_header_into((&b"x-trailer"[..], &b"val\nsmuggled"[..]), &mut encoded)
.unwrap();
let mut decoder = loona_hpack::Decoder::new();
let err = handle_trailer(
&mut kawa,
&encoded,
true,
&mut decoder,
crate::protocol::mux::h2::MAX_HEADER_LIST_SIZE as u32,
u32::MAX,
false,
);
assert!(err.is_err(), "LF in trailer value must be rejected");
}
#[test]
fn test_h2_cookie_value_with_equals() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let kawa = decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"cookie", b"token=abc=def=="),
],
true,
);
assert_eq!(kawa.detached.jar.len(), 1);
let buf = kawa.storage.buffer();
assert_eq!(kawa.detached.jar[0].key.data(buf), b"token");
assert_eq!(kawa.detached.jar[0].val.data(buf), b"abc=def==");
}
#[test]
fn test_strip_port_and_host_authority_match() {
assert_eq!(strip_port(b"example.com"), b"example.com");
assert_eq!(strip_port(b"example.com:8443"), b"example.com");
assert_eq!(strip_port(b"example.com:abc"), b"example.com:abc");
assert_eq!(strip_port(b"[::1]:8443"), b"[::1]");
assert_eq!(strip_port(b"[::1]"), b"[::1]");
assert_eq!(strip_port(b"[::1]:"), b"[::1]:");
assert_eq!(strip_port(b"example.com:"), b"example.com:");
assert!(host_matches_authority(b"example.com", b"example.com"));
assert!(host_matches_authority(b"Example.COM:80", b"example.com:80"));
assert!(host_matches_authority(b"Example.com", b"example.com"));
assert!(host_matches_authority(b"example.com:80", b"example.com:80"));
assert!(host_matches_authority(b"example.com:80", b"example.com"));
assert!(host_matches_authority(b"example.com", b"example.com:443"));
assert!(!host_matches_authority(
b"example.com:80",
b"example.com:443"
));
assert!(!host_matches_authority(b"evil.com", b"example.com"));
assert!(!host_matches_authority(b"example.com.evil", b"example.com"));
}
#[test]
fn test_handle_header_host_matches_authority_is_deduplicated() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let kawa = decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"host", b"Example.com"),
],
true,
);
let buf = kawa.storage.buffer();
for block in kawa.blocks.iter() {
if let Block::Header(pair) = block {
assert!(
!compare_no_case(pair.key.data(buf), b"host"),
"literal host header must be dropped when it matches :authority"
);
}
}
}
#[test]
fn test_handle_header_host_mismatch_rejected() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let err = try_decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"host", b"evil.com"),
],
true,
);
assert!(
matches!(err, Err((H2Error::ProtocolError, _))),
"host != :authority must yield PROTOCOL_ERROR, got {err:?}"
);
}
#[test]
fn test_handle_header_multiple_disagreeing_host_rejected() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let err = try_decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"host", b"example.com"),
(b"host", b"evil.com"),
],
true,
);
assert!(
matches!(err, Err((H2Error::ProtocolError, _))),
"disagreeing host headers must yield PROTOCOL_ERROR, got {err:?}"
);
}
#[test]
fn test_handle_header_host_with_port_matches_authority() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let kawa = decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"host", b"example.com:8443"),
],
true,
);
let buf = kawa.storage.buffer();
for block in kawa.blocks.iter() {
if let Block::Header(pair) = block {
assert!(
!compare_no_case(pair.key.data(buf), b"host"),
"literal host header must be dropped (port-stripped match)"
);
}
}
}
#[test]
fn test_handle_header_host_crlf_rejected() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let err = try_decode_request_headers(
&mut pool,
&[
(b":method", b"GET"),
(b":scheme", b"https"),
(b":path", b"/"),
(b":authority", b"example.com"),
(b"host", b"example.com\r\nX-Smuggled: yes"),
],
true,
);
assert!(err.is_err(), "CRLF in host header must be rejected");
}
#[test]
fn test_h2_to_h1_body_without_content_length_forces_chunked() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let kawa = decode_request_headers(
&mut pool,
&[
(b":method", b"POST"),
(b":scheme", b"https"),
(b":path", b"/upload"),
(b":authority", b"example.com"),
],
false, );
assert_eq!(kawa.body_size, BodySize::Chunked);
let buf = kawa.storage.buffer();
let has_te_chunked = kawa.blocks.iter().any(|b| {
matches!(b, Block::Header(Pair { key, val })
if key.data(buf).eq_ignore_ascii_case(b"Transfer-Encoding")
&& val.data(buf).eq_ignore_ascii_case(b"chunked"))
});
assert!(
has_te_chunked,
"H2→H1 chunked body must emit Transfer-Encoding: chunked for trailer support"
);
}
fn decode_trailer(
pool: &mut crate::pool::Pool,
trailers: &[(&[u8], &[u8])],
) -> GenericHttpStream {
let mut encoder = loona_hpack::Encoder::new();
let mut encoded = Vec::new();
for &(name, value) in trailers {
encoder
.encode_header_into((name, value), &mut encoded)
.unwrap();
}
let mut decoder = loona_hpack::Decoder::new();
let mut kawa = make_generic_kawa(pool, Kind::Request);
let result = handle_trailer(
&mut kawa,
&encoded,
true, &mut decoder,
crate::protocol::mux::h2::MAX_HEADER_LIST_SIZE as u32,
u32::MAX,
false, );
assert!(result.is_ok(), "handle_trailer failed: {:?}", result.err());
kawa
}
fn surviving_trailer_keys(kawa: &GenericHttpStream) -> Vec<Vec<u8>> {
let buf = kawa.storage.buffer();
kawa.blocks
.iter()
.filter_map(|b| match b {
Block::Header(Pair { key, .. }) => Some(key.data(buf).to_vec()),
_ => None,
})
.collect()
}
#[test]
fn handle_trailer_elides_spoof_vector_headers() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let kawa = decode_trailer(
&mut pool,
&[
(b"x-real-ip", b"1.2.3.4"),
(b"x-forwarded-for", b"5.6.7.8"),
(b"forwarded", b"for=9.10.11.12"),
(b"x-request-id", b"attacker-correlation-id"),
(b"x-trailer-keep", b"please-keep-me"),
],
);
let surviving = surviving_trailer_keys(&kawa);
assert_eq!(
surviving,
vec![b"x-trailer-keep".to_vec()],
"only non-spoof trailers must survive the RFC 9110 §6.5 elision"
);
}
#[test]
fn handle_trailer_drops_each_spoof_header_individually() {
for &name in &[
b"x-real-ip" as &[u8],
b"x-forwarded-for",
b"forwarded",
b"x-request-id",
] {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let kawa = decode_trailer(&mut pool, &[(name, b"v")]);
let surviving = surviving_trailer_keys(&kawa);
assert!(
surviving.is_empty(),
"trailer with only {} should leave no surviving block",
std::str::from_utf8(name).unwrap()
);
}
}
#[test]
fn handle_trailer_keeps_grpc_status() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let kawa = decode_trailer(&mut pool, &[(b"grpc-status", b"0")]);
let surviving = surviving_trailer_keys(&kawa);
assert_eq!(surviving, vec![b"grpc-status".to_vec()]);
}
#[test]
fn test_h2_to_h1_body_with_content_length_keeps_length_framing() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let kawa = decode_request_headers(
&mut pool,
&[
(b":method", b"POST"),
(b":scheme", b"https"),
(b":path", b"/upload"),
(b":authority", b"example.com"),
(b"content-length", b"42"),
],
false,
);
assert_eq!(kawa.body_size, BodySize::Length(42));
let buf = kawa.storage.buffer();
let has_te = kawa.blocks.iter().any(|b| {
matches!(b, Block::Header(Pair { key, .. })
if key.data(buf).eq_ignore_ascii_case(b"Transfer-Encoding"))
});
assert!(
!has_te,
"Transfer-Encoding must not be retro-fitted when Content-Length was declared"
);
}
}