Skip to main content

security_rust/protocol/
header_injection.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// 三条模式合成一条 alternation:干净输入上每条模式都要走到串尾,条数直接乘在单次
9// `find` 的开销上;合并后一次扫描扫完三条分支。各分支的完整文本未变,只多了 `|`。
10// `(?i)` 为三条分支所共有,提到最前——它的作用域是整条模式(含 `|` 之后的全部分支),
11// 与逐条编译时每条各自带 `(?i)` 等价。
12static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
13    Regex::new(
14        &[
15            // 只留响应专有头。`Content-Length` / `Content-Type` / `Transfer-Encoding` 是
16            // 请求头:正常请求的每个头前面都跟着一个 `\r\n`,"CRLF + 请求头名"与正常报文
17            // 逐字节同形,判不出来——曾让每个带 body 的 POST 连吃两个 High(单条 High 即
18            // 40 分,到拒收线)。这三个头的编码形态由下面两条 `%0d` 模式覆盖
19            // (`%0d%0aContent-Length: 0` 仍命中),裸 `\r\n` 形态只能放弃。
20            // 响应专有头出现在请求侧没有正常解释,单独出现即异常。
21            //
22            // 本检测器按**请求侧**设计:喂进来的若是抓包存下的**响应**原文,这一条必然命中
23            // (真实响应本来就带 `\r\nSet-Cookie:` / `\r\nLocation:`)。那不是待修的误报——
24            // 响应里哪些头是被注入的,只看响应本身分不出来,所以别把"扫响应也命中"当 bug
25            // 去放宽这条;要扫响应,该由调用方按来源决定是否采信。
26            r"(?i)\r\n\s*(?:Set-Cookie|Location|Refresh|Status|WWW-Authenticate):",
27            r"|%0[dD].*%0[aA]",
28            // 反序对 LF-CR。部分解析器容忍这个顺序,是响应拆分的绕过变体。
29            // 编码形态在正常文本里不出现,误报面与上面那条同量级。
30            // (原先由 crlf_injection 覆盖,该检测器因 LF-only 分支误报、其余形态
31            // 与本检测器重复而删除,其独有的这几条形态改由这里接手。)
32            r"|%0[aA].*%0[dD]",
33        ]
34        .concat(),
35    )
36    .unwrap()
37});
38
39pub struct HeaderInjectionDetector;
40
41impl Detector for HeaderInjectionDetector {
42    fn name(&self) -> &'static str {
43        "header_injection"
44    }
45
46    fn detect(&self, input: &str) -> Option<DetectionResult> {
47        regex_detect(
48            std::slice::from_ref(&*PATTERNS),
49            self.name(),
50            AttackCategory::Protocol,
51            Severity::High,
52            "HTTP header injection (CRLF) detected",
53            input,
54        )
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    fn assert_detected(input: &str) {
63        crate::test_helpers::assert_detected(
64            &HeaderInjectionDetector,
65            input,
66            AttackCategory::Protocol,
67            Severity::High,
68        );
69    }
70
71    fn assert_clean(input: &str) {
72        crate::test_helpers::assert_clean(&HeaderInjectionDetector, input);
73    }
74
75    #[test]
76    fn name_is_header_injection() {
77        assert_eq!(HeaderInjectionDetector.name(), "header_injection");
78    }
79
80    #[test]
81    fn detects_encoded_crlf_set_cookie() {
82        assert_detected("test%0d%0aSet-Cookie: evil=true");
83    }
84
85    #[test]
86    fn detects_encoded_crlf_location() {
87        assert_detected("redirect?url=%0D%0ALocation: /admin");
88    }
89
90    #[test]
91    fn detects_encoded_crlf_content_length() {
92        assert_detected("body%0d%0aContent-Length: 0");
93    }
94
95    #[test]
96    fn detects_raw_crlf_response_headers() {
97        // 裸 CRLF 只在响应专有头上是信号:请求头名(Content-Type 等)与正常报文同形,
98        // 放到这里会打死每个带 body 的正常 POST,改由 %0d%0a 形态覆盖(见下)。
99        assert_detected("foo\r\nSet-Cookie: evil=true");
100        assert_detected("bar\r\nLocation: http://evil.com");
101        assert_detected("foo\r\n  Set-Cookie: evil=true");
102    }
103
104    #[test]
105    fn detects_scattered_encoded_crlf() {
106        assert_detected("a%0dcontent%0a");
107    }
108
109    #[test]
110    fn detects_reverse_order_encoded_crlf() {
111        // 反序对 LF-CR:部分解析器容忍,是响应拆分的绕过变体。
112        // 这组形态原先由已删除的 crlf_injection 覆盖,现在归本检测器。
113        assert_detected("%0a%0d");
114        assert_detected("value%0a%0dSet-Cookie: session=evil");
115        assert_detected("a%0Acontent%0D");
116    }
117
118    #[test]
119    fn detects_injection_via_redirect_headers() {
120        // Refresh / Status / WWW-Authenticate 同样可用于响应拆分,
121        // 原先只在 crlf_injection 的裸 \r\n 名录里,现已并入本检测器
122        assert_detected("value\r\nRefresh: 0;url=http://evil.com");
123        assert_detected("value\r\nStatus: 302");
124        assert_detected("value\r\nWWW-Authenticate: Basic realm=x");
125    }
126
127    #[test]
128    fn rejects_lf_only_prose() {
129        // 这几条正是旧 crlf_injection 被删除的原因:只认 \r\n,不认裸 \n,
130        // 否则任何多行文本 / YAML / 日志粘贴都会被判 High。此测试防止回归。
131        assert_clean("Meeting at 3pm\nlocation: Room 5");
132        assert_clean("笔记\nset-cookie: abc");
133        assert_clean("配置:\nrefresh: 30\nlocation: /var/www");
134        assert_clean("note\ncontent-length: 0");
135    }
136
137    #[test]
138    fn rejects_clean_headers() {
139        assert_clean("Set-Cookie: evil=true");
140        assert_clean("Location: /index.php");
141        assert_clean("Content-Type: text/html");
142    }
143
144    #[test]
145    fn rejects_lone_encoded_chars() {
146        assert_clean("%0d");
147        assert_clean("%0a");
148        assert_clean("test%0dend");
149        assert_clean("%0d%0d");
150    }
151
152    /// 现实语料:正常报文的每个头前面都跟着 `\r\n`,不带编码形态就不能命中。
153    /// 缺了这一组,才会让一个正常 POST(`Content-Type` + `Content-Length`)
154    /// 连吃两个 High 被整条拒掉。
155    #[test]
156    fn rejects_well_formed_requests() {
157        for req in [
158            "GET /index.html HTTP/1.1\r\nHost: example.com\r\nUser-Agent: curl/8.5.0\r\nAccept: */*\r\n",
159            "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",
160            // 与正常报文的头行同形的裸 CRLF 形态:请求头名不再构成信号
161            "foo\r\nContent-Type: text/html",
162            "bar\r\nTransfer-Encoding: chunked",
163        ] {
164            assert_clean(req);
165        }
166        assert_clean("The report was generated with SYSTEM \"production\" settings.");
167        assert_clean("Please cc: ops@example.com on the reply.");
168        assert_clean("/search?tag=rust&tag=security&page=2");
169    }
170
171    #[test]
172    fn rejects_lf_only_newlines() {
173        assert_clean("foo\nContent-Type: text/html");
174        assert_clean("foo\nLocation: /x");
175    }
176
177    #[test]
178    fn rejects_near_misses() {
179        assert_clean("foo\r\nContent-Type text/html");
180    }
181
182    #[test]
183    fn rejects_empty_and_whitespace() {
184        assert_clean("");
185        assert_clean("   ");
186        assert_clean("\r\n");
187    }
188
189    #[test]
190    fn rejects_unicode_text() {
191        assert_clean("这是一段正常文本,无注入");
192    }
193}