use crate::types::Http1Error;
use std::borrow::Cow;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SmugglingKind {
ClTe,
TeCl,
TeTe,
HostInjection,
HeaderInjection,
DoubleContentLength,
DuplicateHost,
}
impl SmugglingKind {
#[inline]
pub fn as_str(&self) -> &'static str {
match self {
Self::ClTe => "CL.TE",
Self::TeCl => "TE.CL",
Self::TeTe => "TE.TE",
Self::HostInjection => "Host Injection",
Self::HeaderInjection => "Header Injection",
Self::DoubleContentLength => "Double Content-Length",
Self::DuplicateHost => "Duplicate Host",
}
}
}
#[derive(Debug, Default, Clone)]
pub struct SmugglingDetector;
impl SmugglingDetector {
#[inline]
pub fn new() -> Self {
Self
}
pub fn detect<N, V>(
&self,
headers: &[(N, V)],
) -> Result<(), (SmugglingKind, String)>
where
N: AsRef<str>,
V: AsRef<str>,
{
self.detect_impl(headers, |s| Cow::Owned(s.to_ascii_lowercase()))
}
fn detect_impl<N, V>(
&self,
headers: &[(N, V)],
normalize: impl for<'a> Fn(&'a str) -> Cow<'a, str>,
) -> Result<(), (SmugglingKind, String)>
where
N: AsRef<str>,
V: AsRef<str>,
{
let mut has_content_length = false;
let mut content_length_count: usize = 0;
let mut transfer_encoding_values: Vec<String> = Vec::with_capacity(4);
let mut has_te = false;
let mut host_count: usize = 0;
let mut cl_position: Option<usize> = None;
let mut te_position: Option<usize> = None;
for (idx, (name, value)) in headers.iter().enumerate() {
let value = value.as_ref();
let n = normalize(name.as_ref());
match &*n {
"content-length" => {
content_length_count += 1;
if content_length_count > 1 {
return Err((
SmugglingKind::DoubleContentLength,
"multiple Content-Length".into(),
));
}
if !value.chars().all(|c| c.is_ascii_digit()) {
return Err((
SmugglingKind::ClTe,
"Content-Length not decimal".into(),
));
}
has_content_length = true;
if cl_position.is_none() {
cl_position = Some(idx);
}
}
"transfer-encoding" => {
has_te = true;
if te_position.is_none() {
te_position = Some(idx);
}
for v in value.split(',') {
let v = v.trim().to_ascii_lowercase();
if !v.is_empty() {
transfer_encoding_values.push(v);
}
}
}
"host" => {
host_count += 1;
if host_count > 1 {
return Err((
SmugglingKind::DuplicateHost,
"multiple Host headers".into(),
));
}
if value.contains('\r') || value.contains('\n') {
return Err((
SmugglingKind::HostInjection,
"host contains CRLF".into(),
));
}
}
_ => {}
}
if value.contains("\r") || value.contains("\n") {
return Err((
SmugglingKind::HeaderInjection,
format!("header '{n}' value contains CRLF"),
));
}
if Self::contains_invalid_control(value) {
return Err((
SmugglingKind::HeaderInjection,
format!("header '{n}' contains invalid control chars"),
));
}
}
if has_content_length && has_te {
let kind = match (cl_position, te_position) {
(Some(cl), Some(te)) if te < cl => SmugglingKind::TeCl,
_ => SmugglingKind::ClTe,
};
return Err((
kind,
"Content-Length and Transfer-Encoding both present".into(),
));
}
if has_te {
if transfer_encoding_values.iter().any(|v| v == "identity") {
return Err((
SmugglingKind::TeTe,
"deprecated 'identity' in Transfer-Encoding (RFC 7230)".into(),
));
}
let chunked_positions: Vec<usize> = transfer_encoding_values
.iter()
.enumerate()
.filter_map(|(i, v)| (v == "chunked").then_some(i))
.collect();
if chunked_positions.len() > 1 {
return Err((
SmugglingKind::TeTe,
"multiple 'chunked' in Transfer-Encoding".into(),
));
}
if let Some(pos) = chunked_positions.first().copied()
&& pos != transfer_encoding_values.len() - 1 {
return Err((
SmugglingKind::TeTe,
"'chunked' not last in Transfer-Encoding".into(),
));
}
if chunked_positions.is_empty() {
return Err((
SmugglingKind::TeTe,
"Transfer-Encoding without 'chunked'".into(),
));
}
}
Ok(())
}
pub fn detect_already_lowercased<N, V>(
&self,
headers: &[(N, V)],
) -> Result<(), (SmugglingKind, String)>
where
N: AsRef<str>,
V: AsRef<str>,
{
self.detect_impl(headers, |s| Cow::Borrowed(s))
}
pub fn detect_err<N, V>(
&self,
headers: &[(N, V)],
) -> Result<(), Http1Error>
where
N: AsRef<str>,
V: AsRef<str>,
{
self.detect(headers).map_err(|(k, m)| {
Http1Error::SmugglingDetected(format!("{}: {}", k.as_str(), m))
})
}
pub fn detect_err_already_lowercased<N, V>(
&self,
headers: &[(N, V)],
) -> Result<(), Http1Error>
where
N: AsRef<str>,
V: AsRef<str>,
{
self.detect_already_lowercased(headers).map_err(|(k, m)| {
Http1Error::SmugglingDetected(format!("{}: {}", k.as_str(), m))
})
}
#[inline]
fn contains_invalid_control(s: &str) -> bool {
s.chars().any(|c| {
let code = c as u32;
matches!(code, 0x00..=0x08 | 0x0b | 0x0c | 0x0e..=0x1f | 0x7f)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clean_headers() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("Content-Length".to_string(), "13".to_string()),
("Accept".to_string(), "text/plain".to_string()),
];
assert!(d.detect(&headers).is_ok());
}
#[test]
fn test_cl_te_smuggling() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("Content-Length".to_string(), "0".to_string()),
("Transfer-Encoding".to_string(), "chunked".to_string()),
];
let r = d.detect(&headers);
assert!(r.is_err());
assert_eq!(r.unwrap_err().0, SmugglingKind::ClTe);
}
#[test]
fn test_te_te_multiple_chunked() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
(
"Transfer-Encoding".to_string(),
"chunked, chunked".to_string(),
),
];
let r = d.detect(&headers);
assert_eq!(r.unwrap_err().0, SmugglingKind::TeTe);
}
#[test]
fn test_te_te_chunked_not_last() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
(
"Transfer-Encoding".to_string(),
"chunked, identity".to_string(),
),
];
let r = d.detect(&headers);
assert_eq!(r.unwrap_err().0, SmugglingKind::TeTe);
}
#[test]
fn test_double_content_length() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("Content-Length".to_string(), "10".to_string()),
("Content-Length".to_string(), "20".to_string()),
];
let r = d.detect(&headers);
assert_eq!(r.unwrap_err().0, SmugglingKind::DoubleContentLength);
}
#[test]
fn test_header_injection_crlf() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("X-Test".to_string(), "val\r\nEvil: yes".to_string()),
];
let r = d.detect(&headers);
assert_eq!(r.unwrap_err().0, SmugglingKind::HeaderInjection);
}
#[test]
fn test_host_injection() {
let d = SmugglingDetector::new();
let headers = vec![("Host".to_string(), "example.com\r\nX: y".to_string())];
let r = d.detect(&headers);
assert_eq!(r.unwrap_err().0, SmugglingKind::HostInjection);
}
#[test]
fn test_duplicate_host_rejected() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "a.com".to_string()),
("Host".to_string(), "b.com".to_string()),
];
let r = d.detect(&headers);
assert_eq!(r.unwrap_err().0, SmugglingKind::DuplicateHost);
}
#[test]
fn test_duplicate_host_case_insensitive() {
let d = SmugglingDetector::new();
let headers = vec![
("HOST".to_string(), "a.com".to_string()),
("host".to_string(), "a.com".to_string()),
];
let r = d.detect(&headers);
assert_eq!(r.unwrap_err().0, SmugglingKind::DuplicateHost);
}
#[test]
fn test_valid_te_chunked() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("Transfer-Encoding".to_string(), "gzip, chunked".to_string()),
];
assert!(d.detect(&headers).is_ok());
}
#[test]
fn test_invalid_control_chars() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("X".to_string(), "val\x00ue".to_string()),
];
let r = d.detect(&headers);
assert_eq!(r.unwrap_err().0, SmugglingKind::HeaderInjection);
}
#[test]
fn test_smuggling_kind_all_variants() {
let kinds = [
SmugglingKind::ClTe,
SmugglingKind::TeCl,
SmugglingKind::TeTe,
SmugglingKind::HostInjection,
SmugglingKind::HeaderInjection,
SmugglingKind::DoubleContentLength,
];
for k in kinds.iter() {
let s = k.as_str();
assert!(!s.is_empty());
}
}
#[test]
fn test_smuggling_kind_as_str() {
assert_eq!(SmugglingKind::ClTe.as_str(), "CL.TE");
assert_eq!(SmugglingKind::TeCl.as_str(), "TE.CL");
assert_eq!(SmugglingKind::TeTe.as_str(), "TE.TE");
assert_eq!(SmugglingKind::HostInjection.as_str(), "Host Injection");
assert_eq!(SmugglingKind::HeaderInjection.as_str(), "Header Injection");
assert_eq!(SmugglingKind::DoubleContentLength.as_str(), "Double Content-Length");
}
#[test]
fn test_smuggling_detector_default() {
let d = SmugglingDetector;
let headers = vec![("Host".to_string(), "example.com".to_string())];
assert!(d.detect(&headers).is_ok());
}
#[test]
fn test_smuggling_detector_new() {
let d = SmugglingDetector::new();
let headers = vec![("Host".to_string(), "example.com".to_string())];
assert!(d.detect(&headers).is_ok());
}
#[test]
fn test_detect_err_wraps_correctly() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("Content-Length".to_string(), "10".to_string()),
("Content-Length".to_string(), "20".to_string()),
];
let r = d.detect_err(&headers);
assert!(r.is_err());
let err = r.unwrap_err();
assert!(matches!(err, Http1Error::SmugglingDetected(_)));
let err_str = err.to_string();
assert!(err_str.contains("Double Content-Length"));
}
#[test]
fn test_content_length_not_decimal() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("Content-Length".to_string(), "12a3".to_string()),
];
let r = d.detect(&headers);
assert!(r.is_err());
assert_eq!(r.unwrap_err().0, SmugglingKind::ClTe);
}
#[test]
fn test_content_length_negative_rejected() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("Content-Length".to_string(), "-5".to_string()),
];
let r = d.detect(&headers);
assert!(r.is_err());
}
#[test]
fn test_transfer_encoding_whitespace() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("Transfer-Encoding".to_string(), " chunked ".to_string()),
];
assert!(d.detect(&headers).is_ok());
}
#[test]
fn test_transfer_encoding_mixed_case() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("Transfer-Encoding".to_string(), "Chunked".to_string()),
];
let r = d.detect(&headers);
assert!(r.is_ok());
}
#[test]
fn test_header_name_case_insensitive() {
let d = SmugglingDetector::new();
let headers = vec![
("HOST".to_string(), "example.com".to_string()),
("content-length".to_string(), "10".to_string()),
];
assert!(d.detect(&headers).is_ok());
}
#[test]
fn test_null_byte_in_header_value() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("X-Test".to_string(), "val\x00ue".to_string()),
];
let r = d.detect(&headers);
assert!(r.is_err());
assert_eq!(r.unwrap_err().0, SmugglingKind::HeaderInjection);
}
#[test]
fn test_vtab_in_header_value() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("X-Test".to_string(), "val\x0bue".to_string()),
];
let r = d.detect(&headers);
assert!(r.is_err());
assert_eq!(r.unwrap_err().0, SmugglingKind::HeaderInjection);
}
#[test]
fn test_formfeed_in_header_value() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("X-Test".to_string(), "val\x0cue".to_string()),
];
let r = d.detect(&headers);
assert!(r.is_err());
assert_eq!(r.unwrap_err().0, SmugglingKind::HeaderInjection);
}
#[test]
fn test_cr_only_in_header_value() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("X-Test".to_string(), "val\revil".to_string()),
];
let r = d.detect(&headers);
assert!(r.is_err());
assert_eq!(r.unwrap_err().0, SmugglingKind::HeaderInjection);
}
#[test]
fn test_lf_only_in_header_value() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("X-Test".to_string(), "val\nevil".to_string()),
];
let r = d.detect(&headers);
assert!(r.is_err());
assert_eq!(r.unwrap_err().0, SmugglingKind::HeaderInjection);
}
#[test]
fn test_te_cl_direction_distinguished() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("Transfer-Encoding".to_string(), "chunked".to_string()),
("Content-Length".to_string(), "0".to_string()),
];
let r = d.detect(&headers);
assert!(r.is_err());
assert_eq!(
r.unwrap_err().0,
SmugglingKind::TeCl,
"TE 在前必须判定为 TE.CL"
);
}
#[test]
fn test_cl_te_direction_distinguished() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("Content-Length".to_string(), "0".to_string()),
("Transfer-Encoding".to_string(), "chunked".to_string()),
];
let r = d.detect(&headers);
assert!(r.is_err());
assert_eq!(
r.unwrap_err().0,
SmugglingKind::ClTe,
"CL 在前必须判定为 CL.TE"
);
}
#[test]
fn test_te_without_chunked_rejected() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("Transfer-Encoding".to_string(), "gzip".to_string()),
];
let r = d.detect(&headers);
assert_eq!(r.unwrap_err().0, SmugglingKind::TeTe);
}
#[test]
fn test_te_identity_rejected() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("Transfer-Encoding".to_string(), "identity".to_string()),
];
let r = d.detect(&headers);
assert_eq!(
r.unwrap_err().0,
SmugglingKind::TeTe,
"TE: identity 必须按 TeTe 拒绝(fail-closed)"
);
}
#[test]
fn test_single_transfer_encoding_chunked() {
let d = SmugglingDetector::new();
let headers = vec![
("Host".to_string(), "example.com".to_string()),
("Transfer-Encoding".to_string(), "chunked".to_string()),
];
assert!(d.detect(&headers).is_ok());
}
#[test]
fn test_empty_headers() {
let d = SmugglingDetector::new();
let headers: Vec<(String, String)> = vec![];
assert!(d.detect(&headers).is_ok());
}
#[test]
fn test_smuggling_kind_debug() {
let k = SmugglingKind::ClTe;
let s = format!("{:?}", k);
assert!(!s.is_empty());
}
#[test]
fn test_smuggling_kind_clone() {
let k = SmugglingKind::ClTe;
let k2 = k;
assert_eq!(k, k2);
}
}