Skip to main content

security_rust/protocol/
request_smuggling.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// 走私要的是"两个解析器读法不同",所以判定条件必须有第二个信号:
9//
10//   1. 重复的 Transfer-Encoding 头 —— 取第一个还是最后一个,解析器之间不一致。
11//      `\s*` 让头名与冒号之间夹空白的形态也进重复判定。
12//   2. 头名与冒号之间夹空白 —— RFC 7230 §3.2.4 明确禁止,正常客户端不会发,
13//      宽松解析器却照收并当作 chunked,这正是绕过前面这条的手法。
14//
15// 单独的 `Transfer-Encoding: chunked` 不是信号:它是带 chunked 请求体的正常请求的
16// 常规写法。原第二条正则 (`Transfer-Encoding:[\s]*chunked`) 把它判成 High,
17// 于是一个正常的 POST 光靠这一条加上 host_header / header_injection 就凑满
18// 3 个 High(score 120)被整条拒掉。
19// 两条模式合成一条 alternation:干净输入上每条模式都要走到串尾,模式条数直接乘在
20// 单次 `find` 的开销上;合并后一次扫描扫完两条分支。
21// `(?i)` 为两条分支所共有,提到最前——它的作用域是整条模式(含 `|` 之后的全部分支),
22// 与逐条编译时每条各自带 `(?i)` 等价。
23static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
24    Regex::new(r"(?i)Transfer-Encoding\s*:.*\r\n.*Transfer-Encoding\s*:|Transfer-Encoding\s+:")
25        .unwrap()
26});
27
28pub struct RequestSmugglingDetector;
29
30impl Detector for RequestSmugglingDetector {
31    fn name(&self) -> &'static str {
32        "request_smuggling"
33    }
34
35    fn detect(&self, input: &str) -> Option<DetectionResult> {
36        regex_detect(
37            std::slice::from_ref(&*PATTERNS),
38            self.name(),
39            AttackCategory::Protocol,
40            Severity::High,
41            "HTTP request smuggling detected",
42            input,
43        )
44    }
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    fn assert_detected(input: &str) {
52        crate::test_helpers::assert_detected(
53            &RequestSmugglingDetector,
54            input,
55            AttackCategory::Protocol,
56            Severity::High,
57        );
58    }
59
60    fn assert_clean(input: &str) {
61        crate::test_helpers::assert_clean(&RequestSmugglingDetector, input);
62    }
63
64    #[test]
65    fn name_is_request_smuggling() {
66        assert_eq!(RequestSmugglingDetector.name(), "request_smuggling");
67    }
68
69    #[test]
70    fn detects_duplicate_transfer_encoding() {
71        assert_detected("Transfer-Encoding: chunked\r\nTransfer-Encoding: identity");
72        // 头名与冒号之间夹空白:两个解析器取不同分支,同样要报(旧正则漏报)
73        assert_detected("Transfer-Encoding : chunked\r\nTransfer-Encoding : identity");
74        assert_detected("Transfer-Encoding : chunked\r\nTransfer-Encoding: identity");
75    }
76
77    #[test]
78    fn detects_obfuscated_transfer_encoding() {
79        // RFC 7230 §3.2.4 禁止头名与冒号之间有空白。正常客户端不发这种头,
80        // 宽松解析器却当作 chunked —— 单独出现即判定,与大小写无关。
81        assert_detected("Transfer-Encoding : chunked");
82        assert_detected("transfer-encoding : CHUNKED");
83        assert_detected("Transfer-Encoding\t: chunked");
84    }
85
86    #[test]
87    fn rejects_well_formed_transfer_encoding() {
88        // 带 chunked 请求体的正常请求就是这样发的。空格的多少、大小写都不构成信号,
89        // 第二个信号(重复头 / 头名夹空白)才构成。整条正常请求必须干净。
90        for input in [
91            "Transfer-Encoding: chunked",
92            "Transfer-Encoding:chunked",
93            "Transfer-Encoding:\tchunked",
94            "transfer-encoding: CHUNKED",
95            "POST /upload HTTP/1.1\r\nHost: example.com\r\nContent-Type: multipart/form-data; boundary=----x\r\nContent-Length: 1234\r\nTransfer-Encoding: chunked\r\n\r\n",
96        ] {
97            assert_clean(input);
98        }
99    }
100
101    #[test]
102    fn rejects_benign_headers() {
103        assert_clean("Content-Length: 5\r\nContent-Length: 10");
104        assert_clean("Transfer-Encoding: gzip");
105        assert_clean("Connection: keep-alive");
106    }
107
108    #[test]
109    fn rejects_missing_colon() {
110        assert_clean("Transfer-Encoding chunked");
111    }
112
113    #[test]
114    fn rejects_near_misses() {
115        assert_clean("Transfer-Encoding: chuncked");
116    }
117
118    /// 现实语料:不含 TE 头的正常文本不该有任何命中
119    #[test]
120    fn rejects_real_world_text() {
121        assert_clean("The report was generated with SYSTEM \"production\" settings.");
122        assert_clean("Please cc: ops@example.com on the reply.");
123        assert_clean("/search?tag=rust&tag=security&page=2");
124        assert_clean(concat!(
125            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
126            "<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" ",
127            "\"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
128            "<svg xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M4 4h16v16H4z\"/></svg>"
129        ));
130    }
131
132    #[test]
133    fn rejects_empty_and_whitespace() {
134        assert_clean("");
135        assert_clean("   ");
136    }
137
138    #[test]
139    fn rejects_unicode_text() {
140        assert_clean("普通请求体,无攻击特征");
141    }
142}