security_rust/protocol/
host_header.rs1use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
7
8static STRONG_PATTERNS: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r"(?i)(?:^|\r\n)[ \t]*Host:[^\r\n]*\r\n[ \t]*Host:").unwrap());
19
20static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
35 Regex::new(r"(?i)\r\n.*X-Forwarded-Host|\r\n.*X-Original-URL|\r\n.*X-Rewrite-URL").unwrap()
36});
37
38pub struct HostHeaderDetector;
39
40impl Detector for HostHeaderDetector {
41 fn name(&self) -> &'static str {
42 "host_header"
43 }
44
45 fn detect(&self, input: &str) -> Option<DetectionResult> {
46 regex_detect(
47 std::slice::from_ref(&*STRONG_PATTERNS),
48 self.name(),
49 AttackCategory::Protocol,
50 Severity::High,
51 "Host header attack detected",
52 input,
53 )
54 .or_else(|| {
55 regex_detect(
56 std::slice::from_ref(&*WEAK_PATTERNS),
57 self.name(),
58 AttackCategory::Protocol,
59 Severity::Low,
60 "Host override header present (weak signal)",
61 input,
62 )
63 })
64 }
65}
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70
71 fn assert_detected(input: &str) {
72 assert_hit_at(input, Severity::High);
73 }
74
75 fn assert_hit_at(input: &str, severity: Severity) {
76 crate::test_helpers::assert_detected(
77 &HostHeaderDetector,
78 input,
79 AttackCategory::Protocol,
80 severity,
81 );
82 }
83
84 fn assert_clean(input: &str) {
85 crate::test_helpers::assert_clean(&HostHeaderDetector, input);
86 }
87
88 #[test]
89 fn name_is_host_header() {
90 assert_eq!(HostHeaderDetector.name(), "host_header");
91 }
92
93 #[test]
94 fn detects_x_forwarded_host() {
95 assert_hit_at(
97 "Host: example.com\r\nX-Forwarded-Host: evil.com",
98 Severity::Low,
99 );
100 }
101
102 #[test]
103 fn detects_duplicate_host_line() {
104 assert_detected("Host: example.com\r\nHost: evil.com");
106 assert_detected("GET / HTTP/1.1\r\nHost: a.com\r\nHost: evil.com");
107 assert_detected("GET / HTTP/1.1\r\nHost: a.com\r\n Host: evil.com");
109 assert_detected("GET / HTTP/1.1\r\nHost: a.com\r\n\tHost: evil.com");
110 }
111
112 #[test]
116 fn host_after_blank_line_is_not_a_duplicate() {
117 assert_clean("POST /x HTTP/1.1\r\nHost: a.com\r\n\r\nHost: b.com\r\n");
118 assert_clean("GET / HTTP/1.1\r\nHost: a.com\r\n\r\nHost: b.com\r\n");
119 }
120
121 #[test]
122 fn detects_x_original_url() {
123 assert_hit_at("Host: a.com\r\nX-Original-URL: /admin", Severity::Low);
124 }
125
126 #[test]
127 fn detects_x_rewrite_url() {
128 assert_hit_at("Host: a.com\r\nX-Rewrite-URL: /admin", Severity::Low);
129 }
130
131 #[test]
132 fn detects_x_forwarded_host_only() {
133 assert_hit_at("Host: a.com\r\nX-Forwarded-Host: evil.com", Severity::Low);
136 }
137
138 #[test]
139 fn detects_mixed_case() {
140 assert_hit_at(
141 "host: example.com\r\nx-forwarded-host: evil.com",
142 Severity::Low,
143 );
144 }
145
146 #[test]
150 fn proxied_requests_hit_only_the_weak_tier() {
151 for req in [
152 "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",
154 "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",
156 ] {
157 assert_hit_at(req, Severity::Low);
158 }
159 }
160
161 #[test]
162 fn rejects_single_header_lines() {
163 assert_clean("Host: example.com");
164 assert_clean("X-Forwarded-Host: evil.com");
165 assert_clean("Host: example.com\r\nAccept: */*");
166 }
167
168 #[test]
173 fn rejects_well_formed_requests() {
174 for req in [
175 "GET /index.html HTTP/1.1\r\nHost: example.com\r\nUser-Agent: curl/8.5.0\r\nAccept: */*\r\n",
176 "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",
177 "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",
181 "GET /index.html HTTP/1.1\r\nHost: example.com\r\nX-Forwarded-For: 203.0.113.7\r\n\r\n",
184 "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",
185 ] {
186 assert_clean(req);
187 }
188 assert_clean("The report was generated with SYSTEM \"production\" settings.");
190 assert_clean("Please cc: ops@example.com on the reply.");
191 assert_clean("/search?tag=rust&tag=security&page=2");
192 }
193
194 #[test]
195 fn rejects_lf_only_newlines() {
196 assert_clean("Host: example.com\nX-Forwarded-Host: evil.com");
197 }
198
199 #[test]
200 fn rejects_empty_and_whitespace() {
201 assert_clean("");
202 assert_clean(" ");
203 }
204
205 #[test]
206 fn rejects_unicode_text() {
207 assert_clean("主机头示例,无攻击");
208 }
209}