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::{
pool::Checkout,
protocol::{
http::{
editor::{TRAILER_FORBIDDEN_FIELDS, TRAILER_SPOOF_VECTOR_HEADERS},
parser::compare_no_case,
},
mux::{
GenericHttpStream, StreamId,
h2::MetricEvent,
h2_scheduler::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, $events:expr) => {
match store_pseudo_header(&$field, $regular, $kawa, &$value) {
Ok(s) => $field = s,
Err(reason) => {
metric_reject(reason, $events);
*$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, events: &mut Vec<MetricEvent>) {
events.push(MetricEvent::HeaderRejected {
reason_key: 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 record_rejected_request(
kawa: &mut GenericHttpStream,
method: Store,
authority: Store,
path: Store,
) {
debug_assert!(
matches!(kawa.kind, Kind::Request),
"only a request line is recorded for a rejected H2 field block"
);
kawa.blocks.clear();
kawa.out.clear();
kawa.detached.status_line = StatusLine::Request {
version: Version::V20,
method,
uri: Store::Empty,
authority,
path,
};
kawa.parsing_phase
.error("H2 request headers rejected".into());
debug_assert!(
kawa.is_error() && kawa.is_completed(),
"a rejected H2 request front must be in error with nothing queued"
);
}
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..])
&& 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 crate::protocol::mux::hpack::Decoder,
input: &[u8],
max_decoded_bytes: usize,
max_header_fields: u32,
events: &mut Vec<MetricEvent>,
mut per_header: F,
) -> Result<bool, (H2Error, bool)>
where
F: FnMut(Cow<[u8]>, Cow<[u8]>, &mut bool, &mut usize, &mut Vec<MetricEvent>),
{
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, events);
invalid_headers = true;
return;
}
per_header(k, v, &mut invalid_headers, &mut field_count, events);
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, events);
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, events);
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 crate::protocol::mux::hpack::Decoder,
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,
) -> (Vec<MetricEvent>, Result<(), (H2Error, bool)>)
where
C: ParserCallbacks<Checkout>,
{
let mut events: Vec<MetricEvent> = Vec::new();
if !kawa.is_initial() {
let status = handle_trailer(
kawa,
input,
end_stream,
decoder,
max_header_list_size,
max_header_fields,
elide_x_real_ip,
&mut events,
);
return (events, status);
}
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 mut authority_refused = false;
let invalid_headers = decode_headers_with_budget(
decoder,
input,
max_decoded_bytes,
max_header_fields,
&mut events,
|k, v, invalid_headers, field_count, events| {
if compare_no_case(&k, b":method") {
if !v.iter().all(|&b| is_tchar(b)) {
metric_reject(RejectReason::InvalidMethod, events);
*invalid_headers = true;
return;
}
store_or_reject!(method, regular_headers, kawa, v, invalid_headers, events);
} else if compare_no_case(&k, b":scheme") {
if v.as_ref() != b"http" && v.as_ref() != b"https" {
metric_reject(RejectReason::InvalidScheme, events);
*invalid_headers = true;
return;
}
store_or_reject!(scheme, regular_headers, kawa, v, invalid_headers, events);
} else if compare_no_case(&k, b":path") {
if v.contains(&b'#') {
metric_reject(RejectReason::InvalidPath, events);
*invalid_headers = true;
return;
}
store_or_reject!(path, regular_headers, kawa, v, invalid_headers, events);
} else if compare_no_case(&k, b":authority") {
match store_pseudo_header(&authority, regular_headers, kawa, &v) {
Ok(s) => authority = s,
Err(reason) => {
metric_reject(reason, events);
*invalid_headers = true;
authority_refused = true;
}
}
} else if k.starts_with(b":") {
metric_reject(RejectReason::UnknownPseudo, events);
*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, events);
*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, events);
*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, events);
*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, events);
*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, events);
*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,
},
);
}
}
},
);
let rejection: Option<(H2Error, bool)> = 'validate: {
let invalid_headers = match invalid_headers {
Ok(invalid) => invalid,
Err(error) => break 'validate Some(error),
};
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)) {
break 'validate Some((H2Error::ProtocolError, false));
}
}
if invalid_headers
|| method.is_empty()
|| authority.is_empty()
|| path.is_empty()
|| scheme.is_empty()
{
error!("{} INVALID HEADERS", log_module_context!());
break 'validate Some((H2Error::ProtocolError, false));
}
if host_conflict {
error!(
"{} H2 host header: multiple disagreeing values",
log_module_context!()
);
break 'validate Some((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!()
);
break 'validate Some((H2Error::ProtocolError, false));
}
}
None
};
if let Some(error) = rejection {
let origin_disputed = authority_refused
|| host_conflict
|| host_value.as_deref().is_some_and(|host| {
!host_matches_authority(
host,
authority.data_opt(kawa.storage.buffer()).unwrap_or(&[]),
)
});
let authority = if origin_disputed {
Store::Empty
} else {
authority
};
record_rejected_request(kawa, method, authority, path);
return (events, Err(error));
}
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,
&mut events,
|k, v, invalid_headers, _field_count, events| {
if compare_no_case(&k, b":status") {
if v.len() != 3 || !v.iter().all(|b| b.is_ascii_digit()) {
metric_reject(RejectReason::InvalidStatus, events);
*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, events);
*invalid_headers = true;
}
}
} else if k.starts_with(b":") {
metric_reject(RejectReason::UnknownPseudo, events);
*invalid_headers = true;
} else {
regular_headers = true;
if let Err(reason) = write_regular_header(kawa, &k, &v) {
metric_reject(reason, events);
*invalid_headers = true;
}
}
},
);
let invalid_headers = match invalid_headers {
Ok(invalid) => invalid,
Err(error) => return (events, Err(error)),
};
if invalid_headers || status.is_empty() {
error!("{} INVALID HEADERS", log_module_context!());
return (events, Err((H2Error::ProtocolError, false)));
}
if code == 101 {
error!(
"{} :status 101 in an H2 response (RFC 9113 §8.6)",
log_module_context!()
);
metric_reject(RejectReason::InvalidStatus, &mut events);
return (events, 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);
let (interim, no_content_length_allowed) = match kawa.detached.status_line {
StatusLine::Response { code, .. } if matches!(kawa.kind, Kind::Response) => (
(100..200).contains(&code),
(100..200).contains(&code) || code == 204,
),
_ => (false, false),
};
let no_content = matches!(kawa.kind, Kind::Response)
&& (no_content_length_allowed
|| kawa.parsing_phase == ParsingPhase::Terminated
|| matches!(
kawa.detached.status_line,
StatusLine::Response { code: 304, .. }
));
if no_content_length_allowed {
let buffer = kawa.storage.buffer();
let mut elided = false;
for block in kawa.blocks.iter_mut() {
if let Block::Header(pair) = block
&& compare_no_case(pair.key.data(buffer), b"content-length")
{
pair.elide();
elided = true;
}
}
if elided {
debug!(
"{} content-length removed from a 1xx or 204 response (RFC 9110 §8.6)",
log_module_context!()
);
}
kawa.body_size = BodySize::Empty;
}
if end_stream {
if let BodySize::Length(n) = kawa.body_size
&& n > 0
&& !no_content
{
error!(
"{} END_STREAM with non-zero Content-Length: {} (RFC 9113 §8.1.1)",
log_module_context!(),
n
);
return (events, Err((H2Error::ProtocolError, false)));
}
if let BodySize::Empty = kawa.body_size {
if !no_content {
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 && !no_content {
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 !end_stream && no_content {
kawa.parsing_phase = if interim {
ParsingPhase::Terminated
} else {
ParsingPhase::Body
};
return (events, Ok(()));
}
if kawa.parsing_phase == ParsingPhase::Terminated {
return (events, 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"
);
(events, Ok(()))
}
#[allow(clippy::too_many_arguments)]
pub fn handle_trailer(
kawa: &mut GenericHttpStream,
input: &[u8],
end_stream: bool,
decoder: &mut crate::protocol::mux::hpack::Decoder,
max_header_list_size: u32,
max_header_fields: u32,
elide_x_real_ip: bool,
events: &mut Vec<MetricEvent>,
) -> Result<(), (H2Error, bool)> {
let _ = elide_x_real_ip;
if !end_stream {
return Err((H2Error::ProtocolError, false));
}
let first_trailer_block = kawa.blocks.len();
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, events);
invalid_trailers = true;
return;
}
if let Some(reason) = classify_invalid_h2_header(&k, &v) {
metric_reject(reason, events);
invalid_trailers = true;
return;
}
if TRAILER_SPOOF_VECTOR_HEADERS.contains(&k.as_ref()) {
events.push(MetricEvent::TrailerSpoofVectorElided);
return;
}
if matches!(kawa.kind, Kind::Request) && TRAILER_FORBIDDEN_FIELDS.contains(&k.as_ref()) {
events.push(MetricEvent::TrailerForbiddenFieldElided);
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, events);
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, events);
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, events);
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"
);
kawa.blocks.insert(
first_trailer_block,
Block::Flags(Flags {
end_body: true,
end_chunk: false,
end_header: false,
end_stream: false,
}),
);
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::*;
fn proxy_counter(key: &str) -> i64 {
use sozu_command::proto::command::filtered_metrics::Inner;
crate::metrics::METRICS.with(|metrics| {
metrics
.borrow_mut()
.dump_local_proxy_metrics()
.get(key)
.and_then(|fm| fm.inner.as_ref())
.and_then(|inner| match inner {
Inner::Count(v) => Some(*v),
_ => None,
})
.unwrap_or(0)
})
}
#[test]
fn a_header_rejection_is_returned_not_recorded() {
let before = proxy_counter(crate::metrics::names::h2::HEADERS_REJECTED_TOTAL);
let mut events = Vec::new();
metric_reject(RejectReason::InvalidMethod, &mut events);
assert_eq!(
proxy_counter(crate::metrics::names::h2::HEADERS_REJECTED_TOTAL),
before,
"pkawa must not write METRICS: the rejection travels back to \
`ConnectionH2` as a MetricEvent and its shell records it"
);
assert_eq!(events.len(), 1, "exactly one event per rejection");
assert!(
matches!(events[0], MetricEvent::HeaderRejected { .. }),
"and it carries the per-reason key alongside the roll-up, got {:?}",
events[0]
);
}
#[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 = crate::protocol::mux::hpack::Encoder::new();
let mut encoded = Vec::new();
for &(name, value) in headers {
encoder.encode_header_into((name, value), &mut encoded);
}
let mut decoder = crate::protocol::mux::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 (_events, 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 = crate::protocol::mux::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);
}
let mut decoder = crate::protocol::mux::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,
)
.1
.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 = crate::protocol::mux::hpack::Encoder::new();
let mut encoded = Vec::new();
for &(name, value) in headers {
encoder.encode_header_into((name, value), &mut encoded);
}
let mut decoder = crate::protocol::mux::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,
)
.1
}
fn try_decode_response_headers(
pool: &mut crate::pool::Pool,
headers: &[(&[u8], &[u8])],
end_stream: bool,
) -> Result<(), (H2Error, bool)> {
let mut encoder = crate::protocol::mux::hpack::Encoder::new();
let mut encoded = Vec::new();
for &(name, value) in headers {
encoder.encode_header_into((name, value), &mut encoded);
}
let mut decoder = crate::protocol::mux::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,
)
.1
}
#[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 a_101_response_is_a_stream_protocol_error() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
for end_stream in [false, true] {
assert_eq!(
try_decode_response_headers(&mut pool, &[(b":status", b"101")], end_stream),
Err((H2Error::ProtocolError, false)),
"end_stream={end_stream}"
);
}
assert_eq!(
try_decode_response_headers(&mut pool, &[(b":status", b"103")], false),
Ok(())
);
}
#[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 = crate::protocol::mux::hpack::Encoder::new();
let mut encoded = Vec::new();
encoder.encode_header_into((&b"x-trailer"[..], &b"val\nsmuggled"[..]), &mut encoded);
let mut decoder = crate::protocol::mux::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,
&mut Vec::new(),
);
assert!(err.is_err(), "LF in trailer value must be rejected");
}
fn kawa_past_headers(pool: &mut crate::pool::Pool, kind: Kind) -> GenericHttpStream {
use kawa::Buffer;
let checkout = pool.checkout().expect("checkout");
let mut kawa: GenericHttpStream = kawa::Kawa::new(kind, Buffer::new(checkout));
kawa.push_block(Block::StatusLine);
kawa
}
type TrailerRun = (Result<(), (H2Error, bool)>, Vec<MetricEvent>, Vec<Vec<u8>>);
fn run_trailer(kawa: &mut GenericHttpStream, fields: &[(&[u8], &[u8])]) -> TrailerRun {
let mut encoder = crate::protocol::mux::hpack::Encoder::new();
let mut encoded = Vec::new();
for (name, value) in fields {
encoder.encode_header_into((*name, *value), &mut encoded);
}
let mut decoder = crate::protocol::mux::hpack::Decoder::new();
let mut events = Vec::new();
let result = handle_trailer(
kawa,
&encoded,
true,
&mut decoder,
crate::protocol::mux::h2::MAX_HEADER_LIST_SIZE as u32,
u32::MAX,
false,
&mut events,
);
let buf = kawa.storage.buffer();
let kept = kawa
.blocks
.iter()
.filter_map(|block| match block {
Block::Header(Pair { key, .. }) => Some(key.data(buf).to_vec()),
_ => None,
})
.collect();
(result, events, kept)
}
#[test]
fn test_handle_trailer_elides_forbidden_request_fields() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 16384);
let mut kawa = kawa_past_headers(&mut pool, Kind::Request);
let connection_specific: [&[u8]; 6] = [
b"connection",
b"keep-alive",
b"proxy-connection",
b"transfer-encoding",
b"upgrade",
b"te",
];
let forbidden: Vec<&[u8]> = TRAILER_FORBIDDEN_FIELDS
.iter()
.copied()
.filter(|name| !connection_specific.contains(name))
.collect();
assert_eq!(
forbidden.len(),
TRAILER_FORBIDDEN_FIELDS.len() - connection_specific.len()
);
let mut fields: Vec<(&[u8], &[u8])> = vec![(b"x-checksum", b"abc123")];
fields.extend(forbidden.iter().map(|name| (*name, &b"forged"[..])));
fields.push((b"grpc-status", b"0"));
let (result, events, kept) = run_trailer(&mut kawa, &fields);
assert!(
result.is_ok(),
"forbidden fields are elided, not refused: {result:?}"
);
assert_eq!(
kept,
vec![b"x-checksum".to_vec(), b"grpc-status".to_vec()],
"only the fields outside TRAILER_FORBIDDEN_FIELDS reach the backend"
);
let elided = events
.iter()
.filter(|event| matches!(event, MetricEvent::TrailerForbiddenFieldElided))
.count();
assert_eq!(elided, forbidden.len(), "one increment per elided field");
assert_eq!(events.len(), forbidden.len(), "no other metric event");
assert!(matches!(kawa.parsing_phase, ParsingPhase::Terminated));
}
#[test]
fn test_handle_trailer_still_refuses_connection_specific_fields() {
for name in [
&b"connection"[..],
b"keep-alive",
b"proxy-connection",
b"transfer-encoding",
b"upgrade",
b"te",
] {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 16384);
let mut kawa = kawa_past_headers(&mut pool, Kind::Request);
let (result, events, _) = run_trailer(&mut kawa, &[(name, b"x")]);
assert!(
matches!(result, Err((H2Error::ProtocolError, false))),
"{} must stay a malformed trailer: {result:?}",
String::from_utf8_lossy(name)
);
assert!(
!events
.iter()
.any(|event| matches!(event, MetricEvent::TrailerForbiddenFieldElided)),
"a refused field is not counted as elided"
);
}
}
#[test]
fn test_handle_trailer_elides_te_trailers() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 16384);
let mut kawa = kawa_past_headers(&mut pool, Kind::Request);
let (result, events, kept) =
run_trailer(&mut kawa, &[(b"te", b"trailers"), (b"grpc-status", b"0")]);
assert!(
result.is_ok(),
"`te: trailers` is elided, not refused: {result:?}"
);
assert_eq!(kept, vec![b"grpc-status".to_vec()]);
assert_eq!(events.len(), 1, "{events:?}");
assert!(matches!(
events[0],
MetricEvent::TrailerForbiddenFieldElided
));
}
#[test]
fn test_handle_trailer_elided_fields_count_against_the_bound() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 16384);
let mut kawa = kawa_past_headers(&mut pool, Kind::Request);
let mut encoder = crate::protocol::mux::hpack::Encoder::new();
let mut encoded = Vec::new();
for field in [
(&b"authorization"[..], &b"Bearer forged"[..]),
(b"cookie", b"session=forged"),
(b"grpc-status", b"0"),
] {
encoder.encode_header_into(field, &mut encoded);
}
let mut decoder = crate::protocol::mux::hpack::Decoder::new();
let mut events = Vec::new();
let result = handle_trailer(
&mut kawa,
&encoded,
true,
&mut decoder,
crate::protocol::mux::h2::MAX_HEADER_LIST_SIZE as u32,
2,
false,
&mut events,
);
assert!(
matches!(result, Err((H2Error::EnhanceYourCalm, false))),
"the third field goes over the bound of two: {result:?}"
);
}
#[test]
fn test_handle_trailer_elides_a_forbidden_name_from_the_dynamic_table() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 16384);
let mut kawa = kawa_past_headers(&mut pool, Kind::Request);
let mut decoder = crate::protocol::mux::hpack::Decoder::new();
let mut prime = vec![0x40, 13];
prime.extend_from_slice(b"authorization");
prime.push(5);
prime.extend_from_slice(b"prime");
decoder
.decode_with_cb(&prime, |_, _| {})
.expect("the priming block decodes");
let mut trailers = vec![0x40 | 62, 6];
trailers.extend_from_slice(b"forged");
trailers.extend_from_slice(&[0x00, 11]);
trailers.extend_from_slice(b"grpc-status");
trailers.extend_from_slice(&[1, b'0']);
let mut events = Vec::new();
let result = handle_trailer(
&mut kawa,
&trailers,
true,
&mut decoder,
crate::protocol::mux::h2::MAX_HEADER_LIST_SIZE as u32,
u32::MAX,
false,
&mut events,
);
assert!(result.is_ok(), "{result:?}");
let buf = kawa.storage.buffer();
let kept: Vec<Vec<u8>> = kawa
.blocks
.iter()
.filter_map(|block| match block {
Block::Header(Pair { key, .. }) => Some(key.data(buf).to_vec()),
_ => None,
})
.collect();
assert_eq!(kept, vec![b"grpc-status".to_vec()]);
assert_eq!(events.len(), 1, "{events:?}");
assert!(matches!(
events[0],
MetricEvent::TrailerForbiddenFieldElided
));
}
#[test]
fn test_handle_trailer_keeps_forbidden_names_in_responses() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 16384);
let mut kawa = kawa_past_headers(&mut pool, Kind::Response);
let (result, events, kept) = run_trailer(
&mut kawa,
&[
(b"content-type", b"application/grpc"),
(b"grpc-status", b"0"),
],
);
assert!(result.is_ok(), "{result:?}");
assert_eq!(
kept,
vec![b"content-type".to_vec(), b"grpc-status".to_vec()]
);
assert!(events.is_empty(), "{events:?}");
}
#[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 = crate::protocol::mux::hpack::Encoder::new();
let mut encoded = Vec::new();
for &(name, value) in trailers {
encoder.encode_header_into((name, value), &mut encoded);
}
let mut decoder = crate::protocol::mux::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, &mut Vec::new(),
);
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 TRAILER_SPOOF_VECTOR_HEADERS {
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()]);
}
fn h1_body_after_trailer(kind: Kind, fields: &[(&[u8], &[u8])]) -> String {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = make_generic_kawa(&mut pool, kind);
kawa.body_size = BodySize::Chunked;
kawa.push_block(Block::ChunkHeader(kawa::ChunkHeader {
length: Store::Static(b"5"),
}));
kawa.push_block(Block::Chunk(kawa::Chunk {
data: Store::Static(b"hello"),
}));
kawa.push_block(Block::Flags(Flags {
end_body: false,
end_chunk: true,
end_header: false,
end_stream: false,
}));
let mut encoder = crate::protocol::mux::hpack::Encoder::new();
let mut encoded = Vec::new();
for &(name, value) in fields {
encoder.encode_header_into((name, value), &mut encoded);
}
let result = handle_trailer(
&mut kawa,
&encoded,
true,
&mut crate::protocol::mux::hpack::Decoder::new(),
crate::protocol::mux::h2::MAX_HEADER_LIST_SIZE as u32,
u32::MAX,
false,
&mut Vec::new(),
);
assert!(result.is_ok(), "handle_trailer failed: {:?}", result.err());
kawa.prepare(&mut kawa::h1::BlockConverter);
let buffer = kawa.storage.buffer();
let serialized: Vec<u8> = kawa
.out
.iter()
.flat_map(|block| match block {
kawa::OutBlock::Store(store) => store.data(buffer).to_vec(),
kawa::OutBlock::Delimiter => Vec::new(),
})
.collect();
String::from_utf8(serialized).expect("the serialized body must be UTF-8")
}
#[test]
fn handle_trailer_follows_the_last_chunk_on_h1() {
for kind in [Kind::Request, Kind::Response] {
assert_eq!(
h1_body_after_trailer(kind, &[(b"grpc-status", b"0")]),
"5\r\nhello\r\n0\r\ngrpc-status: 0\r\n\r\n",
"{kind:?}: the trailer section must follow the last chunk"
);
}
}
#[test]
fn handle_trailer_with_every_field_elided_ends_the_chunked_body_on_h1() {
for kind in [Kind::Request, Kind::Response] {
assert_eq!(
h1_body_after_trailer(kind, &[(b"x-real-ip", b"1.2.3.4")]),
"5\r\nhello\r\n0\r\n\r\n",
"{kind:?}: an emptied trailer block must still end the body"
);
}
}
#[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"
);
}
#[test]
fn handle_trailer_filters_and_keeps_a_length_framed_block() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = kawa_past_headers(&mut pool, Kind::Request);
kawa.body_size = BodySize::Length(5);
let (result, events, kept) = run_trailer(
&mut kawa,
&[
(b"content-length", b"6666"),
(b"x-forwarded-for", b"5.6.7.8"),
(b"grpc-status", b"0"),
],
);
assert!(result.is_ok(), "handle_trailer failed: {:?}", result.err());
assert_eq!(kept, vec![b"grpc-status".to_vec()]);
assert!(
events
.iter()
.any(|event| matches!(event, MetricEvent::TrailerForbiddenFieldElided)),
"the forbidden-field elision must be counted: {events:?}"
);
assert!(
events
.iter()
.any(|event| matches!(event, MetricEvent::TrailerSpoofVectorElided)),
"the spoof-vector elision must be counted: {events:?}"
);
}
}