use std::{
borrow::Cow,
io::Write as _,
net::{IpAddr, SocketAddr},
rc::Rc,
str::from_utf8,
sync::Arc,
};
use rusty_ulid::Ulid;
use sozu_command_lib::{
logging::{CachedTags, LogContext},
proto::command::ForwardedHeaders,
};
use crate::metrics::names;
use crate::{
Protocol, RetrieveClusterError,
pool::Checkout,
protocol::{
http::{
GenericHttpStream,
parser::{Method, compare_no_case},
},
pipe::WebSocketContext,
},
};
#[cfg(feature = "opentelemetry")]
fn parse_traceparent(val: &kawa::Store, buf: &[u8]) -> Option<([u8; 32], [u8; 16])> {
let val = val.data(buf);
let (version, val) = parse_hex::<2>(val)?;
if version.as_slice() != b"00" {
return None;
}
let val = skip_separator(val)?;
let (trace_id, val) = parse_hex::<32>(val)?;
let val = skip_separator(val)?;
let (parent_id, val) = parse_hex::<16>(val)?;
let val = skip_separator(val)?;
let (_, val) = parse_hex::<2>(val)?;
val.is_empty().then_some((trace_id, parent_id))
}
#[cfg(feature = "opentelemetry")]
fn parse_hex<const N: usize>(buf: &[u8]) -> Option<([u8; N], &[u8])> {
let val: [u8; N] = buf.get(..N)?.try_into().unwrap();
val.iter()
.all(|c| c.is_ascii_hexdigit())
.then_some((val, &buf[N..]))
}
#[cfg(feature = "opentelemetry")]
fn skip_separator(buf: &[u8]) -> Option<&[u8]> {
buf.first().filter(|b| **b == b'-').map(|_| &buf[1..])
}
#[cfg(feature = "opentelemetry")]
fn random_id<const N: usize>() -> [u8; N] {
use rand::RngExt;
const CHARSET: &[u8] = b"0123456789abcdef";
let mut rng = rand::rng();
let mut buf = [0; N];
buf.fill_with(|| {
let n = rng.random_range(0..CHARSET.len());
CHARSET[n]
});
buf
}
#[cfg(feature = "opentelemetry")]
fn build_traceparent(trace_id: &[u8; 32], parent_id: &[u8; 16]) -> [u8; 55] {
debug_assert!(
trace_id.iter().all(u8::is_ascii_hexdigit) && parent_id.iter().all(u8::is_ascii_hexdigit),
"traceparent ids must be hex (CR/LF-free header value)"
);
let mut buf = [0; 55];
buf[..3].copy_from_slice(b"00-");
buf[3..35].copy_from_slice(trace_id);
buf[35] = b'-';
buf[36..52].copy_from_slice(parent_id);
buf[52..55].copy_from_slice(b"-01");
debug_assert!(
buf[2] == b'-' && buf[35] == b'-' && buf[52] == b'-',
"traceparent must carry dash separators at fixed offsets"
);
buf
}
pub(crate) fn has_connection_option(value: &[u8], option: &[u8]) -> bool {
value
.split(|byte| *byte == b',')
.any(|listed| compare_no_case(listed.trim_ascii(), option))
}
fn merge_connection_options_into_close(message: &mut GenericHttpStream, refuse_upgrade: bool) {
let buf = message.storage.buffer();
let mut options = Vec::new();
for block in &mut message.blocks {
if let kawa::Block::Header(header) = block
&& !header.is_elided()
{
let key = header.key.data(buf);
if compare_no_case(key, b"connection") {
for option in header.val.data(buf).split(|byte| *byte == b',') {
let option = option.trim_ascii();
let dropped = option.is_empty()
|| compare_no_case(option, b"keep-alive")
|| compare_no_case(option, b"close")
|| (refuse_upgrade && compare_no_case(option, b"upgrade"));
if !dropped {
options.extend_from_slice(option);
options.extend_from_slice(b", ");
}
}
header.elide();
} else if refuse_upgrade && compare_no_case(key, b"upgrade") {
header.elide();
}
}
}
let val = if options.is_empty() {
kawa::Store::Static(b"close")
} else {
options.extend_from_slice(b"close");
debug_assert!(
is_crlf_free(&options),
"the merged Connection options must be CR/LF-free (anti-injection)"
);
kawa::Store::from_vec(options)
};
message.push_block(kawa::Block::Header(kawa::Pair {
key: kawa::Store::Static(b"Connection"),
val,
}));
}
#[allow(dead_code)]
fn is_crlf_free(bytes: &[u8]) -> bool {
!bytes.iter().any(|b| *b == b'\r' || *b == b'\n')
}
fn write_forwarded_for_by(buf: &mut Vec<u8>, peer_ip: IpAddr, peer_port: u16, public_ip: IpAddr) {
let before = buf.len();
buf.extend_from_slice(b";for=\"");
write_ip_literal(buf, peer_ip);
buf.push(b':');
let mut port_buf = itoa::Buffer::new();
buf.extend_from_slice(port_buf.format(peer_port).as_bytes());
buf.extend_from_slice(b"\";by=");
match public_ip {
IpAddr::V4(_) => {
let _ = write!(buf, "{public_ip}");
}
IpAddr::V6(_) => {
buf.push(b'"');
write_ip_literal(buf, public_ip);
buf.push(b'"');
}
}
debug_assert!(
buf.len() > before,
"the Forwarded ;for=;by= fragment must emit bytes"
);
debug_assert!(
is_crlf_free(&buf[before..]),
"the Forwarded fragment must be CR/LF-free (anti-injection)"
);
}
fn write_ip_literal(buf: &mut Vec<u8>, ip: IpAddr) {
let before = buf.len();
match ip {
IpAddr::V4(_) => {
let _ = write!(buf, "{ip}");
}
IpAddr::V6(_) => {
buf.push(b'[');
let _ = write!(buf, "{ip}");
buf.push(b']');
}
}
debug_assert!(buf.len() > before, "write_ip_literal must emit bytes");
debug_assert!(
is_crlf_free(&buf[before..]),
"an IP literal spliced into a header must be CR/LF-free"
);
debug_assert_eq!(
matches!(ip, IpAddr::V6(_)),
buf[before] == b'[' && buf[buf.len() - 1] == b']',
"IPv6 literals are bracketed, IPv4 literals are not"
);
}
fn write_forwarded_suffix(
buf: &mut Vec<u8>,
proto: &str,
peer_ip: IpAddr,
peer_port: u16,
public_ip: IpAddr,
) {
debug_assert!(
is_crlf_free(proto.as_bytes()),
"the proto label spliced into Forwarded must be CR/LF-free"
);
let before = buf.len();
buf.extend_from_slice(b", proto=");
buf.extend_from_slice(proto.as_bytes());
write_forwarded_for_by(buf, peer_ip, peer_port, public_ip);
debug_assert!(
buf.len() > before + b", proto=".len(),
"the Forwarded suffix must emit the separator plus a value"
);
debug_assert!(
buf[before..].starts_with(b", proto="),
"the Forwarded suffix must start with the `, proto=` separator"
);
debug_assert!(
is_crlf_free(&buf[before..]),
"the Forwarded suffix must be CR/LF-free (anti-injection)"
);
}
const HOP_SEPARATOR: &[u8] = b", ";
pub(crate) fn emits_x_forwarded(mode: ForwardedHeaders) -> bool {
matches!(mode, ForwardedHeaders::Both | ForwardedHeaders::XForwarded)
}
pub(crate) fn emits_rfc7239(mode: ForwardedHeaders) -> bool {
matches!(mode, ForwardedHeaders::Both | ForwardedHeaders::Rfc7239)
}
pub(crate) fn strips_x_forwarded(mode: ForwardedHeaders) -> bool {
mode == ForwardedHeaders::Rfc7239
}
fn is_managed_x_forwarded(key: &[u8]) -> bool {
compare_no_case(key, b"X-Forwarded-For")
|| compare_no_case(key, b"X-Forwarded-Proto")
|| compare_no_case(key, b"X-Forwarded-Port")
|| compare_no_case(key, b"X-Forwarded-Host")
}
fn is_tchar(byte: u8) -> bool {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
}
fn is_valid_forwarded(value: &[u8]) -> bool {
let skip_ows = |i: &mut usize| {
while *i < value.len() && matches!(value[*i], b' ' | b'\t') {
*i += 1;
}
};
let mut i = 0;
loop {
loop {
skip_ows(&mut i);
if i < value.len() && is_tchar(value[i]) {
while i < value.len() && is_tchar(value[i]) {
i += 1;
}
if value.get(i) != Some(&b'=') {
return false;
}
i += 1;
match value.get(i) {
Some(b'"') => {
i += 1;
loop {
match value.get(i) {
None => return false,
Some(b'"') => {
i += 1;
break;
}
Some(b'\\') => match value.get(i + 1) {
Some(&escaped)
if escaped == b'\t'
|| escaped == b' '
|| (0x21..=0x7e).contains(&escaped)
|| escaped >= 0x80 =>
{
i += 2
}
_ => return false,
},
Some(&byte)
if byte == b'\t'
|| byte == b' '
|| byte == 0x21
|| (0x23..=0x7e).contains(&byte)
|| byte >= 0x80 =>
{
i += 1
}
Some(_) => return false,
}
}
}
Some(&byte) if is_tchar(byte) => {
while i < value.len() && is_tchar(value[i]) {
i += 1;
}
}
_ => return false,
}
}
skip_ows(&mut i);
if value.get(i) == Some(&b';') {
i += 1;
} else {
break;
}
}
match value.get(i) {
None => return true,
Some(b',') => i += 1,
Some(_) => return false,
}
}
}
#[derive(Debug)]
pub(crate) struct ForwardingHop {
protocol: Protocol,
public_address: SocketAddr,
session_address: Option<SocketAddr>,
port: Rc<[u8]>,
x_forwarded_for: Option<Rc<[u8]>>,
forwarded: Option<Rc<[u8]>>,
}
impl ForwardingHop {
fn render(
protocol: Protocol,
proto: &str,
public_address: SocketAddr,
session_address: Option<SocketAddr>,
) -> Self {
let mut port_buf = itoa::Buffer::new();
let port = Rc::from(port_buf.format(public_address.port()).as_bytes());
let (x_forwarded_for, forwarded) = match session_address {
Some(peer) => {
let mut scratch = Vec::with_capacity(128);
scratch.extend_from_slice(HOP_SEPARATOR);
let _ = write!(scratch, "{}", peer.ip());
let x_forwarded_for = Rc::<[u8]>::from(&scratch[..]);
scratch.clear();
write_forwarded_suffix(
&mut scratch,
proto,
peer.ip(),
peer.port(),
public_address.ip(),
);
(Some(x_forwarded_for), Some(Rc::<[u8]>::from(&scratch[..])))
}
None => (None, None),
};
let hop = Self {
protocol,
public_address,
session_address,
port,
x_forwarded_for,
forwarded,
};
debug_assert!(
hop.x_forwarded_for.is_some() == session_address.is_some()
&& hop.forwarded.is_some() == session_address.is_some(),
"the peer values are rendered exactly when there is a peer"
);
debug_assert!(
[&hop.x_forwarded_for, &hop.forwarded]
.into_iter()
.flatten()
.all(|value| value.len() > HOP_SEPARATOR.len()
&& value.starts_with(HOP_SEPARATOR)
&& is_crlf_free(value)),
"a rendered hop is the separator followed by a CR/LF-free value"
);
debug_assert!(
!hop.port.is_empty() && hop.port.iter().all(u8::is_ascii_digit),
"the X-Forwarded-Port value is the rendered public port"
);
hop
}
fn renders(
&self,
protocol: Protocol,
public_address: SocketAddr,
session_address: Option<SocketAddr>,
) -> bool {
self.protocol == protocol
&& self.public_address == public_address
&& self.session_address == session_address
}
}
fn synthesised_from_hop(hop: &Rc<[u8]>) -> kawa::Store {
debug_assert!(
hop.starts_with(HOP_SEPARATOR),
"a rendered hop starts with the separator"
);
kawa::Store::Shared(hop.clone(), HOP_SEPARATOR.len() as u32)
}
fn extended_chain(client: &[u8], hop: &[u8]) -> kawa::Store {
let mut value = Vec::with_capacity(client.len() + hop.len());
value.extend_from_slice(client);
value.extend_from_slice(hop);
debug_assert!(
value.starts_with(client) && value.len() > client.len(),
"a client chain is only ever extended"
);
debug_assert!(
is_crlf_free(&value[client.len()..]) && value.len() == value.capacity(),
"the appended hop is CR/LF-free and the value exactly sized"
);
kawa::Store::from_vec(value)
}
#[derive(Debug)]
pub struct HttpContext {
pub keep_alive_backend: bool,
pub keep_alive_frontend: bool,
pub sticky_session_found: Option<String>,
pub method: Option<Method>,
pub authority: Option<String>,
pub path: Option<String>,
pub status: Option<u16>,
pub reason: Option<Cow<'static, str>>,
pub user_agent: Option<String>,
pub x_request_id: Option<Rc<str>>,
pub xff_chain: Option<String>,
#[cfg(feature = "opentelemetry")]
pub otel: Option<sozu_command::logging::OpenTelemetry>,
pub closing: bool,
pub session_id: Ulid,
pub id: Ulid,
pub backend_id: Option<Rc<str>>,
pub cluster_id: Option<sozu_command_lib::state::ClusterId>,
pub(crate) cluster_metrics_incarnation: crate::metrics::ClusterMetricsCapture,
pub affinity_key: Option<u64>,
pub protocol: Protocol,
pub public_address: SocketAddr,
pub session_address: Option<SocketAddr>,
pub sticky_name: String,
pub sticky_session: Option<String>,
pub backend_address: Option<SocketAddr>,
pub tls_server_name: Option<String>,
pub tls_cert_names: Option<Arc<Vec<String>>>,
pub strict_sni_binding: bool,
pub elide_x_real_ip: bool,
pub send_x_real_ip: bool,
pub forwarded_headers: ForwardedHeaders,
pub max_trailer_fields: u32,
pub trailer_fields: u32,
pub tls_version: Option<&'static str>,
pub tls_cipher: Option<&'static str>,
pub tls_alpn: Option<&'static str>,
pub sozu_id_header: String,
pub redirect_location: Option<String>,
pub www_authenticate: Option<String>,
pub original_authority: Option<String>,
pub headers_response: Vec<HeaderEditSnapshot>,
pub retry_after_seconds: Option<u32>,
pub frontend_redirect_template: Option<String>,
pub redirect_status: Option<u16>,
pub tags: Option<Rc<CachedTags>>,
pub(crate) forwarding_hop: Option<ForwardingHop>,
pub access_log_message: Option<&'static str>,
pub backends_unavailable: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum HeaderEditMode {
#[default]
Append,
SetIfAbsent,
Set,
}
#[derive(Debug, Clone)]
pub struct HeaderEditSnapshot {
pub key: Vec<u8>,
pub val: Vec<u8>,
pub mode: HeaderEditMode,
}
fn render_ulid(id: Ulid) -> [u8; 26] {
const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
let value = u128::from(id);
let mut rendered = [0u8; 26];
for (index, digit) in rendered.iter_mut().enumerate() {
let shift = 5 * (25 - index);
*digit = CROCKFORD[((value >> shift) & 0x1f) as usize];
}
debug_assert!(
rendered.iter().all(|digit| CROCKFORD.contains(digit)),
"a rendered ULID must only carry Crockford base-32 digits"
);
rendered
}
fn standard_reason(code: u16) -> Option<&'static str> {
let phrase = match code {
100 => "Continue",
101 => "Switching Protocols",
200 => "OK",
201 => "Created",
202 => "Accepted",
203 => "Non-Authoritative Information",
204 => "No Content",
205 => "Reset Content",
206 => "Partial Content",
300 => "Multiple Choices",
301 => "Moved Permanently",
302 => "Found",
303 => "See Other",
304 => "Not Modified",
305 => "Use Proxy",
307 => "Temporary Redirect",
308 => "Permanent Redirect",
400 => "Bad Request",
401 => "Unauthorized",
402 => "Payment Required",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
406 => "Not Acceptable",
407 => "Proxy Authentication Required",
408 => "Request Timeout",
409 => "Conflict",
410 => "Gone",
411 => "Length Required",
412 => "Precondition Failed",
413 => "Content Too Large",
414 => "URI Too Long",
415 => "Unsupported Media Type",
416 => "Range Not Satisfiable",
417 => "Expectation Failed",
421 => "Misdirected Request",
422 => "Unprocessable Content",
426 => "Upgrade Required",
429 => "Too Many Requests",
500 => "Internal Server Error",
501 => "Not Implemented",
502 => "Bad Gateway",
503 => "Service Unavailable",
504 => "Gateway Timeout",
505 => "HTTP Version Not Supported",
_ => return None,
};
debug_assert!(
!phrase.is_empty() && is_crlf_free(phrase.as_bytes()),
"a registered reason phrase is a non-empty single-line token"
);
Some(phrase)
}
fn has_non_digit_content_length(kawa: &GenericHttpStream) -> bool {
let buf = kawa.storage.buffer();
kawa.blocks.iter().any(|block| match block {
kawa::Block::Header(header)
if !header.is_elided() && compare_no_case(header.key.data(buf), b"content-length") =>
{
let val = header.val.data(buf);
val.is_empty() || !val.iter().all(u8::is_ascii_digit)
}
_ => false,
})
}
fn correlation_header_name(name: &str) -> kawa::Store {
const DEFAULT: &str = "Sozu-Id";
if name == DEFAULT {
kawa::Store::Static(DEFAULT.as_bytes())
} else {
kawa::Store::from_slice(name.as_bytes())
}
}
pub const TRAILER_SPOOF_VECTOR_HEADERS: [&[u8]; 7] = [
b"x-real-ip",
b"x-forwarded-for",
b"forwarded",
b"x-request-id",
b"x-forwarded-proto",
b"x-forwarded-port",
b"x-forwarded-host",
];
pub fn is_trailer_spoof_vector(name: &[u8]) -> bool {
TRAILER_SPOOF_VECTOR_HEADERS
.iter()
.any(|spoofed| compare_no_case(name, spoofed))
}
pub const TRAILER_FORBIDDEN_FIELDS: [&[u8]; 25] = [
b"content-length",
b"transfer-encoding",
b"host",
b"cache-control",
b"expect",
b"max-forwards",
b"pragma",
b"range",
b"te",
b"if-match",
b"if-none-match",
b"if-modified-since",
b"if-unmodified-since",
b"if-range",
b"authorization",
b"proxy-authorization",
b"cookie",
b"content-encoding",
b"content-type",
b"content-range",
b"trailer",
b"connection",
b"keep-alive",
b"proxy-connection",
b"upgrade",
];
pub fn is_trailer_forbidden_field(name: &[u8]) -> bool {
TRAILER_FORBIDDEN_FIELDS
.iter()
.any(|forbidden| compare_no_case(name, forbidden))
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct TrailerFilterOutcome {
pub spoof_vectors: usize,
pub forbidden: usize,
pub over_limit: bool,
}
impl kawa::h1::ParserCallbacks<Checkout> for HttpContext {
fn on_headers(&mut self, stream: &mut GenericHttpStream) {
match stream.kind {
kawa::Kind::Request => self.on_request_headers(stream),
kawa::Kind::Response => self.on_response_headers(stream),
}
}
}
impl HttpContext {
#[allow(clippy::too_many_arguments)]
pub fn new(
session_id: Ulid,
request_id: Ulid,
protocol: Protocol,
public_address: SocketAddr,
session_address: Option<SocketAddr>,
sticky_name: String,
sozu_id_header: String,
elide_x_real_ip: bool,
send_x_real_ip: bool,
) -> Self {
Self {
session_id,
id: request_id,
backend_id: None,
cluster_id: None,
cluster_metrics_incarnation: Default::default(),
affinity_key: None,
closing: false,
keep_alive_backend: true,
keep_alive_frontend: true,
protocol,
public_address,
session_address,
sticky_name,
sticky_session: None,
sticky_session_found: None,
method: None,
authority: None,
path: None,
status: None,
reason: None,
user_agent: None,
x_request_id: None,
xff_chain: None,
#[cfg(feature = "opentelemetry")]
otel: Default::default(),
backend_address: None,
tls_server_name: None,
tls_cert_names: None,
strict_sni_binding: true,
elide_x_real_ip,
send_x_real_ip,
forwarded_headers: ForwardedHeaders::Both,
max_trailer_fields: crate::protocol::mux::H2FloodConfig::default().max_header_fields(),
trailer_fields: 0,
tls_version: None,
tls_cipher: None,
tls_alpn: None,
sozu_id_header,
redirect_location: None,
www_authenticate: None,
original_authority: None,
headers_response: Vec::new(),
retry_after_seconds: None,
frontend_redirect_template: None,
redirect_status: None,
tags: None,
forwarding_hop: None,
access_log_message: None,
backends_unavailable: false,
}
}
pub(crate) fn set_cluster_metrics_incarnation(
&mut self,
incarnation: crate::metrics::ClusterMetricsCapture,
) {
self.cluster_metrics_incarnation = incarnation;
}
pub(crate) fn cluster_metrics_incarnation(&self) -> crate::metrics::ClusterMetricsCapture {
self.cluster_metrics_incarnation
}
fn on_request_headers(&mut self, request: &mut GenericHttpStream) {
let blocks_at_entry = request.blocks.len();
let (te_count, te_all_suffix_chunked) = {
const CHUNKED: &[u8] = b"chunked";
let buf0 = request.storage.buffer();
request
.blocks
.iter()
.filter_map(|block| match block {
kawa::Block::Header(header)
if !header.is_elided()
&& compare_no_case(header.key.data(buf0), b"transfer-encoding") =>
{
Some(header.val.data(buf0))
}
_ => None,
})
.fold((0usize, true), |(count, all_chunked), val| {
let suffix_chunked = val.len() >= CHUNKED.len()
&& compare_no_case(&val[val.len() - CHUNKED.len()..], CHUNKED);
(count + 1, all_chunked && suffix_chunked)
})
};
if te_count > 1
|| (te_count == 1
&& (!te_all_suffix_chunked || request.body_size != kawa::BodySize::Chunked))
{
incr!(names::http::FRONTEND_TE_SMUGGLING);
warn!(
"{} rejecting request: ambiguous Transfer-Encoding framing (possible CL.TE request smuggling)",
self.log_context()
);
request
.parsing_phase
.error("Transfer-Encoding conflicts with message framing".into());
return;
}
if has_non_digit_content_length(request) {
incr!(names::http::FRONTEND_CONTENT_LENGTH_INVALID);
warn!(
"{} rejecting request: Content-Length is not 1*DIGIT (possible request smuggling)",
self.log_context()
);
request
.parsing_phase
.error("Content-Length is not 1*DIGIT".into());
return;
}
let buf = request.storage.mut_buffer();
if let kawa::StatusLine::Request {
method,
authority,
path,
..
} = &request.detached.status_line
{
self.method = method.data_opt(buf).map(Method::new);
self.authority = authority
.data_opt(buf)
.and_then(|data| from_utf8(data).ok())
.map(ToOwned::to_owned);
self.path = path
.data_opt(buf)
.and_then(|data| from_utf8(data).ok())
.map(ToOwned::to_owned);
}
if request.parsing_phase == kawa::ParsingPhase::Body
&& request.body_size == kawa::BodySize::Empty
{
request.parsing_phase = kawa::ParsingPhase::Terminated;
}
let public_port = self.public_address.port();
let proto = match self.protocol {
Protocol::HTTP => "http",
Protocol::HTTPS => "https",
_ => unreachable!(),
};
debug_assert!(
proto == "http" || proto == "https",
"proto must be the http/https scheme label"
);
for cookie in &mut request.detached.jar {
let key = cookie.key.data(buf);
if key == self.sticky_name.as_bytes() {
let val = cookie.val.data(buf);
self.sticky_session_found = from_utf8(val).ok().map(ToOwned::to_owned);
cookie.elide();
debug_assert!(
cookie.is_elided(),
"the matched sticky cookie must be elided after capture"
);
}
}
let forwarded_headers = self.forwarded_headers;
let emits_x_forwarded = emits_x_forwarded(forwarded_headers);
let emits_rfc7239 = emits_rfc7239(forwarded_headers);
let strips_x_forwarded = strips_x_forwarded(forwarded_headers);
debug_assert!(
!(strips_x_forwarded && emits_x_forwarded),
"a mode that strips X-Forwarded-* must not emit it"
);
let mut x_for = None;
let mut forwarded = None;
let mut has_x_port = false;
let mut has_x_proto = false;
let mut has_x_request_id = false;
#[cfg(feature = "opentelemetry")]
let mut traceparent: Option<&mut kawa::Pair> = None;
#[cfg(feature = "opentelemetry")]
let mut tracestate: Option<&mut kawa::Pair> = None;
for block in &mut request.blocks {
match block {
kawa::Block::Header(header) if !header.is_elided() => {
let key = header.key.data(buf);
if compare_no_case(key, b"connection") {
let val = header.val.data(buf);
self.keep_alive_frontend &= !has_connection_option(val, b"close");
} else if strips_x_forwarded && is_managed_x_forwarded(key) {
if compare_no_case(key, b"X-Forwarded-For") {
self.xff_chain = header
.val
.data_opt(buf)
.and_then(|data| from_utf8(data).ok())
.map(ToOwned::to_owned);
}
header.elide();
debug_assert!(
header.is_elided(),
"a client X-Forwarded-* header must be elided in rfc7239 mode"
);
} else if !emits_x_forwarded
&& (compare_no_case(key, b"X-Forwarded-Proto")
|| compare_no_case(key, b"X-Forwarded-Port"))
{
debug_assert!(
forwarded_headers == ForwardedHeaders::None,
"only `none` passes a client X-Forwarded-Proto/-Port through"
);
} else if compare_no_case(key, b"X-Forwarded-Proto") {
has_x_proto = true;
incr!(names::http::TRUSTING_X_PROTO);
let val = header.val.data(buf);
if !compare_no_case(val, proto.as_bytes()) {
incr!(names::http::TRUSTING_X_PROTO_DIFF);
debug!(
"{} Trusting X-Forwarded-Proto for {:?} even though {:?} != {}",
self.log_context(),
self.authority,
val,
proto
);
}
} else if compare_no_case(key, b"X-Forwarded-Port") {
has_x_port = true;
incr!(names::http::TRUSTING_X_PORT);
let val = header.val.data(buf);
let mut port_buf = itoa::Buffer::new();
let expected = port_buf.format(public_port);
if !compare_no_case(val, expected.as_bytes()) {
incr!(names::http::TRUSTING_X_PORT_DIFF);
debug!(
"{} Trusting X-Forwarded-Port for {:?} even though {:?} != {}",
self.log_context(),
self.authority,
val,
expected
);
}
} else if compare_no_case(key, b"X-Forwarded-For") {
self.xff_chain = header
.val
.data_opt(buf)
.and_then(|data| from_utf8(data).ok())
.map(ToOwned::to_owned);
x_for = Some(header);
} else if compare_no_case(key, b"X-Real-IP") && self.elide_x_real_ip {
debug_assert!(
self.elide_x_real_ip,
"X-Real-IP is only elided when anti-spoofing is enabled"
);
header.elide();
debug_assert!(
header.is_elided(),
"client X-Real-IP must be elided when elide_x_real_ip is set"
);
} else if compare_no_case(key, b"Forwarded") {
if emits_rfc7239
&& !is_valid_forwarded(header.val.data_opt(buf).unwrap_or_default())
{
incr!(names::http::FORWARDED_MALFORMED_ELIDED);
debug!(
"{} eliding a malformed client Forwarded header",
self.log_context()
);
header.elide();
debug_assert!(
header.is_elided(),
"a malformed client Forwarded must be elided"
);
} else {
forwarded = Some(header);
}
} else if compare_no_case(key, b"User-Agent") {
self.user_agent = header
.val
.data_opt(buf)
.and_then(|data| from_utf8(data).ok())
.map(ToOwned::to_owned);
} else if compare_no_case(key, b"X-Request-Id") {
has_x_request_id = true;
self.x_request_id = header
.val
.data_opt(buf)
.and_then(|data| from_utf8(data).ok())
.map(Rc::from);
} else {
#[cfg(feature = "opentelemetry")]
if compare_no_case(key, b"traceparent") {
if let Some(hdr) = traceparent {
hdr.elide();
}
traceparent = Some(header);
} else if compare_no_case(key, b"tracestate") {
if let Some(hdr) = tracestate {
hdr.elide();
}
tracestate = Some(header);
}
}
}
_ => {}
}
}
#[cfg(feature = "opentelemetry")]
let (otel, has_traceparent) = {
let mut otel = sozu_command_lib::logging::OpenTelemetry::default();
let tp = traceparent
.as_ref()
.and_then(|hdr| parse_traceparent(&hdr.val, buf))
.map(|(trace_id, parent_id)| (trace_id, Some(parent_id)));
if let (None, Some(tracestate)) = (tp, tracestate) {
tracestate.elide();
}
let (trace_id, parent_id) = tp.unwrap_or_else(|| (random_id(), None));
otel.trace_id = trace_id;
otel.parent_span_id = parent_id;
otel.span_id = random_id();
if let Some(id) = &mut traceparent {
let new_val = build_traceparent(&otel.trace_id, &otel.span_id);
id.val.modify(buf, &new_val);
}
(otel, traceparent.is_some())
};
let (port_hop, x_forwarded_for_hop, forwarded_hop) = {
let hop = self.forwarding_hop(proto);
(
hop.port.clone(),
hop.x_forwarded_for.clone(),
hop.forwarded.clone(),
)
};
if let (Some(x_forwarded_for_hop), Some(forwarded_hop)) =
(x_forwarded_for_hop, forwarded_hop)
{
let has_x_for = x_for.is_some();
let has_forwarded = forwarded.is_some();
if emits_x_forwarded && let Some(header) = x_for {
header.val = extended_chain(header.val.data(buf), &x_forwarded_for_hop);
}
if emits_rfc7239 && let Some(header) = &mut forwarded {
header.val = extended_chain(header.val.data(buf), &forwarded_hop);
}
if emits_x_forwarded && !has_x_for {
let blocks_before = request.blocks.len();
request.push_block(kawa::Block::Header(kawa::Pair {
key: kawa::Store::Static(b"X-Forwarded-For"),
val: synthesised_from_hop(&x_forwarded_for_hop),
}));
debug_assert_eq!(
request.blocks.len(),
blocks_before + 1,
"creating X-Forwarded-For must push exactly one block"
);
}
if emits_rfc7239 && !has_forwarded {
let blocks_before = request.blocks.len();
request.push_block(kawa::Block::Header(kawa::Pair {
key: kawa::Store::Static(b"Forwarded"),
val: synthesised_from_hop(&forwarded_hop),
}));
debug_assert_eq!(
request.blocks.len(),
blocks_before + 1,
"creating Forwarded must push exactly one block"
);
}
if self.send_x_real_ip {
let blocks_before = request.blocks.len();
request.push_block(kawa::Block::Header(kawa::Pair {
key: kawa::Store::Static(b"X-Real-IP"),
val: synthesised_from_hop(&x_forwarded_for_hop),
}));
debug_assert_eq!(
request.blocks.len(),
blocks_before + 1,
"injecting X-Real-IP must push exactly one block"
);
}
}
#[cfg(feature = "opentelemetry")]
{
if !has_traceparent {
let val = build_traceparent(&otel.trace_id, &otel.span_id);
request.push_block(kawa::Block::Header(kawa::Pair {
key: kawa::Store::Static(b"traceparent"),
val: kawa::Store::from_slice(&val),
}));
}
self.otel = Some(otel);
}
if emits_x_forwarded && !has_x_port {
request.push_block(kawa::Block::Header(kawa::Pair {
key: kawa::Store::Static(b"X-Forwarded-Port"),
val: kawa::Store::Shared(port_hop, 0),
}));
}
if emits_x_forwarded && !has_x_proto {
request.push_block(kawa::Block::Header(kawa::Pair {
key: kawa::Store::Static(b"X-Forwarded-Proto"),
val: kawa::Store::Static(proto.as_bytes()),
}));
}
if self.closing {
merge_connection_options_into_close(request, true);
}
if has_x_request_id {
incr!(names::http::X_REQUEST_ID_PROPAGATED);
} else {
let rendered = render_ulid(self.id);
let value: Rc<str> = Rc::from(from_utf8(&rendered).expect("a rendered ULID is ASCII"));
debug_assert!(
is_crlf_free(value.as_bytes()),
"the generated X-Request-Id must be CR/LF-free"
);
request.push_block(kawa::Block::Header(kawa::Pair {
key: kawa::Store::Static(b"X-Request-Id"),
val: kawa::Store::Shared(Rc::<[u8]>::from(value.clone()), 0),
}));
self.x_request_id = Some(value);
incr!(names::http::X_REQUEST_ID_GENERATED);
}
debug_assert!(
self.x_request_id.is_some(),
"on_request_headers must record the forwarded X-Request-Id"
);
let blocks_before_sozu_id = request.blocks.len();
request.push_block(kawa::Block::Header(kawa::Pair {
key: correlation_header_name(&self.sozu_id_header),
val: kawa::Store::Shared(self.shared_id(), 0),
}));
debug_assert_eq!(
request.blocks.len(),
blocks_before_sozu_id + 1,
"the Sozu-Id correlation header must be pushed exactly once"
);
debug_assert!(
request.blocks.len() >= blocks_at_entry,
"header editing must never drop a block from the request"
);
debug_assert!(
!strips_x_forwarded
|| request.blocks.iter().all(|block| match block {
kawa::Block::Header(header) if !header.is_elided() =>
!is_managed_x_forwarded(header.key.data(request.storage.buffer())),
_ => true,
}),
"rfc7239 mode must forward no X-Forwarded-For/-Proto/-Port/-Host"
);
}
fn on_response_headers(&mut self, response: &mut GenericHttpStream) {
let blocks_at_entry = response.blocks.len();
if has_non_digit_content_length(response) {
incr!(names::http::BACKEND_CONTENT_LENGTH_INVALID);
warn!(
"{} rejecting response: Content-Length is not 1*DIGIT",
self.log_context()
);
response
.parsing_phase
.error("Content-Length is not 1*DIGIT".into());
return;
}
let buf = &mut response.storage.mut_buffer();
if let kawa::StatusLine::Response { code, reason, .. } = &response.detached.status_line {
self.status = Some(*code);
self.reason = reason
.data_opt(buf)
.and_then(|data| from_utf8(data).ok())
.map(|data| match standard_reason(*code) {
Some(phrase) if phrase == data => Cow::Borrowed(phrase),
_ => Cow::Owned(data.to_owned()),
});
debug_assert_eq!(
self.reason.as_deref().map(str::as_bytes),
reason.data_opt(buf).filter(|data| from_utf8(data).is_ok()),
"the captured reason must be the backend's own bytes"
);
debug_assert!(
!matches!(&self.reason, Some(Cow::Owned(owned))
if standard_reason(*code) == Some(owned.as_str())),
"a standard reason must be borrowed, never copied"
);
}
if self.method == Some(Method::Head) {
response.parsing_phase = kawa::ParsingPhase::Terminated;
}
let (is_http10, is_interim) = match &response.detached.status_line {
kawa::StatusLine::Response { version, code, .. } => (
matches!(version, kawa::Version::V10),
(100..200).contains(code),
),
_ => (false, false),
};
let mut announces_close = false;
let mut asks_keep_alive = false;
let mut has_transfer_encoding = false;
for block in &mut response.blocks {
match block {
kawa::Block::Header(header) if !header.is_elided() => {
let key = header.key.data(buf);
if compare_no_case(key, b"connection") {
let val = header.val.data(buf);
let is_close = has_connection_option(val, b"close");
announces_close |= is_close;
self.keep_alive_backend &= !is_close;
asks_keep_alive |= has_connection_option(val, b"keep-alive");
} else if compare_no_case(key, b"transfer-encoding") {
has_transfer_encoding = true;
}
}
_ => {}
}
}
if is_http10 && !is_interim {
let close_delimited = response.parsing_phase == kawa::ParsingPhase::Body
&& response.body_size == kawa::BodySize::Empty;
if !asks_keep_alive || close_delimited || has_transfer_encoding {
self.keep_alive_backend = false;
}
}
if !is_interim && (self.closing || (is_http10 && !self.keep_alive_backend)) {
merge_connection_options_into_close(response, false);
announces_close = true;
}
if is_http10 {
if let kawa::StatusLine::Response { version, .. } = &mut response.detached.status_line {
*version = kawa::Version::V11;
}
debug_assert!(
matches!(
response.detached.status_line,
kawa::StatusLine::Response {
version: kawa::Version::V11,
..
}
),
"an HTTP/1.0 response must be forwarded as HTTP/1.1"
);
debug_assert!(
self.keep_alive_backend || announces_close,
"a non-persistent HTTP/1.0 response must carry Connection: close"
);
}
if let Some(sticky_session) = &self.sticky_session
&& self.sticky_session != self.sticky_session_found
{
let blocks_before = response.blocks.len();
let mut cookie_buf =
Vec::with_capacity(self.sticky_name.len() + 1 + sticky_session.len() + 8);
cookie_buf.extend_from_slice(self.sticky_name.as_bytes());
cookie_buf.push(b'=');
cookie_buf.extend_from_slice(sticky_session.as_bytes());
cookie_buf.extend_from_slice(b"; Path=/");
debug_assert!(
cookie_buf.contains(&b'='),
"the Set-Cookie value must carry the name=value separator"
);
debug_assert!(
cookie_buf.ends_with(b"; Path=/"),
"the Set-Cookie value must end with the Path attribute"
);
debug_assert!(
is_crlf_free(&cookie_buf),
"the synthesised Set-Cookie value must be CR/LF-free (anti-injection)"
);
response.push_block(kawa::Block::Header(kawa::Pair {
key: kawa::Store::Static(b"Set-Cookie"),
val: kawa::Store::from_vec(cookie_buf),
}));
debug_assert_eq!(
response.blocks.len(),
blocks_before + 1,
"synthesising Set-Cookie must push exactly one block"
);
}
let blocks_before_sozu_id = response.blocks.len();
response.push_block(kawa::Block::Header(kawa::Pair {
key: correlation_header_name(&self.sozu_id_header),
val: kawa::Store::Shared(self.shared_id(), 0),
}));
debug_assert_eq!(
response.blocks.len(),
blocks_before_sozu_id + 1,
"the Sozu-Id correlation header must be pushed exactly once"
);
debug_assert!(
response.blocks.len() >= blocks_at_entry,
"header editing must never drop a block from the response"
);
}
fn forwarding_hop(&mut self, proto: &str) -> &ForwardingHop {
let (protocol, public_address, session_address) =
(self.protocol, self.public_address, self.session_address);
let cached = self
.forwarding_hop
.as_ref()
.is_some_and(|hop| hop.renders(protocol, public_address, session_address));
if !cached {
self.forwarding_hop = Some(ForwardingHop::render(
protocol,
proto,
public_address,
session_address,
));
}
let hop = self
.forwarding_hop
.as_ref()
.expect("the forwarding values were just rendered or reused");
debug_assert!(
hop.renders(protocol, public_address, session_address),
"the forwarding values must match the current inputs"
);
hop
}
fn shared_id(&self) -> Rc<[u8]> {
let rendered = render_ulid(self.id);
let shared = match &self.x_request_id {
Some(generated) if generated.as_bytes() == rendered => {
Rc::<[u8]>::from(generated.clone())
}
_ => Rc::from(&rendered[..]),
};
debug_assert_eq!(
&*shared, &rendered,
"the correlation header must carry this request's own id"
);
shared
}
pub fn filter_request_trailers(
&mut self,
kawa: &mut GenericHttpStream,
first_new_block: usize,
) -> TrailerFilterOutcome {
let mut outcome = TrailerFilterOutcome::default();
if !matches!(kawa.kind, kawa::Kind::Request)
|| kawa.body_size != kawa::BodySize::Chunked
|| !matches!(
kawa.parsing_phase,
kawa::ParsingPhase::Trailers | kawa::ParsingPhase::Terminated
)
{
return outcome;
}
debug_assert!(
first_new_block <= kawa.blocks.len(),
"first_new_block must be a block count read before the parse"
);
debug_assert!(
self.trailer_fields <= self.max_trailer_fields,
"an admitted trailer section fits max_trailer_fields"
);
let first_new_block = first_new_block.min(kawa.blocks.len());
let buf = kawa.storage.buffer();
let mut fields: u32 = 0;
for block in kawa.blocks.range_mut(first_new_block..).rev() {
match block {
kawa::Block::Flags(kawa::Flags {
end_body: false,
end_chunk: false,
end_header: true,
end_stream: true,
}) => {}
kawa::Block::Header(pair) => {
fields = fields.saturating_add(1);
if pair.is_elided() {
continue;
}
let key = pair.key.data(buf);
if is_trailer_spoof_vector(key) {
pair.elide();
outcome.spoof_vectors += 1;
incr!(names::http::TRAILER_SPOOF_VECTOR_ELIDED);
} else if is_trailer_forbidden_field(key) {
pair.elide();
outcome.forbidden += 1;
incr!(names::http::TRAILER_FORBIDDEN_FIELD_ELIDED);
}
debug_assert!(
pair.is_elided()
|| !(is_trailer_spoof_vector(pair.key.data(buf))
|| is_trailer_forbidden_field(pair.key.data(buf))),
"a spoof-vector or forbidden trailer field must be elided"
);
}
_ => break,
}
}
self.trailer_fields = self.trailer_fields.saturating_add(fields);
if self.trailer_fields > self.max_trailer_fields {
outcome.over_limit = true;
incr!(names::http::TRAILER_FIELD_LIMIT_EXCEEDED);
kawa.parsing_phase
.error("trailer section exceeds max_trailer_fields".into());
}
debug_assert!(
outcome.spoof_vectors + outcome.forbidden <= kawa.blocks.len() - first_new_block,
"only blocks appended by the last parse are elided"
);
debug_assert!(
!outcome.over_limit || kawa.is_error(),
"a trailer section over the limit marks the request in error"
);
outcome
}
pub fn reset(&mut self, request_id: Ulid) {
let session_id_before = self.session_id;
let id_before = self.id;
debug_assert_ne!(
request_id, id_before,
"the next request of a connection needs an id of its own"
);
let strict_sni_before = self.strict_sni_binding;
let elide_before = self.elide_x_real_ip;
let send_before = self.send_x_real_ip;
let forwarded_headers_before = self.forwarded_headers;
let max_trailer_fields_before = self.max_trailer_fields;
let tls_version_before = self.tls_version;
let tls_cipher_before = self.tls_cipher;
let tls_alpn_before = self.tls_alpn;
let forwarding_hop_before = self
.forwarding_hop
.as_ref()
.map(|hop| Rc::as_ptr(&hop.port));
self.id = request_id;
self.keep_alive_backend = true;
self.keep_alive_frontend = true;
self.sticky_session_found = None;
self.sticky_session = None;
self.affinity_key = None;
self.cluster_metrics_incarnation = Default::default();
self.method = None;
self.authority = None;
self.path = None;
self.status = None;
self.reason = None;
self.user_agent = None;
self.x_request_id = None;
self.xff_chain = None;
self.redirect_location = None;
self.www_authenticate = None;
self.original_authority = None;
self.headers_response.clear();
self.trailer_fields = 0;
self.backends_unavailable = false;
debug_assert!(
self.method.is_none()
&& self.sticky_session.is_none()
&& self.authority.is_none()
&& self.path.is_none()
&& self.status.is_none()
&& self.x_request_id.is_none()
&& self.headers_response.is_empty()
&& self.trailer_fields == 0
&& !self.backends_unavailable,
"reset() must clear all request-scoped state"
);
debug_assert!(
self.keep_alive_backend && self.keep_alive_frontend,
"reset() must restore keep-alive to its optimistic default"
);
debug_assert_eq!(
self.session_id, session_id_before,
"reset() must preserve the connection session id"
);
debug_assert!(
self.id == request_id && self.id != id_before,
"reset() must give the next request its own id"
);
debug_assert!(
self.strict_sni_binding == strict_sni_before
&& self.elide_x_real_ip == elide_before
&& self.send_x_real_ip == send_before
&& self.forwarded_headers == forwarded_headers_before
&& self.max_trailer_fields == max_trailer_fields_before
&& self.tls_version == tls_version_before
&& self.tls_cipher == tls_cipher_before
&& self.tls_alpn == tls_alpn_before,
"reset() must preserve connection-scoped TLS/listener knobs"
);
debug_assert_eq!(
self.forwarding_hop
.as_ref()
.map(|hop| Rc::as_ptr(&hop.port)),
forwarding_hop_before,
"reset() must keep the connection's forwarding values"
);
}
pub fn extract_route(&self) -> Result<(&str, &str, &Method), RetrieveClusterError> {
let given_method = self.method.as_ref().ok_or(RetrieveClusterError::NoMethod)?;
let given_authority = self
.authority
.as_deref()
.ok_or(RetrieveClusterError::NoHost)?;
let given_path = self.path.as_deref().ok_or(RetrieveClusterError::NoPath)?;
debug_assert!(
std::ptr::eq(given_authority, self.authority.as_deref().unwrap())
&& std::ptr::eq(given_path, self.path.as_deref().unwrap()),
"extract_route must return (authority, path, method) in order"
);
Ok((given_authority, given_path, given_method))
}
pub fn get_route(&self) -> String {
if let Some(method) = &self.method {
if let Some(authority) = &self.authority {
if let Some(path) = &self.path {
return format!("{method} {authority}{path}");
}
return format!("{method} {authority}");
}
return format!("{method}");
}
String::new()
}
pub fn websocket_context(&self) -> WebSocketContext {
WebSocketContext::Http {
method: self.method.clone(),
authority: self.authority.clone(),
path: self.path.clone(),
reason: self.reason.as_deref().map(ToOwned::to_owned),
status: self.status,
}
}
pub fn log_context(&self) -> LogContext<'_> {
let ctx = LogContext {
session_id: self.session_id,
request_id: Some(self.id),
cluster_id: self.cluster_id.as_deref(),
backend_id: self.backend_id.as_deref(),
};
debug_assert!(
ctx.request_id.is_some(),
"log_context must always carry the request id for correlation"
);
ctx
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
fn make_context() -> HttpContext {
HttpContext::new(
Ulid::generate(),
Ulid::generate(),
Protocol::HTTP,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080),
Some(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
54321,
)),
"SERVERID".to_owned(),
"Sozu-Id".to_owned(),
false,
false,
)
}
#[test]
fn test_sozu_id_header_default_name_stored_on_context() {
let ctx = make_context();
assert_eq!(ctx.sozu_id_header, "Sozu-Id");
}
#[test]
fn test_sozu_id_header_custom_name_stored_on_context() {
let ctx = HttpContext::new(
Ulid::generate(),
Ulid::generate(),
Protocol::HTTP,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080),
None,
"SERVERID".to_owned(),
"X-Edge-Id".to_owned(),
false,
false,
);
assert_eq!(ctx.sozu_id_header, "X-Edge-Id");
}
#[test]
fn test_extract_route_all_present() {
let mut ctx = make_context();
ctx.method = Some(Method::Get);
ctx.authority = Some("example.com".to_owned());
ctx.path = Some("/index.html".to_owned());
let (authority, path, method) = ctx.extract_route().unwrap();
assert_eq!(authority, "example.com");
assert_eq!(path, "/index.html");
assert_eq!(method, &Method::Get);
}
#[test]
fn test_extract_route_no_method() {
let mut ctx = make_context();
ctx.authority = Some("example.com".to_owned());
ctx.path = Some("/".to_owned());
let err = ctx.extract_route().unwrap_err();
assert!(matches!(err, RetrieveClusterError::NoMethod));
}
#[test]
fn test_extract_route_no_host() {
let mut ctx = make_context();
ctx.method = Some(Method::Get);
ctx.path = Some("/".to_owned());
let err = ctx.extract_route().unwrap_err();
assert!(matches!(err, RetrieveClusterError::NoHost));
}
#[test]
fn test_extract_route_no_path() {
let mut ctx = make_context();
ctx.method = Some(Method::Get);
ctx.authority = Some("example.com".to_owned());
let err = ctx.extract_route().unwrap_err();
assert!(matches!(err, RetrieveClusterError::NoPath));
}
#[test]
fn test_get_route_all_present() {
let mut ctx = make_context();
ctx.method = Some(Method::Get);
ctx.authority = Some("example.com".to_owned());
ctx.path = Some("/api/v1".to_owned());
assert_eq!(ctx.get_route(), "GET example.com/api/v1");
}
#[test]
fn test_get_route_method_and_authority_only() {
let mut ctx = make_context();
ctx.method = Some(Method::Post);
ctx.authority = Some("example.com".to_owned());
assert_eq!(ctx.get_route(), "POST example.com");
}
#[test]
fn test_get_route_method_only() {
let mut ctx = make_context();
ctx.method = Some(Method::Delete);
assert_eq!(ctx.get_route(), "DELETE");
}
#[test]
fn test_get_route_empty() {
let ctx = make_context();
assert_eq!(ctx.get_route(), "");
}
#[test]
fn test_reset_clears_request_response_state() {
let mut ctx = make_context();
ctx.keep_alive_backend = false;
ctx.keep_alive_frontend = false;
ctx.sticky_session_found = Some("abc123".to_owned());
ctx.method = Some(Method::Post);
ctx.authority = Some("example.com".to_owned());
ctx.path = Some("/upload".to_owned());
ctx.status = Some(200);
ctx.reason = Some(Cow::Owned("Fine".to_owned()));
ctx.user_agent = Some("curl/7.81".to_owned());
ctx.x_request_id = Some(Rc::from("client-xrid-123"));
ctx.xff_chain = Some("203.0.113.5, 198.51.100.10".to_owned());
ctx.redirect_location = Some("https://example.com/".to_owned());
ctx.www_authenticate = Some("Basic realm=\"sozu\"".to_owned());
ctx.original_authority = Some("old.example.com".to_owned());
ctx.headers_response.push(HeaderEditSnapshot {
key: b"X-Cache".to_vec(),
val: b"HIT".to_vec(),
mode: HeaderEditMode::Append,
});
ctx.reset(Ulid::generate());
assert!(ctx.keep_alive_backend);
assert!(ctx.keep_alive_frontend);
assert!(ctx.sticky_session_found.is_none());
assert!(ctx.method.is_none());
assert!(ctx.authority.is_none());
assert!(ctx.path.is_none());
assert!(ctx.status.is_none());
assert!(ctx.reason.is_none());
assert!(ctx.user_agent.is_none());
assert!(ctx.x_request_id.is_none());
assert!(ctx.xff_chain.is_none());
assert!(ctx.redirect_location.is_none());
assert!(ctx.www_authenticate.is_none());
assert!(ctx.original_authority.is_none());
assert!(ctx.headers_response.is_empty());
}
#[test]
fn test_reset_preserves_tls_metadata() {
let mut ctx = make_context();
ctx.tls_server_name = Some("example.com".to_owned());
ctx.tls_version = Some("TLSv1.3");
ctx.tls_cipher = Some("TLS_AES_128_GCM_SHA256");
ctx.tls_alpn = Some("h2");
ctx.strict_sni_binding = false;
ctx.reset(Ulid::generate());
assert_eq!(ctx.tls_server_name.as_deref(), Some("example.com"));
assert_eq!(ctx.tls_version, Some("TLSv1.3"));
assert_eq!(ctx.tls_cipher, Some("TLS_AES_128_GCM_SHA256"));
assert_eq!(ctx.tls_alpn, Some("h2"));
assert!(!ctx.strict_sni_binding);
}
#[test]
fn test_reset_preserves_connection_state() {
let mut ctx = make_context();
ctx.closing = true;
ctx.cluster_id = Some("cluster-1".into());
ctx.backend_id = Some("backend-1".into());
let original_id = ctx.id;
let original_protocol = ctx.protocol;
let original_public_address = ctx.public_address;
let next_id = Ulid::generate();
ctx.reset(next_id);
assert!(ctx.closing);
assert_eq!(ctx.cluster_id.as_deref(), Some("cluster-1"));
assert_eq!(ctx.backend_id.as_deref(), Some("backend-1"));
assert_ne!(ctx.id, original_id);
assert_eq!(ctx.id, next_id);
assert_eq!(ctx.protocol, original_protocol);
assert_eq!(ctx.public_address, original_public_address);
}
fn edited_response(ctx: &mut HttpContext, bytes: &[u8]) -> String {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa: GenericHttpStream = kawa::Kawa::new(
kawa::Kind::Response,
kawa::Buffer::new(
pool.checkout()
.expect("the test pool must hand out a buffer"),
),
);
kawa.storage.space()[..bytes.len()].copy_from_slice(bytes);
kawa.storage.fill(bytes.len());
kawa::h1::parse(&mut kawa, ctx);
assert!(!kawa.is_error(), "premise: the response must parse");
serialized_request(&mut kawa)
}
#[test]
fn reset_clears_the_sticky_session_answer_of_the_previous_request() {
const RESPONSE: &[u8] = b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\nb";
let mut ctx = make_context();
ctx.sticky_session = Some("sticky-a".to_owned());
let first = edited_response(&mut ctx, RESPONSE);
assert!(
first.contains("Set-Cookie: SERVERID=sticky-a; Path=/\r\n"),
"premise: the sticky request is answered with its cookie, got {first:?}"
);
ctx.reset(Ulid::generate());
assert_eq!(
ctx.sticky_session, None,
"reset must clear the previous request's sticky-session answer"
);
let second = edited_response(&mut ctx, RESPONSE);
assert!(
!second.to_ascii_lowercase().contains("set-cookie"),
"a request to a frontend that does not stick must not carry the \
previous request's cookie, got {second:?}"
);
}
use std::net::Ipv6Addr;
fn render_for_by(peer: SocketAddr, public_ip: IpAddr) -> String {
let mut buf = Vec::new();
write_forwarded_for_by(&mut buf, peer.ip(), peer.port(), public_ip);
String::from_utf8(buf).expect("forwarded fragment must be ASCII")
}
fn render_suffix(proto: &str, peer: SocketAddr, public_ip: IpAddr) -> String {
let mut buf = Vec::new();
write_forwarded_suffix(&mut buf, proto, peer.ip(), peer.port(), public_ip);
String::from_utf8(buf).expect("forwarded fragment must be ASCII")
}
#[test]
fn test_forwarded_ipv4_peer_ipv4_by_unchanged() {
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 54321);
let public_ip = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1));
assert_eq!(
render_for_by(peer, public_ip),
r#";for="203.0.113.7:54321";by=198.51.100.1"#,
);
}
#[test]
fn test_forwarded_ipv6_peer_brackets_disambiguate_port() {
let peer = SocketAddr::new(IpAddr::V6("2001:db8::1".parse::<Ipv6Addr>().unwrap()), 8080);
let public_ip = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1));
assert_eq!(
render_for_by(peer, public_ip),
r#";for="[2001:db8::1]:8080";by=198.51.100.1"#,
);
}
#[test]
fn test_forwarded_ipv6_public_address_bracketed_and_quoted() {
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 54321);
let public_ip = IpAddr::V6("2001:db8::2".parse::<Ipv6Addr>().unwrap());
assert_eq!(
render_for_by(peer, public_ip),
r#";for="203.0.113.7:54321";by="[2001:db8::2]""#,
);
}
#[test]
fn test_forwarded_ipv6_peer_and_public_both_bracketed() {
let peer = SocketAddr::new(IpAddr::V6("2001:db8::1".parse::<Ipv6Addr>().unwrap()), 8080);
let public_ip = IpAddr::V6("2001:db8::2".parse::<Ipv6Addr>().unwrap());
assert_eq!(
render_for_by(peer, public_ip),
r#";for="[2001:db8::1]:8080";by="[2001:db8::2]""#,
);
}
#[test]
fn test_forwarded_suffix_prepends_proto_and_brackets_ipv6() {
let peer = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 443);
let public_ip = IpAddr::V6("fe80::1".parse::<Ipv6Addr>().unwrap());
assert_eq!(
render_suffix("https", peer, public_ip),
r#", proto=https;for="[::1]:443";by="[fe80::1]""#,
);
}
#[cfg(feature = "opentelemetry")]
mod otel {
use super::super::*;
#[test]
fn test_parse_hex_valid() {
let (val, rest) = parse_hex::<4>(b"abcd1234").unwrap();
assert_eq!(&val, b"abcd");
assert_eq!(rest, b"1234");
}
#[test]
fn test_parse_hex_exact_length() {
let (val, rest) = parse_hex::<8>(b"01234567").unwrap();
assert_eq!(&val, b"01234567");
assert!(rest.is_empty());
}
#[test]
fn test_parse_hex_too_short() {
assert!(parse_hex::<4>(b"ab").is_none());
}
#[test]
fn test_parse_hex_rejects_non_hex() {
assert!(parse_hex::<4>(b"ghij").is_none());
}
#[test]
fn test_parse_hex_rejects_uppercase_is_ok() {
let (val, _) = parse_hex::<4>(b"ABCD").unwrap();
assert_eq!(&val, b"ABCD");
}
#[test]
fn test_skip_separator_valid() {
let rest = skip_separator(b"-hello").unwrap();
assert_eq!(rest, b"hello");
}
#[test]
fn test_skip_separator_wrong_char() {
assert!(skip_separator(b"+hello").is_none());
}
#[test]
fn test_skip_separator_empty() {
assert!(skip_separator(b"").is_none());
}
#[test]
fn test_build_traceparent_format() {
let trace_id: [u8; 32] = *b"4bf92f3577b34da6a3ce929d0e0e4736";
let parent_id: [u8; 16] = *b"00f067aa0ba902b7";
let result = build_traceparent(&trace_id, &parent_id);
assert_eq!(
&result,
b"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
);
}
#[test]
fn test_build_traceparent_length() {
let trace_id = [b'a'; 32];
let parent_id = [b'b'; 16];
let result = build_traceparent(&trace_id, &parent_id);
assert_eq!(result.len(), 55);
}
#[test]
fn test_parse_traceparent_valid() {
let input = b"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
let store = kawa::Store::Static(input);
let (trace_id, parent_id) = parse_traceparent(&store, input).unwrap();
assert_eq!(&trace_id, b"4bf92f3577b34da6a3ce929d0e0e4736");
assert_eq!(&parent_id, b"00f067aa0ba902b7");
}
#[test]
fn test_parse_traceparent_sampled_flag_zero() {
let input = b"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00";
let store = kawa::Store::Static(input);
let result = parse_traceparent(&store, input);
assert!(result.is_some());
}
#[test]
fn test_parse_traceparent_wrong_version() {
let input = b"01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
let store = kawa::Store::Static(input);
assert!(parse_traceparent(&store, input).is_none());
}
#[test]
fn test_parse_traceparent_too_short() {
let input = b"00-4bf9";
let store = kawa::Store::Static(input);
assert!(parse_traceparent(&store, input).is_none());
}
#[test]
fn test_parse_traceparent_trailing_data() {
let input = b"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra";
let store = kawa::Store::Static(input);
assert!(parse_traceparent(&store, input).is_none());
}
#[test]
fn test_parse_traceparent_missing_separator() {
let input = b"004bf92f3577b34da6a3ce929d0e0e473600f067aa0ba902b701";
let store = kawa::Store::Static(input);
assert!(parse_traceparent(&store, input).is_none());
}
#[test]
fn test_parse_build_roundtrip() {
let trace_id: [u8; 32] = *b"4bf92f3577b34da6a3ce929d0e0e4736";
let parent_id: [u8; 16] = *b"00f067aa0ba902b7";
let built = build_traceparent(&trace_id, &parent_id);
let expected: &[u8] = b"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
assert_eq!(&built[..], expected);
let store = kawa::Store::Static(expected);
let (parsed_trace_id, parsed_parent_id) = parse_traceparent(&store, expected).unwrap();
assert_eq!(parsed_trace_id, trace_id);
assert_eq!(parsed_parent_id, parent_id);
}
}
fn parse_framed(
pool: &mut crate::pool::Pool,
kind: kawa::Kind,
bytes: &[u8],
) -> GenericHttpStream {
let mut kawa: GenericHttpStream = kawa::Kawa::new(
kind,
kawa::Buffer::new(
pool.checkout()
.expect("the test pool must hand out a buffer"),
),
);
kawa.storage.space()[..bytes.len()].copy_from_slice(bytes);
kawa.storage.fill(bytes.len());
kawa::h1::parse(&mut kawa, &mut make_context());
kawa
}
#[test]
fn a_request_content_length_that_is_not_only_digits_is_rejected() {
let cases: [(&str, &[u8]); 13] = [
("plus", b"+5"),
("plus-zero", b"+0"),
("minus-zero", b"-0"),
("plus-alone", b"+"),
("list-of-equal-values", b"5, 5"),
("list-without-space", b"5,5"),
("inner-space", b"5 5"),
("hexadecimal", b"0x5"),
("decimal-point", b"5.0"),
("overflow", b"99999999999999999999999"),
("arabic-indic-digit", "\u{0665}".as_bytes()),
("fullwidth-digit", "\u{FF15}".as_bytes()),
("empty", b""),
];
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
for (label, value) in cases {
let request = [
&b"POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Length: "[..],
value,
b"\r\n\r\nHello",
]
.concat();
let kawa = parse_framed(&mut pool, kawa::Kind::Request, &request);
assert!(
kawa.is_error(),
"{label}: a Content-Length that is not 1*DIGIT must be refused, got {:?} with {:?}",
kawa.parsing_phase,
kawa.body_size
);
}
let request = b"POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Length: +5\r\nContent-Length: 5\r\n\r\nHello";
let kawa = parse_framed(&mut pool, kawa::Kind::Request, request);
assert!(
kawa.is_error(),
"plus-then-canonical: the surviving `+5` must be refused, got {:?}",
kawa.parsing_phase
);
drop(kawa);
let request = b"POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\nContent-Length: +5\r\n\r\nHello";
let kawa = parse_framed(&mut pool, kawa::Kind::Request, request);
assert!(
kawa.is_error(),
"canonical-then-plus: a `+5` field line must be refused, got {:?}",
kawa.parsing_phase
);
}
#[test]
fn the_content_length_helper_judges_every_non_digit_value() {
fn content_length(val: &'static [u8], elided: bool) -> GenericHttpStream {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa: GenericHttpStream = kawa::Kawa::new(
kawa::Kind::Request,
kawa::Buffer::new(
pool.checkout()
.expect("the test pool must hand out a buffer"),
),
);
let mut pair = kawa::Pair {
key: kawa::Store::Static(b"Content-Length"),
val: kawa::Store::Static(val),
};
if elided {
pair.elide();
}
kawa.push_block(kawa::Block::Header(pair));
kawa
}
let cases: [(&str, &'static [u8], bool, bool); 7] = [
("minus-zero", b"-0", false, true),
("hexadecimal", b"0x5", false, true),
("inner-space", b"5 5", false, true),
("empty", b"", false, true),
("leading-zeros", b"005", false, false),
("zero", b"0", false, false),
("elided-plus", b"+5", true, false),
];
for (label, val, elided, expected) in cases {
assert_eq!(
has_non_digit_content_length(&content_length(val, elided)),
expected,
"{label}"
);
}
}
#[test]
fn a_forwarded_content_length_is_only_digits() {
let cases: [(&str, &[u8], &str); 3] = [
(
"canonical",
b"POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\n\r\nHello",
"Content-Length: 5\r\n",
),
(
"leading-zeros",
b"POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Length: 005\r\n\r\nHello",
"Content-Length: 005\r\n",
),
(
"plus-beside-chunked",
b"POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Length: +5\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nHello\r\n0\r\n\r\n",
"Transfer-Encoding: chunked\r\n",
),
];
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
for (label, request, expected) in cases {
let mut kawa = parse_framed(&mut pool, kawa::Kind::Request, request);
assert!(
!kawa.is_error(),
"{label}: must be accepted, got {:?}",
kawa.parsing_phase
);
let forwarded = serialized_request(&mut kawa);
assert!(
forwarded.contains(expected),
"{label}: expected {expected:?} in {forwarded:?}"
);
assert!(
!forwarded.contains('+'),
"{label}: a signed Content-Length must never be forwarded: {forwarded:?}"
);
assert_eq!(
forwarded.matches("Content-Length").count(),
usize::from(label != "plus-beside-chunked"),
"{label}: exactly the framing Content-Length is forwarded: {forwarded:?}"
);
}
}
#[test]
fn a_response_content_length_that_is_not_only_digits_is_rejected() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
for status in ["200 OK", "204 No Content", "304 Not Modified"] {
let response = format!("HTTP/1.1 {status}\r\nContent-Length: +5\r\n\r\nHello");
let kawa = parse_framed(&mut pool, kawa::Kind::Response, response.as_bytes());
assert!(
kawa.is_error(),
"{status}: a signed Content-Length must fail the response, got {:?}",
kawa.parsing_phase
);
}
let kawa = parse_framed(
&mut pool,
kawa::Kind::Response,
b"HTTP/1.1 200 OK\r\nContent-Length: 005\r\n\r\nHello",
);
assert!(
!kawa.is_error(),
"a 1*DIGIT Content-Length must still be accepted, got {:?}",
kawa.parsing_phase
);
}
const BARE_REQUEST: &[u8] = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
fn allocations_of_request_parse(
ctx: &mut HttpContext,
kawa: &mut GenericHttpStream,
request: &[u8],
) -> usize {
use crate::test_allocations::allocations;
kawa.clear();
kawa.storage.clear();
kawa.storage.space()[..request.len()].copy_from_slice(request);
kawa.storage.fill(request.len());
let before = allocations();
kawa::h1::parse(kawa, ctx);
let parsed = allocations() - before;
assert!(kawa.is_main_phase(), "premise: the request must parse");
parsed
}
fn warm_request_kawa(pool: &mut crate::pool::Pool) -> GenericHttpStream {
let mut kawa: GenericHttpStream = kawa::Kawa::new(
kawa::Kind::Request,
kawa::Buffer::new(
pool.checkout()
.expect("the test pool must hand out a buffer"),
),
);
allocations_of_request_parse(&mut make_context(), &mut kawa, BARE_REQUEST);
kawa
}
fn serialized_request(kawa: &mut GenericHttpStream) -> String {
kawa.prepare(&mut kawa::h1::BlockConverter);
let buffer = kawa.storage.buffer();
let bytes = kawa
.out
.iter()
.flat_map(|block| match block {
kawa::OutBlock::Store(store) => store.data(buffer).to_vec(),
kawa::OutBlock::Delimiter => Vec::new(),
})
.collect::<Vec<u8>>();
String::from_utf8(bytes).expect("the serialised request must be UTF-8")
}
fn synthesised_traceparent_line(ctx: &HttpContext) -> String {
#[cfg(feature = "opentelemetry")]
{
let otel = ctx
.otel
.as_ref()
.expect("on_request_headers records the trace context it forwards");
let value = build_traceparent(&otel.trace_id, &otel.span_id);
format!(
"traceparent: {}\r\n",
from_utf8(&value).expect("a traceparent is ASCII hex")
)
}
#[cfg(not(feature = "opentelemetry"))]
{
let _ = ctx;
String::new()
}
}
const SYNTHESISED_TRACEPARENT: usize = if cfg!(feature = "opentelemetry") {
1
} else {
0
};
#[test]
fn a_bare_request_costs_one_scratch_and_one_copy_per_forwarding_header() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = warm_request_kawa(&mut pool);
let mut ctx = make_context();
let first = allocations_of_request_parse(&mut ctx, &mut kawa, BARE_REQUEST);
assert_eq!(
first,
7 + SYNTHESISED_TRACEPARENT,
"a bare request's header editing must cost one scratch, one exact \
copy per synthesised forwarding header and one rendering of the \
request id"
);
}
fn response_parse(ctx: &mut HttpContext, response: &[u8]) -> (usize, String) {
use crate::test_allocations::allocations;
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa: GenericHttpStream = kawa::Kawa::new(
kawa::Kind::Response,
kawa::Buffer::new(
pool.checkout()
.expect("the test pool must hand out a buffer"),
),
);
let mut measured = 0;
for context in [&mut make_context(), ctx] {
kawa.clear();
kawa.storage.clear();
kawa.storage.space()[..response.len()].copy_from_slice(response);
kawa.storage.fill(response.len());
let before = allocations();
kawa::h1::parse(&mut kawa, context);
measured = allocations() - before;
assert!(kawa.is_main_phase(), "premise: the response must parse");
}
kawa.prepare(&mut kawa::h1::BlockConverter);
let buffer = kawa.storage.buffer();
let bytes = kawa
.out
.iter()
.flat_map(|block| match block {
kawa::OutBlock::Store(store) => store.data(buffer).to_vec(),
kawa::OutBlock::Delimiter => Vec::new(),
})
.collect::<Vec<u8>>();
(
measured,
String::from_utf8(bytes).expect("the serialised response must be UTF-8"),
)
}
#[test]
fn a_response_shares_the_request_id_rendering() {
const RESPONSE: &[u8] = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = warm_request_kawa(&mut pool);
let mut generated = make_context();
allocations_of_request_parse(&mut generated, &mut kawa, BARE_REQUEST);
let (allocations, response) = response_parse(&mut generated, RESPONSE);
let id = generated.id.to_string();
assert_eq!(
response,
format!("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nSozu-Id: {id}\r\n\r\n")
);
assert_eq!(
allocations, 0,
"the response must share the id rendering the request generated \
and borrow its standard reason"
);
let mut client_chosen = make_context();
allocations_of_request_parse(
&mut client_chosen,
&mut kawa,
b"GET / HTTP/1.1\r\nHost: example.com\r\nX-Request-Id: client-chosen\r\n\r\n",
);
let (_, response) = response_parse(&mut client_chosen, RESPONSE);
assert_eq!(
response,
format!(
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nSozu-Id: {}\r\n\r\n",
client_chosen.id
),
"the correlation header carries Sōzu's id, not the client's"
);
}
#[test]
fn a_response_reason_is_borrowed_when_standard_and_copied_otherwise() {
let cases: [(&[u8], &str, usize); 8] = [
(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", "OK", 0),
(b"HTTP/1.1 200 Fine\r\nContent-Length: 0\r\n\r\n", "Fine", 1),
(b"HTTP/1.1 200 ok\r\nContent-Length: 0\r\n\r\n", "ok", 1),
(
b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n",
"Not Found",
0,
),
(
b"HTTP/1.1 413 Payload Too Large\r\nContent-Length: 0\r\n\r\n",
"Payload Too Large",
1,
),
(
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
"Service Unavailable",
0,
),
(
b"HTTP/1.1 599 Network Connect Timeout Error\r\nContent-Length: 0\r\n\r\n",
"Network Connect Timeout Error",
1,
),
(
b"HTTP/1.1 302 Found\r\nContent-Length: 0\r\n\r\n",
"Found",
0,
),
];
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = warm_request_kawa(&mut pool);
let mut ctx = make_context();
for (response, reason, cost) in cases {
allocations_of_request_parse(&mut ctx, &mut kawa, BARE_REQUEST);
let (allocations, serialized) = response_parse(&mut ctx, response);
let raw = from_utf8(response).expect("the response literal is ASCII");
let headers_end = raw.len() - "\r\n".len();
assert_eq!(
serialized,
format!("{}Sozu-Id: {}\r\n\r\n", &raw[..headers_end], ctx.id),
"the forwarded response of {raw:?} is byte-exact"
);
assert_eq!(
ctx.reason.as_deref(),
Some(reason),
"the access log records the reason of {raw:?} verbatim"
);
assert_eq!(
allocations, cost,
"capturing the reason of {raw:?} costs {cost} allocation(s)"
);
ctx.reset(Ulid::generate());
}
}
#[test]
fn render_ulid_matches_the_display_rendering() {
let ids = [
Ulid::from(0u128),
Ulid::from(u128::MAX),
Ulid::from(1u128 << 127),
Ulid::generate(),
Ulid::generate(),
Ulid::generate(),
];
for id in ids {
assert_eq!(
from_utf8(&render_ulid(id)).expect("a rendered ULID is ASCII"),
id.to_string()
);
}
assert_eq!(render_ulid(Ulid::from(u128::MAX))[0], b'7');
}
#[test]
fn header_editing_output_is_byte_exact_across_keep_alive_requests() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = warm_request_kawa(&mut pool);
let mut ctx = HttpContext::new(
Ulid::generate(),
Ulid::generate(),
Protocol::HTTP,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080),
Some(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
54321,
)),
"SERVERID".to_owned(),
"X-Edge-Id".to_owned(),
false,
true,
);
let mut seen = Vec::new();
for request in 0..3 {
let id = ctx.id.to_string();
assert!(
!seen.contains(&id),
"request {request} of the connection reuses the id of an earlier one: {id}"
);
seen.push(id.clone());
allocations_of_request_parse(&mut ctx, &mut kawa, BARE_REQUEST);
let traceparent = synthesised_traceparent_line(&ctx);
assert_eq!(
serialized_request(&mut kawa),
format!(
"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Forwarded-For: 10.0.0.1\r\n\
Forwarded: proto=http;for=\"10.0.0.1:54321\";by=127.0.0.1\r\n\
X-Real-IP: 10.0.0.1\r\n\
{traceparent}\
X-Forwarded-Port: 8080\r\n\
X-Forwarded-Proto: http\r\n\
X-Request-Id: {id}\r\n\
X-Edge-Id: {id}\r\n\r\n"
),
"request {request} of the connection"
);
assert_eq!(ctx.x_request_id.as_deref(), Some(id.as_str()));
ctx.reset(Ulid::generate());
}
let id = ctx.id.to_string();
assert!(
!seen.contains(&id),
"the request with a client X-Request-Id reuses an earlier id: {id}"
);
allocations_of_request_parse(
&mut ctx,
&mut kawa,
b"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Forwarded-For: 192.0.2.7\r\n\
Forwarded: for=192.0.2.7\r\n\
X-Request-Id: client-chosen\r\n\r\n",
);
let traceparent = synthesised_traceparent_line(&ctx);
assert_eq!(
serialized_request(&mut kawa),
format!(
"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Forwarded-For: 192.0.2.7, 10.0.0.1\r\n\
Forwarded: for=192.0.2.7, proto=http;for=\"10.0.0.1:54321\";by=127.0.0.1\r\n\
X-Request-Id: client-chosen\r\n\
X-Real-IP: 10.0.0.1\r\n\
{traceparent}\
X-Forwarded-Port: 8080\r\n\
X-Forwarded-Proto: http\r\n\
X-Edge-Id: {id}\r\n\r\n"
),
"client chains are extended, a client X-Request-Id is kept verbatim"
);
assert_eq!(ctx.x_request_id.as_deref(), Some("client-chosen"));
}
fn forwarding_context(
protocol: Protocol,
public_address: SocketAddr,
session_address: SocketAddr,
send_x_real_ip: bool,
) -> HttpContext {
HttpContext::new(
Ulid::generate(),
Ulid::generate(),
protocol,
public_address,
Some(session_address),
"SERVERID".to_owned(),
"Sozu-Id".to_owned(),
false,
send_x_real_ip,
)
}
#[test]
fn keep_alive_requests_reuse_the_connection_forwarding_hop() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = warm_request_kawa(&mut pool);
let mut ctx = forwarding_context(
Protocol::HTTP,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 54321),
true,
);
let first = allocations_of_request_parse(&mut ctx, &mut kawa, BARE_REQUEST);
assert_eq!(
first,
7 + SYNTHESISED_TRACEPARENT,
"the first request renders the connection's forwarding values once, \
X-Real-IP sharing the X-Forwarded-For rendering"
);
for request in 1..4 {
ctx.reset(Ulid::generate());
let later = allocations_of_request_parse(&mut ctx, &mut kawa, BARE_REQUEST);
assert_eq!(
later,
3 + SYNTHESISED_TRACEPARENT,
"request {request} of the connection must reuse the forwarding values"
);
}
}
#[test]
fn a_client_chain_costs_one_exact_copy_per_extended_header() {
const CLIENT_CHAINS: &[u8] = b"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Forwarded-For: 192.0.2.7\r\n\
Forwarded: for=192.0.2.7\r\n\r\n";
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = warm_request_kawa(&mut pool);
let mut ctx = make_context();
allocations_of_request_parse(&mut ctx, &mut kawa, BARE_REQUEST);
ctx.reset(Ulid::generate());
let later = allocations_of_request_parse(&mut ctx, &mut kawa, CLIENT_CHAINS);
assert_eq!(
later,
6 + SYNTHESISED_TRACEPARENT,
"a client chain costs one exact copy per extended header"
);
}
#[test]
fn a_client_chain_never_leaks_into_the_next_request() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = warm_request_kawa(&mut pool);
let mut ctx = forwarding_context(
Protocol::HTTPS,
SocketAddr::new(
IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)),
443,
),
SocketAddr::new(
IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 1, 2)),
50000,
),
true,
);
let bare = |id: &str, traceparent: &str| {
format!(
"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Forwarded-For: 2001:db8::1:2\r\n\
Forwarded: proto=https;for=\"[2001:db8::1:2]:50000\";by=\"[2001:db8::1]\"\r\n\
X-Real-IP: 2001:db8::1:2\r\n\
{traceparent}\
X-Forwarded-Port: 443\r\n\
X-Forwarded-Proto: https\r\n\
X-Request-Id: {id}\r\n\
Sozu-Id: {id}\r\n\r\n"
)
};
for round in 0..2 {
let id = ctx.id.to_string();
allocations_of_request_parse(&mut ctx, &mut kawa, BARE_REQUEST);
let traceparent = synthesised_traceparent_line(&ctx);
assert_eq!(
serialized_request(&mut kawa),
bare(&id, &traceparent),
"bare request of round {round}"
);
assert_eq!(ctx.xff_chain, None);
ctx.reset(Ulid::generate());
let id = ctx.id.to_string();
allocations_of_request_parse(
&mut ctx,
&mut kawa,
b"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Forwarded-For: 192.0.2.7, 198.51.100.3\r\n\
Forwarded: for=192.0.2.7;proto=http\r\n\r\n",
);
let traceparent = synthesised_traceparent_line(&ctx);
assert_eq!(
serialized_request(&mut kawa),
format!(
"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Forwarded-For: 192.0.2.7, 198.51.100.3, 2001:db8::1:2\r\n\
Forwarded: for=192.0.2.7;proto=http, \
proto=https;for=\"[2001:db8::1:2]:50000\";by=\"[2001:db8::1]\"\r\n\
X-Real-IP: 2001:db8::1:2\r\n\
{traceparent}\
X-Forwarded-Port: 443\r\n\
X-Forwarded-Proto: https\r\n\
X-Request-Id: {id}\r\n\
Sozu-Id: {id}\r\n\r\n"
),
"request with client chains of round {round}"
);
assert_eq!(ctx.xff_chain.as_deref(), Some("192.0.2.7, 198.51.100.3"));
ctx.reset(Ulid::generate());
}
}
#[test]
fn a_changed_forwarding_input_renders_the_hop_again() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = warm_request_kawa(&mut pool);
let mut ctx = forwarding_context(
Protocol::HTTP,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 54321),
true,
);
let expected = |ctx: &HttpContext, peer: &str, forwarded: &str, port: u16, proto: &str| {
let id = ctx.id.to_string();
let traceparent = synthesised_traceparent_line(ctx);
format!(
"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Forwarded-For: {peer}\r\n\
Forwarded: {forwarded}\r\n\
X-Real-IP: {peer}\r\n\
{traceparent}\
X-Forwarded-Port: {port}\r\n\
X-Forwarded-Proto: {proto}\r\n\
X-Request-Id: {id}\r\n\
Sozu-Id: {id}\r\n\r\n"
)
};
allocations_of_request_parse(&mut ctx, &mut kawa, BARE_REQUEST);
assert_eq!(
serialized_request(&mut kawa),
expected(
&ctx,
"10.0.0.1",
"proto=http;for=\"10.0.0.1:54321\";by=127.0.0.1",
8080,
"http"
)
);
ctx.reset(Ulid::generate());
ctx.session_address = Some(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)),
40000,
));
allocations_of_request_parse(&mut ctx, &mut kawa, BARE_REQUEST);
assert_eq!(
serialized_request(&mut kawa),
expected(
&ctx,
"10.0.0.2",
"proto=http;for=\"10.0.0.2:40000\";by=127.0.0.1",
8080,
"http"
),
"a new peer is rendered"
);
ctx.reset(Ulid::generate());
ctx.public_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), 8443);
allocations_of_request_parse(&mut ctx, &mut kawa, BARE_REQUEST);
assert_eq!(
serialized_request(&mut kawa),
expected(
&ctx,
"10.0.0.2",
"proto=http;for=\"10.0.0.2:40000\";by=192.0.2.1",
8443,
"http"
),
"a new public address is rendered"
);
ctx.reset(Ulid::generate());
ctx.protocol = Protocol::HTTPS;
allocations_of_request_parse(&mut ctx, &mut kawa, BARE_REQUEST);
assert_eq!(
serialized_request(&mut kawa),
expected(
&ctx,
"10.0.0.2",
"proto=https;for=\"10.0.0.2:40000\";by=192.0.2.1",
8443,
"https"
),
"a new protocol is rendered"
);
}
const CLIENT_CHAIN_REQUEST: &[u8] = b"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Forwarded-For: 198.51.100.1\r\n\
X-Forwarded-For: 192.0.2.7\r\n\
Forwarded: for=192.0.2.7\r\n\
X-Forwarded-Proto: https\r\n\
X-Forwarded-Port: 443\r\n\
X-Forwarded-Host: public.example\r\n\
X-Real-IP: 203.0.113.9\r\n\
X-Request-Id: client-chosen\r\n\r\n";
fn forwarded_in_mode(mode: ForwardedHeaders, request: &[u8]) -> (String, HttpContext) {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = warm_request_kawa(&mut pool);
let mut ctx = forwarding_context(
Protocol::HTTP,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 54321),
true,
);
ctx.forwarded_headers = mode;
allocations_of_request_parse(&mut ctx, &mut kawa, request);
(serialized_request(&mut kawa), ctx)
}
#[test]
fn forwarded_headers_both_adds_both_families_and_extends_client_chains() {
let (bare, ctx) = forwarded_in_mode(ForwardedHeaders::Both, BARE_REQUEST);
let id = ctx.id.to_string();
let traceparent = synthesised_traceparent_line(&ctx);
assert_eq!(
bare,
format!(
"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Forwarded-For: 10.0.0.1\r\n\
Forwarded: proto=http;for=\"10.0.0.1:54321\";by=127.0.0.1\r\n\
X-Real-IP: 10.0.0.1\r\n\
{traceparent}\
X-Forwarded-Port: 8080\r\n\
X-Forwarded-Proto: http\r\n\
X-Request-Id: {id}\r\n\
Sozu-Id: {id}\r\n\r\n"
)
);
let (chained, ctx) = forwarded_in_mode(ForwardedHeaders::Both, CLIENT_CHAIN_REQUEST);
let id = ctx.id.to_string();
let traceparent = synthesised_traceparent_line(&ctx);
assert_eq!(
chained,
format!(
"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Forwarded-For: 198.51.100.1\r\n\
X-Forwarded-For: 192.0.2.7, 10.0.0.1\r\n\
Forwarded: for=192.0.2.7, proto=http;for=\"10.0.0.1:54321\";by=127.0.0.1\r\n\
X-Forwarded-Proto: https\r\n\
X-Forwarded-Port: 443\r\n\
X-Forwarded-Host: public.example\r\n\
X-Real-IP: 203.0.113.9\r\n\
X-Request-Id: client-chosen\r\n\
X-Real-IP: 10.0.0.1\r\n\
{traceparent}\
Sozu-Id: {id}\r\n\r\n"
)
);
assert_eq!(ctx.xff_chain.as_deref(), Some("192.0.2.7"));
}
#[test]
fn forwarded_headers_x_forwarded_adds_only_the_x_forwarded_family() {
let (bare, ctx) = forwarded_in_mode(ForwardedHeaders::XForwarded, BARE_REQUEST);
let id = ctx.id.to_string();
let traceparent = synthesised_traceparent_line(&ctx);
assert_eq!(
bare,
format!(
"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Forwarded-For: 10.0.0.1\r\n\
X-Real-IP: 10.0.0.1\r\n\
{traceparent}\
X-Forwarded-Port: 8080\r\n\
X-Forwarded-Proto: http\r\n\
X-Request-Id: {id}\r\n\
Sozu-Id: {id}\r\n\r\n"
)
);
let (chained, ctx) = forwarded_in_mode(ForwardedHeaders::XForwarded, CLIENT_CHAIN_REQUEST);
let id = ctx.id.to_string();
let traceparent = synthesised_traceparent_line(&ctx);
assert_eq!(
chained,
format!(
"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Forwarded-For: 198.51.100.1\r\n\
X-Forwarded-For: 192.0.2.7, 10.0.0.1\r\n\
Forwarded: for=192.0.2.7\r\n\
X-Forwarded-Proto: https\r\n\
X-Forwarded-Port: 443\r\n\
X-Forwarded-Host: public.example\r\n\
X-Real-IP: 203.0.113.9\r\n\
X-Request-Id: client-chosen\r\n\
X-Real-IP: 10.0.0.1\r\n\
{traceparent}\
Sozu-Id: {id}\r\n\r\n"
)
);
assert_eq!(ctx.xff_chain.as_deref(), Some("192.0.2.7"));
}
#[test]
fn forwarded_headers_rfc7239_adds_only_forwarded_and_strips_client_x_forwarded() {
let (bare, ctx) = forwarded_in_mode(ForwardedHeaders::Rfc7239, BARE_REQUEST);
let id = ctx.id.to_string();
let traceparent = synthesised_traceparent_line(&ctx);
assert_eq!(
bare,
format!(
"GET / HTTP/1.1\r\nHost: example.com\r\n\
Forwarded: proto=http;for=\"10.0.0.1:54321\";by=127.0.0.1\r\n\
X-Real-IP: 10.0.0.1\r\n\
{traceparent}\
X-Request-Id: {id}\r\n\
Sozu-Id: {id}\r\n\r\n"
)
);
let (chained, ctx) = forwarded_in_mode(ForwardedHeaders::Rfc7239, CLIENT_CHAIN_REQUEST);
let id = ctx.id.to_string();
let traceparent = synthesised_traceparent_line(&ctx);
assert_eq!(
chained,
format!(
"GET / HTTP/1.1\r\nHost: example.com\r\n\
Forwarded: for=192.0.2.7, proto=http;for=\"10.0.0.1:54321\";by=127.0.0.1\r\n\
X-Real-IP: 203.0.113.9\r\n\
X-Request-Id: client-chosen\r\n\
X-Real-IP: 10.0.0.1\r\n\
{traceparent}\
Sozu-Id: {id}\r\n\r\n"
)
);
assert_eq!(ctx.xff_chain.as_deref(), Some("192.0.2.7"));
}
#[test]
fn forwarded_headers_none_adds_nothing_and_passes_client_headers_through() {
let (bare, ctx) = forwarded_in_mode(ForwardedHeaders::None, BARE_REQUEST);
let id = ctx.id.to_string();
let traceparent = synthesised_traceparent_line(&ctx);
assert_eq!(
bare,
format!(
"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Real-IP: 10.0.0.1\r\n\
{traceparent}\
X-Request-Id: {id}\r\n\
Sozu-Id: {id}\r\n\r\n"
)
);
let (chained, ctx) = forwarded_in_mode(ForwardedHeaders::None, CLIENT_CHAIN_REQUEST);
let id = ctx.id.to_string();
let traceparent = synthesised_traceparent_line(&ctx);
assert_eq!(
chained,
format!(
"GET / HTTP/1.1\r\nHost: example.com\r\n\
X-Forwarded-For: 198.51.100.1\r\n\
X-Forwarded-For: 192.0.2.7\r\n\
Forwarded: for=192.0.2.7\r\n\
X-Forwarded-Proto: https\r\n\
X-Forwarded-Port: 443\r\n\
X-Forwarded-Host: public.example\r\n\
X-Real-IP: 203.0.113.9\r\n\
X-Request-Id: client-chosen\r\n\
X-Real-IP: 10.0.0.1\r\n\
{traceparent}\
Sozu-Id: {id}\r\n\r\n"
)
);
assert_eq!(ctx.xff_chain.as_deref(), Some("192.0.2.7"));
}
#[test]
fn a_malformed_client_forwarded_is_elided_where_the_chain_is_extended() {
const MALFORMED: &[u8] = b"GET / HTTP/1.1\r\nHost: example.com\r\n\
Forwarded: for=\"6.6.6.6\r\n\r\n";
const VALID_THEN_MALFORMED: &[u8] = b"GET / HTTP/1.1\r\nHost: example.com\r\n\
Forwarded: for=192.0.2.7\r\n\
Forwarded: for=\"6.6.6.6\r\n\r\n";
let forwarded_lines = |out: &str| -> Vec<String> {
out.split("\r\n")
.filter_map(|line| line.strip_prefix("Forwarded: "))
.map(ToOwned::to_owned)
.collect()
};
let hop = "proto=http;for=\"10.0.0.1:54321\";by=127.0.0.1";
for mode in [ForwardedHeaders::Both, ForwardedHeaders::Rfc7239] {
let (out, _) = forwarded_in_mode(mode, MALFORMED);
assert_eq!(
forwarded_lines(&out),
vec![hop.to_owned()],
"{mode:?}: {out}"
);
let (out, _) = forwarded_in_mode(mode, VALID_THEN_MALFORMED);
let lines = forwarded_lines(&out);
assert_eq!(
lines,
vec![format!("for=192.0.2.7, {hop}")],
"{mode:?}: {out}"
);
assert!(
lines.iter().all(|line| is_valid_forwarded(line.as_bytes())),
"{mode:?}: the forwarded chain must be well-formed: {lines:?}"
);
}
for mode in [ForwardedHeaders::XForwarded, ForwardedHeaders::None] {
let (out, _) = forwarded_in_mode(mode, MALFORMED);
assert_eq!(
forwarded_lines(&out),
vec!["for=\"6.6.6.6".to_owned()],
"{mode:?} passes a client Forwarded through as sent: {out}"
);
}
}
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_malformed_client_forwarded_elision_is_counted_once_per_line() {
const MALFORMED: &[u8] = b"GET / HTTP/1.1\r\nHost: example.com\r\n\
Forwarded: for=\"6.6.6.6\r\n\r\n";
const TWO_MALFORMED_AROUND_VALID: &[u8] = b"GET / HTTP/1.1\r\nHost: example.com\r\n\
Forwarded: for=1.2.3.4:80\r\n\
Forwarded: for=192.0.2.7\r\n\
Forwarded: for=2001:db8::1\r\n\r\n";
let elided = || proxy_counter(names::http::FORWARDED_MALFORMED_ELIDED);
for mode in [ForwardedHeaders::Both, ForwardedHeaders::Rfc7239] {
let before = elided();
forwarded_in_mode(mode, CLIENT_CHAIN_REQUEST);
assert_eq!(elided(), before, "{mode:?}: a valid line is not counted");
let before = elided();
forwarded_in_mode(mode, MALFORMED);
assert_eq!(elided(), before + 1, "{mode:?}: one malformed line");
let before = elided();
forwarded_in_mode(mode, TWO_MALFORMED_AROUND_VALID);
assert_eq!(
elided(),
before + 2,
"{mode:?}: each removed line counts once, the valid one not"
);
}
for mode in [ForwardedHeaders::XForwarded, ForwardedHeaders::None] {
let before = elided();
forwarded_in_mode(mode, MALFORMED);
forwarded_in_mode(mode, TWO_MALFORMED_AROUND_VALID);
assert_eq!(
elided(),
before,
"{mode:?} passes a client Forwarded through and counts nothing"
);
}
}
#[test]
fn is_valid_forwarded_follows_the_rfc7239_grammar() {
for valid in [
&b""[..],
b"for=192.0.2.7",
b"For=\"[2001:db8:cafe::17]:4711\"",
b"for=192.0.2.60;proto=http;by=203.0.113.43",
b"for=192.0.2.43, for=198.51.100.17",
b"for=192.0.2.43,for=\"[2001:db8:cafe::17]\",for=unknown",
b"for=_gazonk",
b"for=\"\\\"quoted\\\"\"",
b"for=a;",
b"for=a ; by=b",
b"for=a, , for=b",
b"proto=http;for=\"10.0.0.1:54321\";by=127.0.0.1",
] {
assert!(
is_valid_forwarded(valid),
"{:?} is valid",
String::from_utf8_lossy(valid)
);
}
for invalid in [
&b"for=\"6.6.6.6"[..],
b"for=\"6.6.6.6\\\"",
b"for=",
b"=192.0.2.7",
b"for",
b"for=a b",
b"for=a\"b\"",
b"for=\"a\"b",
b"for = a",
b"for=a\x01",
b"for=\"a\x7f\"",
] {
assert!(
!is_valid_forwarded(invalid),
"{:?} is invalid",
String::from_utf8_lossy(invalid)
);
}
}
#[test]
fn forwarded_headers_survives_reset() {
let mut ctx = make_context();
ctx.forwarded_headers = ForwardedHeaders::Rfc7239;
ctx.reset(Ulid::generate());
assert_eq!(ctx.forwarded_headers, ForwardedHeaders::Rfc7239);
}
fn parse_message(
pool: &mut crate::pool::Pool,
kind: kawa::Kind,
bytes: &[u8],
) -> GenericHttpStream {
let mut kawa: GenericHttpStream = kawa::Kawa::new(
kind,
kawa::Buffer::new(
pool.checkout()
.expect("the test pool must hand out a buffer"),
),
);
kawa.storage.space()[..bytes.len()].copy_from_slice(bytes);
kawa.storage.fill(bytes.len());
kawa::h1::parse(&mut kawa, &mut make_context());
kawa
}
fn header_end_flags(kawa: &GenericHttpStream) -> kawa::Flags {
kawa.blocks
.iter()
.find_map(|block| match block {
kawa::Block::Flags(flags) if flags.end_header => Some(flags.clone()),
_ => None,
})
.expect("a parsed message carries its end-of-headers flags")
}
#[test]
fn a_request_without_length_ends_after_its_headers() {
const NEXT: &[u8] = b"GET /next HTTP/1.1\r\nHost: example.com\r\n\r\n";
let heads: [&[u8]; 7] = [
b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
b"HEAD / HTTP/1.1\r\nHost: example.com\r\n\r\n",
b"DELETE /item HTTP/1.1\r\nHost: example.com\r\n\r\n",
b"POST /form HTTP/1.1\r\nHost: example.com\r\n\r\n",
b"POST /form HTTP/1.0\r\nHost: example.com\r\n\r\n",
b"PUT /item HTTP/1.1\r\nHost: example.com\r\nExpect: 100-continue\r\n\r\n",
b"GET /chat HTTP/1.1\r\nHost: example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n",
];
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
for head in heads {
let label = String::from_utf8_lossy(head.split(|b| *b == b'\r').next().unwrap());
let bytes = [head, NEXT].concat();
let kawa = parse_message(&mut pool, kawa::Kind::Request, &bytes);
assert!(
kawa.is_terminated(),
"{label}: must end after its headers, got {:?}",
kawa.parsing_phase
);
let flags = header_end_flags(&kawa);
assert!(
flags.end_stream,
"{label}: the end-of-headers flags must end the stream"
);
assert!(
!kawa
.blocks
.iter()
.any(|block| matches!(block, kawa::Block::Chunk(_))),
"{label}: no byte may be read as its body"
);
assert_eq!(
kawa.storage.unparsed_data(),
NEXT,
"{label}: the pipelined request must stay unparsed for its own turn"
);
}
}
#[test]
fn a_framed_request_and_an_unframed_response_keep_their_bodies() {
let mut pool = crate::pool::Pool::with_capacity(1, 3, 4096);
let length = parse_message(
&mut pool,
kawa::Kind::Request,
b"POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 10\r\n\r\nHello",
);
assert_eq!(length.parsing_phase, kawa::ParsingPhase::Body);
assert_eq!(length.expects, 5, "half of the declared body is still due");
let chunked = parse_message(
&mut pool,
kawa::Kind::Request,
b"POST / HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nHello\r\n",
);
assert!(
matches!(chunked.parsing_phase, kawa::ParsingPhase::Chunks { .. }),
"a chunked request waits for its last chunk, got {:?}",
chunked.parsing_phase
);
let response = parse_message(
&mut pool,
kawa::Kind::Response,
b"HTTP/1.1 200 OK\r\n\r\nclose-delimited",
);
assert_eq!(
response.parsing_phase,
kawa::ParsingPhase::Body,
"a response without length is delimited by the close"
);
assert!(!header_end_flags(&response).end_stream);
assert!(
response
.blocks
.iter()
.any(|block| matches!(block, kawa::Block::Chunk(_))),
"the close-delimited response body is still read"
);
}
#[test]
fn an_h2_request_is_left_for_pkawa_to_frame() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa: GenericHttpStream = kawa::Kawa::new(
kawa::Kind::Request,
kawa::Buffer::new(
pool.checkout()
.expect("the test pool must hand out a buffer"),
),
);
kawa.detached.status_line = kawa::StatusLine::Request {
version: kawa::Version::V20,
method: kawa::Store::Static(b"POST"),
uri: kawa::Store::Static(b"/upload"),
authority: kawa::Store::Static(b"example.com"),
path: kawa::Store::Static(b"/upload"),
};
let phase_before = kawa.parsing_phase;
assert_eq!(kawa.body_size, kawa::BodySize::Empty, "premise");
kawa::h1::ParserCallbacks::on_headers(&mut make_context(), &mut kawa);
assert!(!kawa.is_terminated(), "an H2 request body may still follow");
assert_eq!(kawa.parsing_phase, phase_before);
}
fn parse_response(
pool: &mut crate::pool::Pool,
bytes: &[u8],
) -> (GenericHttpStream, HttpContext) {
let mut ctx = make_context();
let mut kawa: GenericHttpStream = kawa::Kawa::new(
kawa::Kind::Response,
kawa::Buffer::new(
pool.checkout()
.expect("the test pool must hand out a buffer"),
),
);
kawa.storage.space()[..bytes.len()].copy_from_slice(bytes);
kawa.storage.fill(bytes.len());
kawa::h1::parse(&mut kawa, &mut ctx);
assert!(!kawa.is_error(), "premise: the response must parse");
(kawa, ctx)
}
#[test]
fn an_http10_keep_alive_response_is_forwarded_as_http11() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let (mut kawa, ctx) = parse_response(
&mut pool,
b"HTTP/1.0 200 OK\r\nContent-Length: 5\r\nConnection: keep-alive\r\n\r\nhello",
);
assert!(kawa.is_terminated(), "premise: the whole body was parsed");
assert!(
ctx.keep_alive_backend,
"a keep-alive HTTP/1.0 response keeps the backend connection"
);
let wire = serialized_request(&mut kawa);
assert!(
wire.starts_with("HTTP/1.1 200 OK\r\n"),
"the status line carries Sōzu's version, got {wire:?}"
);
assert!(
!wire.contains("HTTP/1.0"),
"the backend's version must not reach the client, got {wire:?}"
);
assert!(
!wire.to_ascii_lowercase().contains("connection: close"),
"a persistent response must not announce a close, got {wire:?}"
);
assert!(
wire.ends_with("\r\n\r\nhello"),
"the body follows, got {wire:?}"
);
}
#[test]
fn an_http10_response_without_keep_alive_is_forwarded_as_http11_with_close() {
let cases: [(&str, &[u8], &str); 3] = [
("close-delimited", b"HTTP/1.0 200 OK\r\n\r\nabcd", "abcd"),
(
"content-length",
b"HTTP/1.0 200 OK\r\nContent-Length: 4\r\n\r\nabcd",
"abcd",
),
(
"keep-alive but close-delimited",
b"HTTP/1.0 200 OK\r\nConnection: keep-alive\r\n\r\nabcd",
"abcd",
),
];
for (label, bytes, body) in cases {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let (mut kawa, ctx) = parse_response(&mut pool, bytes);
assert!(
!ctx.keep_alive_backend,
"{label}: a non-persistent HTTP/1.0 response closes the backend"
);
let wire = serialized_request(&mut kawa);
assert!(
wire.starts_with("HTTP/1.1 200 OK\r\n"),
"{label}: the status line carries Sōzu's version, got {wire:?}"
);
let (head, forwarded_body) = wire
.split_once("\r\n\r\n")
.expect("the response head ends with an empty line");
let connection: Vec<&str> = head
.split("\r\n")
.filter(|line| line.to_ascii_lowercase().starts_with("connection:"))
.collect();
assert_eq!(
connection,
["Connection: close"],
"{label}: exactly one Connection: close announces the close, got {wire:?}"
);
assert_eq!(forwarded_body, body, "{label}: the body is forwarded");
}
}
fn connection_lines(wire: &str) -> Vec<&str> {
let (head, _) = wire
.split_once("\r\n\r\n")
.expect("the response head ends with an empty line");
head.split("\r\n")
.filter(|line| line.to_ascii_lowercase().starts_with("connection:"))
.collect()
}
#[test]
fn a_close_option_in_a_connection_list_closes_the_backend() {
let cases: [(&str, &[u8]); 3] = [
(
"HTTP/1.0 list",
b"HTTP/1.0 200 OK\r\nContent-Length: 4\r\nConnection: keep-alive, close\r\n\r\nabcd",
),
(
"HTTP/1.1 list",
b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: keep-alive, Close \r\n\r\nabcd",
),
(
"HTTP/1.1 second line",
b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: x-custom\r\nConnection: close\r\n\r\nabcd",
),
];
for (label, bytes) in cases {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let (mut kawa, ctx) = parse_response(&mut pool, bytes);
assert!(
!ctx.keep_alive_backend,
"{label}: a close option closes the backend"
);
let wire = serialized_request(&mut kawa);
assert!(
connection_lines(&wire)
.iter()
.any(|line| line.to_ascii_lowercase().contains("close")),
"{label}: the close is still announced, got {wire:?}"
);
}
}
fn parse_request(
pool: &mut crate::pool::Pool,
bytes: &[u8],
) -> (GenericHttpStream, HttpContext) {
let mut ctx = make_context();
let mut kawa: GenericHttpStream = kawa::Kawa::new(
kawa::Kind::Request,
kawa::Buffer::new(
pool.checkout()
.expect("the test pool must hand out a buffer"),
),
);
kawa.storage.space()[..bytes.len()].copy_from_slice(bytes);
kawa.storage.fill(bytes.len());
kawa::h1::parse(&mut kawa, &mut ctx);
assert!(!kawa.is_error(), "premise: the request must parse");
(kawa, ctx)
}
#[test]
fn a_close_option_in_a_request_connection_list_closes_the_client() {
let cases: [(&str, &[u8], bool); 7] = [
(
"single close",
b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n",
false,
),
(
"keep-alive then close",
b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: keep-alive, close\r\n\r\n",
false,
),
(
"close then TE",
b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close, TE\r\nTE: trailers\r\n\r\n",
false,
),
(
"close listed in the second line",
b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: keep-alive\r\nConnection: TE, Close \r\nTE: trailers\r\n\r\n",
false,
),
(
"closed is not close",
b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: closed\r\n\r\n",
true,
),
(
"keep-alive only",
b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: keep-alive\r\n\r\n",
true,
),
(
"no Connection",
b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
true,
),
];
for (label, bytes, keeps) in cases {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let (mut kawa, ctx) = parse_request(&mut pool, bytes);
assert_eq!(
ctx.keep_alive_frontend, keeps,
"{label}: keep_alive_frontend"
);
assert!(
ctx.keep_alive_backend,
"{label}: the request never decides the backend's persistence"
);
let sent: Vec<String> = connection_lines(&String::from_utf8_lossy(bytes))
.iter()
.map(|line| line.to_ascii_lowercase().replace(' ', ""))
.collect();
let wire = serialized_request(&mut kawa);
let forwarded: Vec<String> = connection_lines(&wire)
.iter()
.map(|line| line.to_ascii_lowercase().replace(' ', ""))
.collect();
assert_eq!(
forwarded, sent,
"{label}: the Connection lines are forwarded as sent, got {wire:?}"
);
}
}
#[test]
fn a_non_persistent_http10_response_keeps_its_connection_options() {
let cases: [(&str, &[u8], &str); 3] = [
(
"nominated field",
b"HTTP/1.0 200 OK\r\nConnection: x-custom\r\nX-Custom: 1\r\n\r\nabcd",
"Connection: x-custom, close",
),
(
"keep-alive dropped",
b"HTTP/1.0 200 OK\r\nConnection: keep-alive, x-custom\r\n\r\nabcd",
"Connection: x-custom, close",
),
(
"two lines merged",
b"HTTP/1.0 200 OK\r\nConnection: x-one\r\nConnection: x-two\r\n\r\nabcd",
"Connection: x-one, x-two, close",
),
];
for (label, bytes, want) in cases {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let (mut kawa, ctx) = parse_response(&mut pool, bytes);
assert!(!ctx.keep_alive_backend, "{label}: premise, not persistent");
let wire = serialized_request(&mut kawa);
assert_eq!(
connection_lines(&wire),
[want],
"{label}: the options survive beside close, got {wire:?}"
);
}
}
#[test]
fn a_chunked_http10_response_closes_the_backend() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let (mut kawa, ctx) = parse_response(
&mut pool,
b"HTTP/1.0 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n4\r\nabcd\r\n0\r\n\r\n",
);
assert!(kawa.is_terminated(), "premise: the chunked body ended");
assert!(
!ctx.keep_alive_backend,
"a chunked HTTP/1.0 response closes the backend"
);
let wire = serialized_request(&mut kawa);
assert_eq!(
connection_lines(&wire),
["Connection: close"],
"got {wire:?}"
);
}
#[test]
fn an_http10_101_keeps_its_connection_upgrade() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let (mut kawa, ctx) = parse_response(
&mut pool,
b"HTTP/1.0 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n",
);
assert!(
ctx.keep_alive_backend,
"a 101 leaves keep_alive_backend alone"
);
let wire = serialized_request(&mut kawa);
assert!(
wire.starts_with("HTTP/1.1 101 Switching Protocols\r\n"),
"got {wire:?}"
);
assert_eq!(
connection_lines(&wire),
["Connection: Upgrade"],
"the upgrade must survive, got {wire:?}"
);
}
#[test]
fn an_http10_100_leaves_persistence_to_the_final_response() {
let mut pool = crate::pool::Pool::with_capacity(1, 2, 4096);
let mut ctx = make_context();
for (bytes, label) in [
(&b"HTTP/1.0 100 Continue\r\n\r\n"[..], "100"),
(
&b"HTTP/1.0 200 OK\r\nContent-Length: 4\r\nConnection: keep-alive\r\n\r\nabcd"[..],
"final",
),
] {
let mut kawa: GenericHttpStream = kawa::Kawa::new(
kawa::Kind::Response,
kawa::Buffer::new(
pool.checkout()
.expect("the test pool must hand out a buffer"),
),
);
kawa.storage.space()[..bytes.len()].copy_from_slice(bytes);
kawa.storage.fill(bytes.len());
kawa::h1::parse(&mut kawa, &mut ctx);
assert!(kawa.is_terminated(), "{label}: premise, parsed whole");
assert!(
ctx.keep_alive_backend,
"{label}: a 100 then a keep-alive response keep the backend"
);
let wire = serialized_request(&mut kawa);
assert!(
connection_lines(&wire)
.iter()
.all(|line| !line.to_ascii_lowercase().contains("close")),
"{label}: no close is announced, got {wire:?}"
);
}
}
fn parse_closing(
pool: &mut crate::pool::Pool,
kind: kawa::Kind,
bytes: &[u8],
) -> (GenericHttpStream, HttpContext) {
let mut ctx = make_context();
ctx.closing = true;
let mut kawa: GenericHttpStream = kawa::Kawa::new(
kind,
kawa::Buffer::new(
pool.checkout()
.expect("the test pool must hand out a buffer"),
),
);
kawa.storage.space()[..bytes.len()].copy_from_slice(bytes);
kawa.storage.fill(bytes.len());
kawa::h1::parse(&mut kawa, &mut ctx);
assert!(!kawa.is_error(), "premise: the message must parse");
(kawa, ctx)
}
fn head_lines(wire: &str) -> Vec<String> {
let (head, _) = wire
.split_once("\r\n\r\n")
.expect("the head ends with an empty line");
head.split("\r\n")
.map(|line| line.to_ascii_lowercase())
.collect()
}
#[test]
fn a_shutting_down_request_keeps_its_connection_options() {
let cases: [(&str, &[u8], &str, bool); 6] = [
(
"no Connection",
b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
"Connection: close",
true,
),
(
"nominated field",
b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: x-custom\r\nX-Custom: 1\r\n\r\n",
"Connection: x-custom, close",
true,
),
(
"keep-alive dropped",
b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: keep-alive, x-custom\r\n\r\n",
"Connection: x-custom, close",
true,
),
(
"single close not duplicated",
b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n",
"Connection: close",
false,
),
(
"two lines merged",
b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: x-one\r\nConnection: TE, Close \r\nTE: trailers\r\n\r\n",
"Connection: x-one, TE, close",
false,
),
(
"HTTP/1.0",
b"GET / HTTP/1.0\r\nHost: example.com\r\nConnection: keep-alive, x-custom\r\n\r\n",
"Connection: x-custom, close",
true,
),
];
for (label, bytes, want, keeps) in cases {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let (mut kawa, ctx) = parse_closing(&mut pool, kawa::Kind::Request, bytes);
assert_eq!(
ctx.keep_alive_frontend, keeps,
"{label}: a client close is still recorded"
);
let wire = serialized_request(&mut kawa);
assert_eq!(
connection_lines(&wire),
[want],
"{label}: the options survive beside one close, got {wire:?}"
);
}
}
#[test]
fn a_shutting_down_request_does_not_upgrade() {
let cases: [(&str, &[u8], &str); 2] = [
(
"websocket",
b"GET /chat HTTP/1.1\r\nHost: example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n",
"Connection: close",
),
(
"upgrade among other options",
b"GET /chat HTTP/1.1\r\nHost: example.com\r\nConnection: keep-alive, Upgrade, x-custom\r\nUpgrade: websocket\r\nX-Custom: 1\r\n\r\n",
"Connection: x-custom, close",
),
];
for (label, bytes, want) in cases {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let (mut kawa, _ctx) = parse_closing(&mut pool, kawa::Kind::Request, bytes);
let wire = serialized_request(&mut kawa);
assert_eq!(
connection_lines(&wire),
[want],
"{label}: the upgrade is not requested, got {wire:?}"
);
assert!(
!head_lines(&wire)
.iter()
.any(|line| line.starts_with("upgrade:")),
"{label}: the Upgrade field is not forwarded, got {wire:?}"
);
}
}
#[test]
fn a_shutting_down_response_keeps_its_connection_options() {
let cases: [(&str, &[u8], &str, bool); 6] = [
(
"no Connection",
b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nabcd",
"Connection: close",
true,
),
(
"nominated field",
b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: x-custom\r\nX-Custom: 1\r\n\r\nabcd",
"Connection: x-custom, close",
true,
),
(
"keep-alive dropped",
b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: keep-alive, x-custom\r\n\r\nabcd",
"Connection: x-custom, close",
true,
),
(
"single close not duplicated",
b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\nabcd",
"Connection: close",
false,
),
(
"two lines merged",
b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: x-one\r\nConnection: x-two, Close \r\n\r\nabcd",
"Connection: x-one, x-two, close",
false,
),
(
"persistent HTTP/1.0",
b"HTTP/1.0 200 OK\r\nContent-Length: 4\r\nConnection: keep-alive, x-custom\r\n\r\nabcd",
"Connection: x-custom, close",
true,
),
];
for (label, bytes, want, keeps) in cases {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let (mut kawa, ctx) = parse_closing(&mut pool, kawa::Kind::Response, bytes);
assert_eq!(
ctx.keep_alive_backend, keeps,
"{label}: the backend's close is still recorded"
);
let wire = serialized_request(&mut kawa);
assert!(wire.starts_with("HTTP/1.1 200 OK\r\n"), "got {wire:?}");
assert_eq!(
connection_lines(&wire),
[want],
"{label}: the options survive beside one close, got {wire:?}"
);
}
}
#[test]
fn a_shutting_down_interim_response_is_left_alone() {
let cases: [(&str, &[u8], &[&str]); 2] = [
(
"101",
b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n",
&["Connection: Upgrade"],
),
("100", b"HTTP/1.1 100 Continue\r\n\r\n", &[]),
];
for (label, bytes, want) in cases {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let (mut kawa, ctx) = parse_closing(&mut pool, kawa::Kind::Response, bytes);
assert!(
ctx.keep_alive_backend,
"{label}: an interim response leaves keep_alive_backend alone"
);
let wire = serialized_request(&mut kawa);
assert_eq!(
connection_lines(&wire),
want,
"{label}: the interim Connection lines are forwarded as sent, got {wire:?}"
);
}
}
fn elide_spoof_vectors(kawa: &mut GenericHttpStream, first_new_block: usize) -> usize {
make_context()
.filter_request_trailers(kawa, first_new_block)
.spoof_vectors
}
const CHUNKED_HEAD: &[u8] = b"POST /upload HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nHello\r\n0\r\n";
const SPOOF_TRAILERS: &[u8] = b"X-Forwarded-For: 6.6.6.6\r\nforwarded: for=6.6.6.6\r\nX-REAL-IP: 6.6.6.6\r\nX-Request-Id: 6.6.6.6\r\nX-Forwarded-Proto: 6.6.6.6\r\nx-forwarded-port: 6.6.6.6\r\nX-Forwarded-Host: 6.6.6.6\r\nGrpc-Status: 0\r\n\r\n";
#[test]
fn a_chunked_request_trailer_section_loses_its_spoof_vector_fields() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let bytes = [CHUNKED_HEAD, SPOOF_TRAILERS].concat();
let mut kawa = parse_message(&mut pool, kawa::Kind::Request, &bytes);
assert!(kawa.is_terminated(), "the trailer section ends the request");
assert_eq!(
elide_spoof_vectors(&mut kawa, 0),
TRAILER_SPOOF_VECTOR_HEADERS.len(),
"every spoof-vector trailer field is elided once"
);
assert_eq!(
elide_spoof_vectors(&mut kawa, 0),
0,
"a second walk finds nothing left to elide"
);
let wire = serialized_request(&mut kawa);
assert!(
!wire.contains("6.6.6.6"),
"no spoofed trailer may reach the backend, got {wire:?}"
);
assert!(
wire.ends_with("\r\n\r\n5\r\nHello\r\n0\r\nGrpc-Status: 0\r\n\r\n"),
"the legitimate trailer and the chunk framing survive, got {wire:?}"
);
}
#[test]
fn a_trailer_section_parsed_after_the_last_chunk_was_forwarded_is_filtered() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = parse_message(&mut pool, kawa::Kind::Request, CHUNKED_HEAD);
assert_eq!(kawa.parsing_phase, kawa::ParsingPhase::Trailers);
assert_eq!(elide_spoof_vectors(&mut kawa, 0), 0);
let head = serialized_request(&mut kawa);
assert!(
kawa.blocks.is_empty(),
"the last-chunk marker was forwarded"
);
const ONLY_SPOOF: &[u8] = b"X-Forwarded-For: 6.6.6.6\r\nForwarded: for=6.6.6.6\r\n\r\n";
kawa.storage.space()[..ONLY_SPOOF.len()].copy_from_slice(ONLY_SPOOF);
kawa.storage.fill(ONLY_SPOOF.len());
let before = kawa.blocks.len();
kawa::h1::parse(&mut kawa, &mut make_context());
assert!(kawa.is_terminated(), "the trailer section ends the request");
assert_eq!(elide_spoof_vectors(&mut kawa, before), 2);
let wire = serialized_request(&mut kawa);
assert!(
wire.starts_with(&head) && !wire.contains("6.6.6.6"),
"no spoofed trailer may reach the backend, got {wire:?}"
);
assert!(
wire.ends_with("0\r\n\r\n"),
"the empty trailer section is still closed, got {wire:?}"
);
}
#[test]
fn trailer_elision_leaves_headers_and_responses_alone() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let cases: [(kawa::Kind, &[u8]); 3] = [
(
kawa::Kind::Request,
b"POST / HTTP/1.1\r\nHost: example.com\r\nX-Request-Id: keep\r\nContent-Length: 2\r\n\r\nok",
),
(
kawa::Kind::Request,
b"POST / HTTP/1.1\r\nHost: example.com\r\nX-Request-Id: keep\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nok\r\n",
),
(
kawa::Kind::Response,
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nX-Request-Id: keep\r\n\r\n",
),
];
for (kind, bytes) in cases {
let mut kawa = parse_message(&mut pool, kind, bytes);
assert_eq!(
elide_spoof_vectors(&mut kawa, 0),
0,
"{kind:?} {:?}: nothing to elide",
kawa.parsing_phase
);
let wire = serialized_request(&mut kawa);
assert!(
wire.contains("X-Request-Id: keep\r\n"),
"{kind:?}: the field survives, got {wire:?}"
);
}
}
fn feed_and_filter(
context: &mut HttpContext,
kawa: &mut GenericHttpStream,
bytes: &[u8],
) -> TrailerFilterOutcome {
kawa.storage.space()[..bytes.len()].copy_from_slice(bytes);
kawa.storage.fill(bytes.len());
let before = kawa.blocks.len();
kawa::h1::parse(kawa, &mut make_context());
context.filter_request_trailers(kawa, before)
}
fn feed_and_elide(kawa: &mut GenericHttpStream, bytes: &[u8]) -> usize {
feed_and_filter(&mut make_context(), kawa, bytes).spoof_vectors
}
#[test]
fn a_trailer_section_split_across_reads_is_filtered_in_each_part() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = parse_message(&mut pool, kawa::Kind::Request, CHUNKED_HEAD);
assert_eq!(kawa.parsing_phase, kawa::ParsingPhase::Trailers);
assert_eq!(
feed_and_elide(&mut kawa, b"X-Forwarded-For: 6.6.6.6\r\n"),
1,
"the first trailer part is filtered on its own"
);
assert_eq!(kawa.parsing_phase, kawa::ParsingPhase::Trailers);
let first = serialized_request(&mut kawa);
assert!(kawa.blocks.is_empty(), "the first part was forwarded");
assert_eq!(
feed_and_elide(
&mut kawa,
b"Forwarded: for=6.6.6.6\r\nGrpc-Status: 0\r\n\r\n"
),
1,
"the second trailer part is filtered on its own"
);
assert!(kawa.is_terminated(), "the trailer section ends the request");
let wire = serialized_request(&mut kawa);
assert!(
wire.starts_with(&first) && !wire.contains("6.6.6.6"),
"no spoofed trailer may reach the backend, got {wire:?}"
);
assert!(
wire.ends_with("0\r\nGrpc-Status: 0\r\n\r\n"),
"the legitimate trailer and the closing empty line survive, got {wire:?}"
);
}
#[test]
fn a_trailer_walk_stops_at_the_blocks_queued_before_the_parse() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let head = [CHUNKED_HEAD, b"X-Forwarded-For: 6.6.6.6\r\n"].concat();
let mut kawa = parse_message(&mut pool, kawa::Kind::Request, &head);
assert_eq!(kawa.parsing_phase, kawa::ParsingPhase::Trailers);
let queued = kawa.blocks.len();
for line in [
&b"Forwarded: for=6.6.6.6\r\n"[..],
b"X-Real-IP: 6.6.6.6\r\n",
b"Grpc-Status: 0\r\n",
] {
let spoofed = usize::from(!line.starts_with(b"Grpc"));
assert_eq!(
feed_and_elide(&mut kawa, line),
spoofed,
"only the field this read appended is examined"
);
}
assert_eq!(feed_and_elide(&mut kawa, b"\r\n"), 0);
assert!(kawa.is_terminated(), "the trailer section ends the request");
let buf = kawa.storage.buffer();
let older = kawa
.blocks
.range(..queued)
.filter_map(|block| match block {
kawa::Block::Header(pair) if !pair.is_elided() => Some(pair.key.data(buf)),
_ => None,
})
.collect::<Vec<_>>();
assert!(
older
.iter()
.any(|key| key.eq_ignore_ascii_case(b"X-Forwarded-For")),
"a block queued before the parse is outside the walk, got {older:?}"
);
}
const FORBIDDEN_TRAILERS: &[u8] = b"Content-Length: 6666\r\nTRANSFER-ENCODING: 6.6.6.6\r\nHost: 6.6.6.6\r\nExpect: 6.6.6.6\r\nIf-Match: 6.6.6.6\r\nauthorization: 6.6.6.6\r\nCookie: 6.6.6.6\r\nContent-Type: 6.6.6.6\r\nTrailer: 6.6.6.6\r\nConnection: 6.6.6.6\r\nGrpc-Status: 0\r\nX-Checksum: abc\r\n\r\n";
#[test]
fn a_chunked_request_trailer_section_loses_its_forbidden_fields() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let bytes = [CHUNKED_HEAD, FORBIDDEN_TRAILERS].concat();
let mut kawa = parse_message(&mut pool, kawa::Kind::Request, &bytes);
assert!(kawa.is_terminated(), "the trailer section ends the request");
let mut context = make_context();
let outcome = context.filter_request_trailers(&mut kawa, 0);
assert_eq!(
outcome,
TrailerFilterOutcome {
spoof_vectors: 0,
forbidden: 10,
over_limit: false,
},
"every forbidden trailer field is elided once"
);
assert_eq!(context.trailer_fields, 12, "every field is counted");
assert!(!kawa.is_error(), "a section within the bound is admitted");
let wire = serialized_request(&mut kawa);
assert!(
!wire.contains("6.6.6.6")
&& !wire
.to_ascii_lowercase()
.contains("\r\ncontent-length: 6666\r\n"),
"no forbidden trailer may reach the backend, got {wire:?}"
);
assert!(
wire.ends_with("\r\n\r\n5\r\nHello\r\n0\r\nGrpc-Status: 0\r\nX-Checksum: abc\r\n\r\n"),
"the legitimate trailers and the chunk framing survive, got {wire:?}"
);
}
#[test]
fn trailer_forbidden_fields_are_matched_without_case_and_disjoint_from_spoof_vectors() {
for name in TRAILER_FORBIDDEN_FIELDS {
assert!(
is_trailer_forbidden_field(&name.to_ascii_uppercase()),
"{:?} is matched in upper case",
String::from_utf8_lossy(name)
);
assert!(
!is_trailer_spoof_vector(name),
"{:?} is in both lists",
String::from_utf8_lossy(name)
);
}
for legit in [
&b"grpc-status"[..],
b"grpc-message",
b"x-checksum",
b"digest",
] {
assert!(!is_trailer_forbidden_field(legit));
}
}
fn numbered_trailers(count: usize) -> Vec<u8> {
let mut bytes = Vec::new();
for i in 0..count {
bytes.extend_from_slice(format!("X-T{i}: {i}\r\n").as_bytes());
}
bytes.extend_from_slice(b"\r\n");
bytes
}
#[test]
fn a_trailer_section_over_the_field_bound_is_an_error() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
for (count, over) in [(4, false), (5, true)] {
let bytes = [CHUNKED_HEAD, &numbered_trailers(count)].concat();
let mut kawa = parse_message(&mut pool, kawa::Kind::Request, &bytes);
assert!(kawa.is_terminated());
let mut context = make_context();
context.max_trailer_fields = 4;
let outcome = context.filter_request_trailers(&mut kawa, 0);
assert_eq!(
outcome.over_limit, over,
"{count} fields against a bound of 4"
);
assert_eq!(kawa.is_error(), over, "{count} fields against a bound of 4");
}
let bytes = [
CHUNKED_HEAD,
b"Host: a\r\nHost: b\r\nX-Forwarded-For: c\r\n\r\n".as_slice(),
]
.concat();
let mut kawa = parse_message(&mut pool, kawa::Kind::Request, &bytes);
let mut context = make_context();
context.max_trailer_fields = 2;
let outcome = context.filter_request_trailers(&mut kawa, 0);
assert!(outcome.over_limit && kawa.is_error(), "elided fields count");
}
#[test]
fn the_trailer_field_bound_spans_reads_and_resets_per_request() {
let mut pool = crate::pool::Pool::with_capacity(1, 1, 4096);
let mut kawa = parse_message(&mut pool, kawa::Kind::Request, CHUNKED_HEAD);
let mut context = make_context();
context.max_trailer_fields = 2;
assert_eq!(
context.filter_request_trailers(&mut kawa, 0),
Default::default()
);
for (i, line) in [&b"X-A: 1\r\n"[..], b"X-B: 2\r\n"].into_iter().enumerate() {
let outcome = feed_and_filter(&mut context, &mut kawa, line);
assert!(!outcome.over_limit && !kawa.is_error(), "field {i} fits");
serialized_request(&mut kawa);
assert!(kawa.blocks.is_empty(), "field {i} was forwarded");
}
let outcome = feed_and_filter(&mut context, &mut kawa, b"X-C: 3\r\n");
assert!(
outcome.over_limit && kawa.is_error(),
"the third field goes over"
);
context.reset(Ulid::generate());
assert_eq!(context.trailer_fields, 0, "reset clears the running count");
assert_eq!(context.max_trailer_fields, 2, "reset keeps the bound");
}
}