Skip to main content

security_rust/protocol/
hpp.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7// 分隔符混用:一层解析器把 `;` 当分隔符、另一层不当,于是 `?a=1&b=2;c=3`
8// 在第一层是 3 个参数、第二层只有 2 个(b 的值变成 "2;c=3")。纯 `;` 分隔
9// 而全程无 `&` 不算污染——所有解析器结论一致,报了就是误杀。
10//
11// 两条约束防止把正常串打进来:
12//   1. 值里排除 `?`:`/app;jsessionid=abc?p=1&q=2` 的 `?` 之后才是查询串,
13//      前面的 `;jsessionid=` 是路径上的矩阵参数,不是分隔符混用。
14//   2. 混用的 `;k=v` 必须是一个完整参数(后面紧跟 `&` 或到串尾)。后面还接着
15//      空格/别的内容说明这不是查询串——`GET /a?x=1&y=2;z=3 HTTP/1.1` 是请求行,
16//      `;z=3` 后面跟着的是协议版本。
17// 两条同档模式合成一条 alternation:一次 `find` 扫完两条分支,而不是逐条 `find` 各扫
18// 一遍。`(?i)` 为两条分支所共有,提到最前——它的作用域是整条模式(含 `|` 之后的分支),
19// 与逐条编译时每条各自带 `(?i)` 等价。分支文本未变,条数从 2 降到 1;`detect()` 里的
20// 前置判断(无分隔符直接返回、`;jsessionid=` 走弱档)与下面的重复 key 手动判定都不受影响。
21static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
22    Regex::new(
23        &[
24            r"(?i)&[a-z0-9_%.\-]{1,64}=[^&;\s?]{0,200};[a-z0-9_%.\-]{1,64}=[^&;\s?]{0,200}(?:&|$)",
25            r"|;[a-z0-9_%.\-]{1,64}=[^&;\s?]{0,200}&[a-z0-9_%.\-]{1,64}=[^&;\s?]{0,200}(?:&|$)",
26        ]
27        .concat(),
28    )
29    .unwrap()
30});
31
32/// `;jsessionid=` 是 Java 容器的矩阵参数(URL 重写会话 ID)。出现它就说明这一层
33/// 的 `;` 是路径分隔符而非参数分隔符——只保留重复 key 判定,混用形态不再报。
34fn has_matrix_session(input: &str) -> bool {
35    const NEEDLE: &[u8] = b";jsessionid=";
36    input
37        .as_bytes()
38        .windows(NEEDLE.len())
39        .any(|w| w.eq_ignore_ascii_case(NEEDLE))
40}
41
42/// 同一 key 重复出现的位置。`regex` crate 无反向引用,纯正则表达不了
43/// "两个 key 相等"——只能退化成"任意两个参数",那 `a=1&b=2` 也会报。
44/// 所以这里显式拆参比对。
45///
46/// 只按 `&` 拆:`;` 不是查询串的参数分隔符(见文件头注释),按它拆会把
47/// `PATH=/usr/bin; PATH=/opt/bin` 这类栈/配置粘贴判成重复 key。
48fn duplicate_key(input: &str) -> Option<(usize, usize)> {
49    // ponytail: 只扫前 64 个参数,防超长输入退化成 O(n²),真实请求够用
50    const MAX_TOKENS: usize = 64;
51
52    let mut keys: Vec<&str> = Vec::new();
53    let mut token_start = 0usize;
54
55    for (i, token) in input.split('&').enumerate() {
56        let start = token_start;
57        token_start = start + token.len() + 1; // 分隔符恒为单字节 ASCII
58        if i >= MAX_TOKENS {
59            break;
60        }
61        let Some(eq) = token.find('=') else {
62            continue;
63        };
64        // `?id=1&id=2`、`/p?id=1&id=2`:首个 key 挂着查询串标记或路径,先剥掉
65        let raw = &token[..eq];
66        let key_head = raw.rfind(['?', '#']).map_or(0, |p| p + 1);
67        let tail = &raw[key_head..];
68        let key = tail.trim();
69        if key.is_empty() {
70            continue;
71        }
72        if keys.contains(&key) {
73            let lead = tail.len() - tail.trim_start().len();
74            let at = start + key_head + lead;
75            return Some((at, key.len()));
76        }
77        keys.push(key);
78    }
79    None
80}
81
82pub struct HttpParameterPollutionDetector;
83
84impl Detector for HttpParameterPollutionDetector {
85    fn name(&self) -> &'static str {
86        "hpp"
87    }
88
89    fn detect(&self, input: &str) -> Option<DetectionResult> {
90        // 两个分隔符一个都没有时不可能有参数:重复 key 无从谈起,下面两条正则也都
91        // 要求 `&`。超长无分隔符文本(JSON / 正文)实测快 13.8~18.7 倍。
92        if !input.contains('&') && !input.contains(';') {
93            return None;
94        }
95        // 强档:分隔符混用。正常查询串不产生这个形状(真实语料 14 条全干净)。
96        // `;jsessionid=` 是 Java 容器的矩阵参数,此时这一层的 `;` 是路径分隔符而非
97        // 参数分隔符,混用不成立 —— 只保留下面的重复 key 判定。
98        if !has_matrix_session(input)
99            && let Some(hit) = regex_detect(
100                std::slice::from_ref(&*PATTERNS),
101                self.name(),
102                AttackCategory::Protocol,
103                Severity::Medium,
104                "HTTP parameter pollution detected",
105                input,
106            )
107        {
108            return Some(hit);
109        }
110        // 弱档:同一 key 出现两次。攻击(`?id=1&id=2`,两层解析器取值不一致)与正常
111        // 多值参数(`?tag=rust&tag=web`、`?ids[]=1&ids[]=2`)在字节层面完全一致,
112        // 正则分不开 —— 只报 Low:仍然命中,但不单独越线,也不参与
113        // "多条 Medium 叠加成 High"。
114        duplicate_key(input).map(|(offset, len)| DetectionResult {
115            attack_type: self.name().to_string(),
116            category: AttackCategory::Protocol,
117            severity: Severity::Low,
118            matched_pattern: input[offset..offset + len].to_string(),
119            offset,
120            message: "HTTP parameter pollution detected".into(),
121        })
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::test_helpers::{assert_clean, assert_detected};
129
130    fn det() -> HttpParameterPollutionDetector {
131        HttpParameterPollutionDetector
132    }
133
134    fn assert_hit(input: &str) {
135        assert_hit_at(input, Severity::Medium);
136    }
137
138    fn assert_hit_at(input: &str, severity: Severity) {
139        assert_detected(&det(), input, AttackCategory::Protocol, severity);
140    }
141
142    #[test]
143    fn name_is_hpp() {
144        assert_eq!(det().name(), "hpp");
145    }
146
147    /// 重复 key 是弱档:攻击形态(`?id=1&id=2`)与正常多值参数
148    /// (`?tag=rust&tag=web`)逐字节同形,只报 Low —— 仍然命中,不静默漏报。
149    #[test]
150    fn detects_repeated_key() {
151        for input in [
152            "a=1&a=2",
153            "?id=1&id=2",
154            "id=1&id=2&id=3",
155            "user=admin&user=guest",
156            "a=1&b=2&a=3",
157        ] {
158            assert_hit_at(input, Severity::Low);
159        }
160    }
161
162    #[test]
163    fn detects_repeated_array_key() {
164        for input in [
165            "a[]=1&a[]=2",
166            "ids[]=1&ids[]=2&ids[]=3",
167            "?filter[role]=admin&filter[role]=user",
168        ] {
169            assert_hit_at(input, Severity::Low);
170        }
171    }
172
173    /// 正常的多值参数:多选框表单、数组参数、文档里的查询串。它们与重复 key 攻击
174    /// 同形,所以是弱档命中(5 分)而不是干净 —— 但不能像 Medium 那样参与叠加把
175    /// 普通文档推到拒收线(真实语料 `SUPPORT_TICKET` 记的正是这条叠加路径)。
176    #[test]
177    fn multi_value_params_hit_only_the_weak_tier() {
178        for input in [
179            "GET /search?tag=rust&tag=web&page=2 HTTP/1.1\r\nHost: example.com\r\n\r\n",
180            "?ids[]=1&ids[]=2&ids[]=3",
181            "https://example.com/log?a=1&a=2",
182        ] {
183            assert_hit_at(input, Severity::Low);
184        }
185    }
186
187    #[test]
188    fn detects_mixed_delimiters() {
189        for input in [
190            "a=1&b=2;c=3",
191            "a=1;b=2&c=3",
192            "?x=1&y=2;z=3",
193            "a=1&b=2;c=3&d=4",
194            "a=1;b=2&c=3&d=4",
195        ] {
196            assert_hit(input);
197        }
198    }
199
200    #[test]
201    fn ignores_distinct_keys() {
202        // 关键误报防护:不同 key 的正常查询串一律不报
203        for input in [
204            "a=1&b=2",
205            "?page=2&size=20&sort=name",
206            "user=admin&pass=123",
207            "a=1&b=2&c=3&d=4",
208        ] {
209            assert_clean(&det(), input);
210        }
211    }
212
213    #[test]
214    fn ignores_matrix_parameter_and_request_line() {
215        // `;jsessionid=` 是路径上的矩阵参数——`?` 之前的部分不是查询串
216        assert_clean(&det(), "http://x.com/app;jsessionid=ABC123?p=1&q=2");
217        assert_clean(&det(), "http://x.com/app;JSESSIONID=ABC123?p=1&q=2");
218        assert_clean(&det(), "http://x.com/app;jsessionid=ABC123&p=1");
219        // 请求行:`;z=3` 后面跟着协议版本,不是完整参数
220        assert_clean(&det(), "GET /a?x=1&y=2;z=3 HTTP/1.1");
221        // 混用的 `;k=v` 后面还有别的东西 = 散文
222        assert_clean(&det(), "a=1&b=2;c=3 说明");
223    }
224
225    #[test]
226    fn ignores_benign_inputs() {
227        for input in [
228            "Hello, this is a normal text input. Nothing suspicious here.",
229            "q=donation=5",
230            "q=2024--2025",
231            "a=1;b=2",
232            "Content-Type: multipart/mixed; boundary=abc123",
233            "1 & 1 == 2",
234            "a + b; c + d",
235            "name=John Doe",
236            "穿越之霸道总裁爱上我--重生之都市修仙",
237        ] {
238            assert_clean(&det(), input);
239        }
240    }
241
242    /// 现实语料:`;` 不是查询串的参数分隔符,栈/配置/散文里的 `;` 与 `=` 同现
243    /// 不是污染。缺了这一组才会把 `PATH=/usr/bin; PATH=/opt/bin` 判成 Medium。
244    #[test]
245    fn ignores_real_world_text() {
246        // 环境变量 / 启动参数粘贴(工单里最常见的一类输入)
247        assert_clean(&det(), "PATH=/usr/bin; PATH=/opt/bin");
248        assert_clean(&det(), "JAVA_HOME=/usr/lib/jvm; JAVA_HOME=/opt/jdk");
249        assert_clean(
250            &det(),
251            "env LANG=zh_CN.UTF-8; JAVA_OPTS=-Xmx2g; JAVA_OPTS=-Dfile.encoding=UTF-8",
252        );
253        // 源码与散文
254        assert_clean(&det(), "for (i=0; i<n; i++) { sum += i; }");
255        assert_clean(&det(), "作业:把 a=1; b=2 代入公式,另外 cc: 一下组长。");
256        // 正常查询串与它的请求行形态
257        assert_clean(&det(), "/search?q=rust&page=2&sort=recent");
258        assert_clean(&det(), "GET /search?q=rust&page=2 HTTP/1.1");
259    }
260
261    #[test]
262    fn edge_cases() {
263        assert_clean(&det(), "");
264        assert_clean(&det(), "   ");
265        assert_clean(&det(), "&");
266        assert_clean(&det(), "&=");
267        // 空 key 不进比对
268        assert_clean(&det(), "=1&=2");
269        // 第二个 key 没有 `=value`,不进比对
270        assert_clean(&det(), "a=1&a");
271        // 查询串标记不算 key 的一部分
272        assert_hit_at("?id=1&id=2", Severity::Low);
273        assert_hit_at("/p?id=1&id=2", Severity::Low);
274        assert_hit_at("http://h/p?id=1&id=2", Severity::Low);
275        // 空值但 key 重复,仍算污染
276        assert_hit_at("a=&a=", Severity::Low);
277        // 重复 key 的结果要能对上原文切片
278        let r = det().detect("?x=1&y=2&x=3").expect("expected detection");
279        assert_eq!(
280            &"?x=1&y=2&x=3"[r.offset..r.offset + r.matched_pattern.len()],
281            r.matched_pattern
282        );
283        assert_eq!(r.matched_pattern, "x");
284    }
285}