use regex::Regex;
use std::sync::LazyLock;
use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
&[
r"(?i)\r\n\s*(?:Set-Cookie|Location|Refresh|Status|WWW-Authenticate):",
r"|%0[dD].*%0[aA]",
r"|%0[aA].*%0[dD]",
]
.concat(),
)
.unwrap()
});
pub struct HeaderInjectionDetector;
impl Detector for HeaderInjectionDetector {
fn name(&self) -> &'static str {
"header_injection"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
regex_detect(
std::slice::from_ref(&*PATTERNS),
self.name(),
AttackCategory::Protocol,
Severity::High,
"HTTP header injection (CRLF) detected",
input,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_detected(input: &str) {
crate::test_helpers::assert_detected(
&HeaderInjectionDetector,
input,
AttackCategory::Protocol,
Severity::High,
);
}
fn assert_clean(input: &str) {
crate::test_helpers::assert_clean(&HeaderInjectionDetector, input);
}
#[test]
fn name_is_header_injection() {
assert_eq!(HeaderInjectionDetector.name(), "header_injection");
}
#[test]
fn detects_encoded_crlf_set_cookie() {
assert_detected("test%0d%0aSet-Cookie: evil=true");
}
#[test]
fn detects_encoded_crlf_location() {
assert_detected("redirect?url=%0D%0ALocation: /admin");
}
#[test]
fn detects_encoded_crlf_content_length() {
assert_detected("body%0d%0aContent-Length: 0");
}
#[test]
fn detects_raw_crlf_response_headers() {
assert_detected("foo\r\nSet-Cookie: evil=true");
assert_detected("bar\r\nLocation: http://evil.com");
assert_detected("foo\r\n Set-Cookie: evil=true");
}
#[test]
fn detects_scattered_encoded_crlf() {
assert_detected("a%0dcontent%0a");
}
#[test]
fn detects_reverse_order_encoded_crlf() {
assert_detected("%0a%0d");
assert_detected("value%0a%0dSet-Cookie: session=evil");
assert_detected("a%0Acontent%0D");
}
#[test]
fn detects_injection_via_redirect_headers() {
assert_detected("value\r\nRefresh: 0;url=http://evil.com");
assert_detected("value\r\nStatus: 302");
assert_detected("value\r\nWWW-Authenticate: Basic realm=x");
}
#[test]
fn rejects_lf_only_prose() {
assert_clean("Meeting at 3pm\nlocation: Room 5");
assert_clean("笔记\nset-cookie: abc");
assert_clean("配置:\nrefresh: 30\nlocation: /var/www");
assert_clean("note\ncontent-length: 0");
}
#[test]
fn rejects_clean_headers() {
assert_clean("Set-Cookie: evil=true");
assert_clean("Location: /index.php");
assert_clean("Content-Type: text/html");
}
#[test]
fn rejects_lone_encoded_chars() {
assert_clean("%0d");
assert_clean("%0a");
assert_clean("test%0dend");
assert_clean("%0d%0d");
}
#[test]
fn rejects_well_formed_requests() {
for req in [
"GET /index.html HTTP/1.1\r\nHost: example.com\r\nUser-Agent: curl/8.5.0\r\nAccept: */*\r\n",
"POST /upload HTTP/1.1\r\nHost: upload.example.com\r\nContent-Type: multipart/form-data; boundary=----x\r\nContent-Length: 1234\r\nTransfer-Encoding: chunked\r\n\r\n",
"foo\r\nContent-Type: text/html",
"bar\r\nTransfer-Encoding: chunked",
] {
assert_clean(req);
}
assert_clean("The report was generated with SYSTEM \"production\" settings.");
assert_clean("Please cc: ops@example.com on the reply.");
assert_clean("/search?tag=rust&tag=security&page=2");
}
#[test]
fn rejects_lf_only_newlines() {
assert_clean("foo\nContent-Type: text/html");
assert_clean("foo\nLocation: /x");
}
#[test]
fn rejects_near_misses() {
assert_clean("foo\r\nContent-Type text/html");
}
#[test]
fn rejects_empty_and_whitespace() {
assert_clean("");
assert_clean(" ");
assert_clean("\r\n");
}
#[test]
fn rejects_unicode_text() {
assert_clean("这是一段正常文本,无注入");
}
}