Skip to main content

security_rust/protocol/
host_header.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
7
8// 正常 HTTP/1.1 请求必然带一个 Host 头,"出现 Host 头"零信息量,曾让每个正常请求
9// (`GET /index.html HTTP/1.1\r\nHost: example.com\r\n…`)都吃一个 High —— 单条
10// High 就是 40 分,到拒收线。真正的信号是**两个** Host 头:RFC 7230 §5.4 要求含
11// 多个 Host 的请求一律回 400,两个解析器取值不一致,这正是主机头攻击
12// (路由/缓存投毒、密码重置链接投毒)的落点。
13// 两个 `[ \t]*` 而非 `\s*`:折行(obs-fold,RFC 7230 §3.2.4)只允许 CRLF 后跟
14// SP/HTAB,而 `\s` 还能吞掉 CRLF —— 那正是空行,头块的终止符。`Host: a\r\n\r\nHost: b`
15// 里第二个 Host 在头块**之外**(正文,或第二个请求行被截掉的粘贴内容),
16// 用 `\s*` 会把它当重复头,一粘贴就是 High(40) 到拒收线。
17static STRONG_PATTERNS: LazyLock<Regex> =
18    LazyLock::new(|| Regex::new(r"(?i)(?:^|\r\n)[ \t]*Host:[^\r\n]*\r\n[ \t]*Host:").unwrap());
19
20// `X-Forwarded-Host` / `X-Original-URL` / `X-Rewrite-URL` 是攻击者覆盖主机判定、
21// 绕过前置访问控制的自定义头,但**出现**本身不是攻击——它们同样由代理自己加:
22//   * `X-Forwarded-Host`:Traefik 默认就加,nginx 的
23//     `proxy_set_header X-Forwarded-Host $host;` 也是常见配置;
24//   * `X-Original-URL` / `X-Rewrite-URL`:IIS ARR / Sitecore 这类重写代理会加,
25//     代理的用途是让上游知道改写前的原始路径。
26// 客户端伪造与代理添加在字节层面完全一致,正则分不开(要判只能判值),所以报 Low:
27// 仍然命中,但不单独触发拒绝,也不参与"多条 Medium 叠加成 High"。
28// `X-Forwarded-For` / `X-Forwarded-Proto` 连弱档都不进——它们几乎每个走代理的请求
29// 都有,判断 XFF 的伪造要看**值**(内网/回环地址、与对端地址矛盾)。
30// 三条模式合成一条 alternation:一次 `find` 扫完三条分支,而不是逐条 `find` 各扫一遍
31// (干净输入上每条模式都要走到串尾,条数直接乘在开销上)。
32// `(?i)` 为三条分支所共有,提到最前——它的作用域是整条模式(含 `|` 之后的全部分支),
33// 与逐条编译时每条各自带 `(?i)` 等价。
34static 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        // 弱档:伪造形态仍然命中,只是不再单独越线(见下面的代理链路样例)
96        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        // 单个 Host 头是正常请求,两个才是攻击信号(RFC 7230 §5.4:多个 Host → 400)
105        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        // 折行(obs-fold)夹在中间也算相邻两行 —— SP 与 HTAB 都算
108        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    /// 头块终止符(CRLFCRLF)不能当 obs-fold 跨过去:`Host: a.com\r\n\r\nHost: b.com`
113    /// 里第二个 `Host:` 属于**正文或下一段内容**,不是重复头。旧模式的第二个 `\s*`
114    /// 正好能吞掉空的 `\r\n`,把这种粘贴内容判成 High(40) —— 到拒收线。
115    #[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        // 同族里只有 `X-Forwarded-Host` 是主机欺骗的载体。`X-Forwarded-For` /
134        // `X-Forwarded-Proto` 是代理自己加的,见下面的代理链路负样例。
135        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    /// 代理链路正样例:这些头是**代理自己加的**,请求正常。
147    /// 判 High 时每一条都是 40 分、直接拒收——误杀的是整条代理链路。
148    /// 弱档下仍然命中(不静默漏报),但单条 5 分越不过拒收线。
149    #[test]
150    fn proxied_requests_hit_only_the_weak_tier() {
151        for req in [
152            // Traefik 默认转发头;nginx 的 `proxy_set_header X-Forwarded-Host $host;`
153            "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            // 重写代理(IIS ARR / Sitecore):让上游知道改写前的原始路径
155            "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    /// 现实语料:正常请求只带一个 Host 头。缺了这一组,才会让每个正常请求
169    /// (`GET /index.html HTTP/1.1\r\nHost: example.com\r\n…`)都吃一个 High。
170    /// 带 `X-Forwarded-Host` / `X-Original-URL` / `X-Rewrite-URL` 的代理请求
171    /// 见 `proxied_requests_hit_only_the_weak_tier`。
172    #[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            // 两段请求连排的日志粘贴:各自带一个 Host,不是重复头。这条干净**不依赖**
178            // 第二个请求行 —— 头块终止符(空行)之后的 Host 本来就不是头
179            // (见 `host_after_blank_line_is_not_a_duplicate`)。
180            "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            // 代理链路:反代 / 负载均衡自己加的头。几乎每个走代理的正常请求都带,
182            // 判定的只能是值(内网/回环、与对端地址矛盾),不是头在不在。
183            "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        // 普通文档 / 散文 / 查询串
189        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}