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// (路由/缓存投毒、密码重置链接投毒)的落点。
13static STRONG_PATTERNS: LazyLock<Regex> =
14    LazyLock::new(|| Regex::new(r"(?i)(?:^|\r\n)\s*Host:[^\r\n]*\r\n\s*Host:").unwrap());
15
16// `X-Forwarded-Host` / `X-Original-URL` / `X-Rewrite-URL` 是攻击者覆盖主机判定、
17// 绕过前置访问控制的自定义头,但**出现**本身不是攻击——它们同样由代理自己加:
18//   * `X-Forwarded-Host`:Traefik 默认就加,nginx 的
19//     `proxy_set_header X-Forwarded-Host $host;` 也是常见配置;
20//   * `X-Original-URL` / `X-Rewrite-URL`:IIS ARR / Sitecore 这类重写代理会加,
21//     代理的用途是让上游知道改写前的原始路径。
22// 客户端伪造与代理添加在字节层面完全一致,正则分不开(要判只能判值),所以报 Low:
23// 仍然命中,但不单独触发拒绝,也不参与"多条 Medium 叠加成 High"。
24// `X-Forwarded-For` / `X-Forwarded-Proto` 连弱档都不进——它们几乎每个走代理的请求
25// 都有,判断 XFF 的伪造要看**值**(内网/回环地址、与对端地址矛盾)。
26// 三条模式合成一条 alternation:一次 `find` 扫完三条分支,而不是逐条 `find` 各扫一遍
27// (干净输入上每条模式都要走到串尾,条数直接乘在开销上)。
28// `(?i)` 为三条分支所共有,提到最前——它的作用域是整条模式(含 `|` 之后的全部分支),
29// 与逐条编译时每条各自带 `(?i)` 等价。
30static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
31    Regex::new(r"(?i)\r\n.*X-Forwarded-Host|\r\n.*X-Original-URL|\r\n.*X-Rewrite-URL").unwrap()
32});
33
34pub struct HostHeaderDetector;
35
36impl Detector for HostHeaderDetector {
37    fn name(&self) -> &'static str {
38        "host_header"
39    }
40
41    fn detect(&self, input: &str) -> Option<DetectionResult> {
42        regex_detect(
43            std::slice::from_ref(&*STRONG_PATTERNS),
44            self.name(),
45            AttackCategory::Protocol,
46            Severity::High,
47            "Host header attack detected",
48            input,
49        )
50        .or_else(|| {
51            regex_detect(
52                std::slice::from_ref(&*WEAK_PATTERNS),
53                self.name(),
54                AttackCategory::Protocol,
55                Severity::Low,
56                "Host override header present (weak signal)",
57                input,
58            )
59        })
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    fn assert_detected(input: &str) {
68        assert_hit_at(input, Severity::High);
69    }
70
71    fn assert_hit_at(input: &str, severity: Severity) {
72        crate::test_helpers::assert_detected(
73            &HostHeaderDetector,
74            input,
75            AttackCategory::Protocol,
76            severity,
77        );
78    }
79
80    fn assert_clean(input: &str) {
81        crate::test_helpers::assert_clean(&HostHeaderDetector, input);
82    }
83
84    #[test]
85    fn name_is_host_header() {
86        assert_eq!(HostHeaderDetector.name(), "host_header");
87    }
88
89    #[test]
90    fn detects_x_forwarded_host() {
91        // 弱档:伪造形态仍然命中,只是不再单独越线(见下面的代理链路样例)
92        assert_hit_at(
93            "Host: example.com\r\nX-Forwarded-Host: evil.com",
94            Severity::Low,
95        );
96    }
97
98    #[test]
99    fn detects_duplicate_host_line() {
100        // 单个 Host 头是正常请求,两个才是攻击信号(RFC 7230 §5.4:多个 Host → 400)
101        assert_detected("Host: example.com\r\nHost: evil.com");
102        assert_detected("GET / HTTP/1.1\r\nHost: a.com\r\nHost: evil.com");
103        // 折行(obs-fold)夹在中间也算相邻两行
104        assert_detected("GET / HTTP/1.1\r\nHost: a.com\r\n Host: evil.com");
105    }
106
107    #[test]
108    fn detects_x_original_url() {
109        assert_hit_at("Host: a.com\r\nX-Original-URL: /admin", Severity::Low);
110    }
111
112    #[test]
113    fn detects_x_rewrite_url() {
114        assert_hit_at("Host: a.com\r\nX-Rewrite-URL: /admin", Severity::Low);
115    }
116
117    #[test]
118    fn detects_x_forwarded_host_only() {
119        // 同族里只有 `X-Forwarded-Host` 是主机欺骗的载体。`X-Forwarded-For` /
120        // `X-Forwarded-Proto` 是代理自己加的,见下面的代理链路负样例。
121        assert_hit_at("Host: a.com\r\nX-Forwarded-Host: evil.com", Severity::Low);
122    }
123
124    #[test]
125    fn detects_mixed_case() {
126        assert_hit_at(
127            "host: example.com\r\nx-forwarded-host: evil.com",
128            Severity::Low,
129        );
130    }
131
132    /// 代理链路正样例:这些头是**代理自己加的**,请求正常。
133    /// 判 High 时每一条都是 40 分、直接拒收——误杀的是整条代理链路。
134    /// 弱档下仍然命中(不静默漏报),但单条 5 分越不过拒收线。
135    #[test]
136    fn proxied_requests_hit_only_the_weak_tier() {
137        for req in [
138            // Traefik 默认转发头;nginx 的 `proxy_set_header X-Forwarded-Host $host;`
139            "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",
140            // 重写代理(IIS ARR / Sitecore):让上游知道改写前的原始路径
141            "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",
142        ] {
143            assert_hit_at(req, Severity::Low);
144        }
145    }
146
147    #[test]
148    fn rejects_single_header_lines() {
149        assert_clean("Host: example.com");
150        assert_clean("X-Forwarded-Host: evil.com");
151        assert_clean("Host: example.com\r\nAccept: */*");
152    }
153
154    /// 现实语料:正常请求只带一个 Host 头。缺了这一组,才会让每个正常请求
155    /// (`GET /index.html HTTP/1.1\r\nHost: example.com\r\n…`)都吃一个 High。
156    /// 带 `X-Forwarded-Host` / `X-Original-URL` / `X-Rewrite-URL` 的代理请求
157    /// 见 `proxied_requests_hit_only_the_weak_tier`。
158    #[test]
159    fn rejects_well_formed_requests() {
160        for req in [
161            "GET /index.html HTTP/1.1\r\nHost: example.com\r\nUser-Agent: curl/8.5.0\r\nAccept: */*\r\n",
162            "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",
163            // 两段请求连排的日志粘贴:各自带一个 Host,不是重复头
164            "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",
165            // 代理链路:反代 / 负载均衡自己加的头。几乎每个走代理的正常请求都带,
166            // 判定的只能是值(内网/回环、与对端地址矛盾),不是头在不在。
167            "GET /index.html HTTP/1.1\r\nHost: example.com\r\nX-Forwarded-For: 203.0.113.7\r\n\r\n",
168            "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",
169        ] {
170            assert_clean(req);
171        }
172        // 普通文档 / 散文 / 查询串
173        assert_clean("The report was generated with SYSTEM \"production\" settings.");
174        assert_clean("Please cc: ops@example.com on the reply.");
175        assert_clean("/search?tag=rust&tag=security&page=2");
176    }
177
178    #[test]
179    fn rejects_lf_only_newlines() {
180        assert_clean("Host: example.com\nX-Forwarded-Host: evil.com");
181    }
182
183    #[test]
184    fn rejects_empty_and_whitespace() {
185        assert_clean("");
186        assert_clean("   ");
187    }
188
189    #[test]
190    fn rejects_unicode_text() {
191        assert_clean("主机头示例,无攻击");
192    }
193}