use regex::Regex;
use std::sync::LazyLock;
use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
static STRONG_PATTERNS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)(?:^|\r\n)[ \t]*Host:[^\r\n]*\r\n[ \t]*Host:").unwrap());
static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\r\n.*X-Forwarded-Host|\r\n.*X-Original-URL|\r\n.*X-Rewrite-URL").unwrap()
});
pub struct HostHeaderDetector;
impl Detector for HostHeaderDetector {
fn name(&self) -> &'static str {
"host_header"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
regex_detect(
std::slice::from_ref(&*STRONG_PATTERNS),
self.name(),
AttackCategory::Protocol,
Severity::High,
"Host header attack detected",
input,
)
.or_else(|| {
regex_detect(
std::slice::from_ref(&*WEAK_PATTERNS),
self.name(),
AttackCategory::Protocol,
Severity::Low,
"Host override header present (weak signal)",
input,
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_detected(input: &str) {
assert_hit_at(input, Severity::High);
}
fn assert_hit_at(input: &str, severity: Severity) {
crate::test_helpers::assert_detected(
&HostHeaderDetector,
input,
AttackCategory::Protocol,
severity,
);
}
fn assert_clean(input: &str) {
crate::test_helpers::assert_clean(&HostHeaderDetector, input);
}
#[test]
fn name_is_host_header() {
assert_eq!(HostHeaderDetector.name(), "host_header");
}
#[test]
fn detects_x_forwarded_host() {
assert_hit_at(
"Host: example.com\r\nX-Forwarded-Host: evil.com",
Severity::Low,
);
}
#[test]
fn detects_duplicate_host_line() {
assert_detected("Host: example.com\r\nHost: evil.com");
assert_detected("GET / HTTP/1.1\r\nHost: a.com\r\nHost: evil.com");
assert_detected("GET / HTTP/1.1\r\nHost: a.com\r\n Host: evil.com");
assert_detected("GET / HTTP/1.1\r\nHost: a.com\r\n\tHost: evil.com");
}
#[test]
fn host_after_blank_line_is_not_a_duplicate() {
assert_clean("POST /x HTTP/1.1\r\nHost: a.com\r\n\r\nHost: b.com\r\n");
assert_clean("GET / HTTP/1.1\r\nHost: a.com\r\n\r\nHost: b.com\r\n");
}
#[test]
fn detects_x_original_url() {
assert_hit_at("Host: a.com\r\nX-Original-URL: /admin", Severity::Low);
}
#[test]
fn detects_x_rewrite_url() {
assert_hit_at("Host: a.com\r\nX-Rewrite-URL: /admin", Severity::Low);
}
#[test]
fn detects_x_forwarded_host_only() {
assert_hit_at("Host: a.com\r\nX-Forwarded-Host: evil.com", Severity::Low);
}
#[test]
fn detects_mixed_case() {
assert_hit_at(
"host: example.com\r\nx-forwarded-host: evil.com",
Severity::Low,
);
}
#[test]
fn proxied_requests_hit_only_the_weak_tier() {
for req in [
"GET /dashboard HTTP/1.1\r\nHost: app.example.com\r\nX-Forwarded-Host: app.example.com\r\nX-Forwarded-Proto: https\r\nX-Forwarded-For: 203.0.113.9\r\n\r\n",
"GET /private HTTP/1.1\r\nHost: app.example.com\r\nX-Original-URL: /index.html\r\nX-Forwarded-For: 203.0.113.9\r\n\r\n",
] {
assert_hit_at(req, Severity::Low);
}
}
#[test]
fn rejects_single_header_lines() {
assert_clean("Host: example.com");
assert_clean("X-Forwarded-Host: evil.com");
assert_clean("Host: example.com\r\nAccept: */*");
}
#[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\n\r\n",
"GET /a HTTP/1.1\r\nHost: example.com\r\n\r\nGET /b HTTP/1.1\r\nHost: example.com\r\n\r\n",
"GET /index.html HTTP/1.1\r\nHost: example.com\r\nX-Forwarded-For: 203.0.113.7\r\n\r\n",
"GET /index.html HTTP/1.1\r\nHost: example.com\r\nX-Forwarded-For: 203.0.113.7, 10.0.0.1\r\nX-Forwarded-Proto: https\r\nX-Real-IP: 203.0.113.7\r\n\r\n",
] {
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("Host: example.com\nX-Forwarded-Host: evil.com");
}
#[test]
fn rejects_empty_and_whitespace() {
assert_clean("");
assert_clean(" ");
}
#[test]
fn rejects_unicode_text() {
assert_clean("主机头示例,无攻击");
}
}