use crate::WafRequest;
const MAX_HEADER_VALUE_LEN: usize = 8 * 1024;
pub fn check_protocol(req: &WafRequest) -> Option<String> {
if req.path.contains('\0') || req.path.contains("%00") || req.path.contains("%2500") {
return Some("null byte in request path".into());
}
if let Some(ref q) = req.query {
if q.contains('\0') || q.contains("%00") || q.contains("%2500") {
return Some("null byte in query string".into());
}
}
for (name, value) in &req.headers {
if has_illegal_control_chars(name) {
return Some(format!("control character in header name: {name}"));
}
if has_illegal_control_chars(value) {
return Some(format!("control character in header value for: {name}"));
}
}
for (name, value) in &req.headers {
if value.len() > MAX_HEADER_VALUE_LEN {
return Some(format!(
"header value too long for {name}: {} bytes (max {})",
value.len(),
MAX_HEADER_VALUE_LEN
));
}
}
if req.body.is_some() && is_body_method(&req.method) {
let has_content_length = req
.headers
.keys()
.any(|k| k.eq_ignore_ascii_case("content-length"));
let has_transfer_encoding = req
.headers
.keys()
.any(|k| k.eq_ignore_ascii_case("transfer-encoding"));
if !has_content_length && !has_transfer_encoding {
return Some(
"request has body but no Content-Length or Transfer-Encoding header".into(),
);
}
}
let has_cl = req
.headers
.keys()
.any(|k| k.eq_ignore_ascii_case("content-length"));
let has_te = req
.headers
.keys()
.any(|k| k.eq_ignore_ascii_case("transfer-encoding"));
if has_cl && has_te {
return Some(
"request smuggling attempt: both Content-Length and Transfer-Encoding present".into(),
);
}
if let Some(cl) = req
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
.map(|(_, v)| v)
{
if !cl.trim().chars().all(|c| c.is_ascii_digit()) {
return Some("invalid Content-Length: non-numeric value".into());
}
}
if has_illegal_control_chars(&req.path) {
return Some("control character in request path".into());
}
if req.path.contains("//") {
return Some("double-slash in path (normalization bypass attempt)".into());
}
let upper = req.method.to_uppercase();
match upper.as_str() {
"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" => {}
"TRACE" | "TRACK" => {
return Some(format!(
"dangerous HTTP method: {} (XST/cross-site tracing)",
req.method
))
}
"DEBUG" => return Some("dangerous HTTP method: DEBUG (ASP.NET debug mode)".into()),
"CONNECT" => return Some("CONNECT method not allowed".into()),
_ => return Some(format!("non-standard HTTP method: {}", req.method)),
}
None
}
fn is_body_method(method: &str) -> bool {
matches!(method.to_uppercase().as_str(), "POST" | "PUT" | "PATCH")
}
fn has_illegal_control_chars(s: &str) -> bool {
s.bytes()
.any(|b| b < 0x20 && b != 0x09 && b != 0x0A && b != 0x0D)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn make_req(
method: &str,
path: &str,
headers: Vec<(&str, &str)>,
body: Option<&str>,
) -> WafRequest {
WafRequest {
client_ip: "127.0.0.1".parse().unwrap(),
method: method.into(),
path: path.into(),
query: None,
headers: headers
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
body: body.map(String::from),
user_agent: None,
}
}
#[test]
fn clean_request_passes() {
let req = make_req(
"POST",
"/api/data",
vec![
("Content-Length", "13"),
("Content-Type", "application/json"),
],
Some(r#"{"key":"val"}"#),
);
assert!(check_protocol(&req).is_none());
}
#[test]
fn clean_get_without_body_passes() {
let req = make_req("GET", "/api/users", vec![], None);
assert!(check_protocol(&req).is_none());
}
#[test]
fn detects_null_byte_in_path() {
let req = make_req("GET", "/uploads/shell.php\0.jpg", vec![], None);
let result = check_protocol(&req);
assert!(result.is_some());
assert!(result.unwrap().contains("null byte"));
}
#[test]
fn detects_percent_encoded_null_in_path() {
let req = make_req("GET", "/uploads/shell.php%00.jpg", vec![], None);
let result = check_protocol(&req);
assert!(result.is_some());
assert!(result.unwrap().contains("null byte"));
}
#[test]
fn detects_double_encoded_null_in_path() {
let req = make_req("GET", "/uploads/shell.php%2500.jpg", vec![], None);
let result = check_protocol(&req);
assert!(result.is_some());
}
#[test]
fn detects_null_byte_in_query() {
let mut req = make_req("GET", "/search", vec![], None);
req.query = Some("q=test%00.php".into());
let result = check_protocol(&req);
assert!(result.is_some());
assert!(result.unwrap().contains("null byte"));
}
#[test]
fn detects_control_char_in_header_value() {
let value = format!("normal\x01value");
let mut headers = HashMap::new();
headers.insert("X-Custom".into(), value);
let req = WafRequest {
client_ip: "127.0.0.1".parse().unwrap(),
method: "GET".into(),
path: "/".into(),
query: None,
headers,
body: None,
user_agent: None,
};
let result = check_protocol(&req);
assert!(result.is_some());
assert!(result.unwrap().contains("control character"));
}
#[test]
fn detects_control_char_in_header_name() {
let name = format!("X-Bad\x02Header");
let mut headers = HashMap::new();
headers.insert(name, "value".into());
let req = WafRequest {
client_ip: "127.0.0.1".parse().unwrap(),
method: "GET".into(),
path: "/".into(),
query: None,
headers,
body: None,
user_agent: None,
};
let result = check_protocol(&req);
assert!(result.is_some());
assert!(result.unwrap().contains("control character"));
}
#[test]
fn allows_tab_in_header_value() {
let value = "value\twith\ttabs".to_string();
let mut headers = HashMap::new();
headers.insert("X-Custom".into(), value);
let req = WafRequest {
client_ip: "127.0.0.1".parse().unwrap(),
method: "GET".into(),
path: "/".into(),
query: None,
headers,
body: None,
user_agent: None,
};
assert!(check_protocol(&req).is_none());
}
#[test]
fn detects_control_char_in_path() {
let path = "/api/\x01endpoint";
let req = make_req("GET", path, vec![], None);
let result = check_protocol(&req);
assert!(result.is_some());
assert!(result.unwrap().contains("control character"));
}
#[test]
fn detects_excessively_long_header_value() {
let long_value = "x".repeat(MAX_HEADER_VALUE_LEN + 1);
let req = make_req("GET", "/", vec![("X-Big", long_value.as_str())], None);
let result = check_protocol(&req);
assert!(result.is_some());
assert!(result.unwrap().contains("too long"));
}
#[test]
fn allows_header_at_limit() {
let value = "x".repeat(MAX_HEADER_VALUE_LEN);
let req = make_req("GET", "/", vec![("X-Big", value.as_str())], None);
assert!(check_protocol(&req).is_none());
}
#[test]
fn detects_post_body_without_content_length() {
let req = make_req("POST", "/api/data", vec![], Some("some body data"));
let result = check_protocol(&req);
assert!(result.is_some());
assert!(result.unwrap().contains("Content-Length"));
}
#[test]
fn detects_put_body_without_content_length() {
let req = make_req("PUT", "/api/data", vec![], Some("body data"));
let result = check_protocol(&req);
assert!(result.is_some());
}
#[test]
fn allows_post_body_with_content_length() {
let req = make_req(
"POST",
"/api/data",
vec![("Content-Length", "14")],
Some("some body data"),
);
assert!(check_protocol(&req).is_none());
}
#[test]
fn allows_post_body_with_transfer_encoding() {
let req = make_req(
"POST",
"/api/data",
vec![("Transfer-Encoding", "chunked")],
Some("some body data"),
);
assert!(check_protocol(&req).is_none());
}
#[test]
fn get_body_without_content_length_allowed() {
let req = make_req("GET", "/api/data", vec![], Some("unexpected body"));
assert!(check_protocol(&req).is_none());
}
#[test]
fn detects_request_smuggling_cl_te() {
let req = make_req(
"POST",
"/api/data",
vec![("Content-Length", "13"), ("Transfer-Encoding", "chunked")],
Some(r#"{"key":"val"}"#),
);
let result = check_protocol(&req);
assert!(result.is_some());
assert!(result.unwrap().contains("request smuggling"));
}
#[test]
fn detects_request_smuggling_case_insensitive() {
let req = make_req(
"POST",
"/api/data",
vec![("content-length", "13"), ("transfer-encoding", "chunked")],
Some(r#"{"key":"val"}"#),
);
let result = check_protocol(&req);
assert!(result.is_some());
assert!(result.unwrap().contains("request smuggling"));
}
#[test]
fn allows_cl_without_te() {
let req = make_req(
"POST",
"/api/data",
vec![("Content-Length", "13")],
Some(r#"{"key":"val"}"#),
);
assert!(check_protocol(&req).is_none());
}
#[test]
fn allows_te_without_cl() {
let req = make_req(
"POST",
"/api/data",
vec![("Transfer-Encoding", "chunked")],
Some(r#"{"key":"val"}"#),
);
assert!(check_protocol(&req).is_none());
}
#[test]
fn detects_non_numeric_content_length() {
let req = make_req(
"POST",
"/api/data",
vec![("Content-Length", "13abc")],
Some(r#"{"key":"val"}"#),
);
let result = check_protocol(&req);
assert!(result.is_some());
assert!(result.unwrap().contains("non-numeric"));
}
#[test]
fn detects_negative_content_length() {
let req = make_req(
"POST",
"/api/data",
vec![("Content-Length", "-1")],
Some(r#"{"key":"val"}"#),
);
let result = check_protocol(&req);
assert!(result.is_some());
assert!(result.unwrap().contains("non-numeric"));
}
#[test]
fn allows_valid_numeric_content_length() {
let req = make_req(
"POST",
"/api/data",
vec![("Content-Length", "13")],
Some(r#"{"key":"val"}"#),
);
assert!(check_protocol(&req).is_none());
}
#[test]
fn allows_content_length_with_whitespace() {
let req = make_req(
"POST",
"/api/data",
vec![("Content-Length", " 13 ")],
Some(r#"{"key":"val"}"#),
);
assert!(check_protocol(&req).is_none());
}
#[test]
fn has_illegal_control_chars_works() {
assert!(!has_illegal_control_chars("normal text"));
assert!(!has_illegal_control_chars("text\twith\ttabs"));
assert!(!has_illegal_control_chars("text\nwith\nnewlines"));
assert!(!has_illegal_control_chars("text\r\nwith\r\ncrlf"));
assert!(has_illegal_control_chars("text\x00with null"));
assert!(has_illegal_control_chars("text\x01with SOH"));
assert!(has_illegal_control_chars("text\x1Fwith US"));
}
#[test]
fn is_body_method_works() {
assert!(is_body_method("POST"));
assert!(is_body_method("PUT"));
assert!(is_body_method("PATCH"));
assert!(is_body_method("post")); assert!(!is_body_method("GET"));
assert!(!is_body_method("DELETE"));
assert!(!is_body_method("HEAD"));
assert!(!is_body_method("OPTIONS"));
}
}