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` 后面跟着的是协议版本。
17static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
18    vec![
19        Regex::new(
20            r"(?i)&[a-z0-9_%.\-]{1,64}=[^&;\s?]{0,200};[a-z0-9_%.\-]{1,64}=[^&;\s?]{0,200}(?:&|$)",
21        )
22        .unwrap(),
23        Regex::new(
24            r"(?i);[a-z0-9_%.\-]{1,64}=[^&;\s?]{0,200}&[a-z0-9_%.\-]{1,64}=[^&;\s?]{0,200}(?:&|$)",
25        )
26        .unwrap(),
27    ]
28});
29
30/// `;jsessionid=` 是 Java 容器的矩阵参数(URL 重写会话 ID)。出现它就说明这一层
31/// 的 `;` 是路径分隔符而非参数分隔符——只保留重复 key 判定,混用形态不再报。
32fn has_matrix_session(input: &str) -> bool {
33    const NEEDLE: &[u8] = b";jsessionid=";
34    input
35        .as_bytes()
36        .windows(NEEDLE.len())
37        .any(|w| w.eq_ignore_ascii_case(NEEDLE))
38}
39
40/// 同一 key 重复出现的位置。`regex` crate 无反向引用,纯正则表达不了
41/// "两个 key 相等"——只能退化成"任意两个参数",那 `a=1&b=2` 也会报。
42/// 所以这里显式拆参比对。
43fn duplicate_key(input: &str) -> Option<(usize, usize)> {
44    // ponytail: 只扫前 64 个参数,防超长输入退化成 O(n²),真实请求够用
45    const MAX_TOKENS: usize = 64;
46
47    let mut keys: Vec<&str> = Vec::new();
48    let mut token_start = 0usize;
49
50    for (i, token) in input.split(['&', ';']).enumerate() {
51        let start = token_start;
52        token_start = start + token.len() + 1; // 分隔符恒为单字节 ASCII
53        if i >= MAX_TOKENS {
54            break;
55        }
56        let Some(eq) = token.find('=') else {
57            continue;
58        };
59        // `?id=1&id=2`、`/p?id=1&id=2`:首个 key 挂着查询串标记或路径,先剥掉
60        let raw = &token[..eq];
61        let key_head = raw.rfind(['?', '#']).map_or(0, |p| p + 1);
62        let tail = &raw[key_head..];
63        let key = tail.trim();
64        if key.is_empty() {
65            continue;
66        }
67        if keys.contains(&key) {
68            let lead = tail.len() - tail.trim_start().len();
69            let at = start + key_head + lead;
70            return Some((at, key.len()));
71        }
72        keys.push(key);
73    }
74    None
75}
76
77pub struct HttpParameterPollutionDetector;
78
79impl Detector for HttpParameterPollutionDetector {
80    fn name(&self) -> &'static str {
81        "hpp"
82    }
83
84    fn detect(&self, input: &str) -> Option<DetectionResult> {
85        if let Some((offset, len)) = duplicate_key(input) {
86            return Some(DetectionResult {
87                attack_type: self.name().to_string(),
88                category: AttackCategory::Protocol,
89                severity: Severity::Medium,
90                matched_pattern: input[offset..offset + len].to_string(),
91                offset,
92                message: "HTTP parameter pollution detected".into(),
93            });
94        }
95        if has_matrix_session(input) {
96            return None;
97        }
98        regex_detect(
99            &PATTERNS,
100            self.name(),
101            AttackCategory::Protocol,
102            Severity::Medium,
103            "HTTP parameter pollution detected",
104            input,
105        )
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::test_helpers::{assert_clean, assert_detected};
113
114    fn det() -> HttpParameterPollutionDetector {
115        HttpParameterPollutionDetector
116    }
117
118    fn assert_hit(input: &str) {
119        assert_detected(&det(), input, AttackCategory::Protocol, Severity::Medium);
120    }
121
122    #[test]
123    fn name_is_hpp() {
124        assert_eq!(det().name(), "hpp");
125    }
126
127    #[test]
128    fn detects_repeated_key() {
129        for input in [
130            "a=1&a=2",
131            "?id=1&id=2",
132            "id=1&id=2&id=3",
133            "user=admin&user=guest",
134            "a=1&b=2&a=3",
135        ] {
136            assert_hit(input);
137        }
138    }
139
140    #[test]
141    fn detects_repeated_array_key() {
142        for input in [
143            "a[]=1&a[]=2",
144            "ids[]=1&ids[]=2&ids[]=3",
145            "?filter[role]=admin&filter[role]=user",
146        ] {
147            assert_hit(input);
148        }
149    }
150
151    #[test]
152    fn detects_mixed_delimiters() {
153        for input in [
154            "a=1&b=2;c=3",
155            "a=1;b=2&c=3",
156            "?x=1&y=2;z=3",
157            "a=1&b=2;c=3&d=4",
158            "a=1;b=2&c=3&d=4",
159        ] {
160            assert_hit(input);
161        }
162    }
163
164    #[test]
165    fn ignores_distinct_keys() {
166        // 关键误报防护:不同 key 的正常查询串一律不报
167        for input in [
168            "a=1&b=2",
169            "?page=2&size=20&sort=name",
170            "user=admin&pass=123",
171            "a=1&b=2&c=3&d=4",
172        ] {
173            assert_clean(&det(), input);
174        }
175    }
176
177    #[test]
178    fn ignores_matrix_parameter_and_request_line() {
179        // `;jsessionid=` 是路径上的矩阵参数——`?` 之前的部分不是查询串
180        assert_clean(&det(), "http://x.com/app;jsessionid=ABC123?p=1&q=2");
181        assert_clean(&det(), "http://x.com/app;JSESSIONID=ABC123?p=1&q=2");
182        assert_clean(&det(), "http://x.com/app;jsessionid=ABC123&p=1");
183        // 请求行:`;z=3` 后面跟着协议版本,不是完整参数
184        assert_clean(&det(), "GET /a?x=1&y=2;z=3 HTTP/1.1");
185        // 混用的 `;k=v` 后面还有别的东西 = 散文
186        assert_clean(&det(), "a=1&b=2;c=3 说明");
187    }
188
189    #[test]
190    fn ignores_benign_inputs() {
191        for input in [
192            "Hello, this is a normal text input. Nothing suspicious here.",
193            "q=donation=5",
194            "q=2024--2025",
195            "a=1;b=2",
196            "Content-Type: multipart/mixed; boundary=abc123",
197            "1 & 1 == 2",
198            "a + b; c + d",
199            "name=John Doe",
200            "穿越之霸道总裁爱上我--重生之都市修仙",
201        ] {
202            assert_clean(&det(), input);
203        }
204    }
205
206    #[test]
207    fn edge_cases() {
208        assert_clean(&det(), "");
209        assert_clean(&det(), "   ");
210        assert_clean(&det(), "&");
211        assert_clean(&det(), "&=");
212        // 空 key 不进比对
213        assert_clean(&det(), "=1&=2");
214        // 第二个 key 没有 `=value`,不进比对
215        assert_clean(&det(), "a=1&a");
216        // 查询串标记不算 key 的一部分
217        assert_hit("?id=1&id=2");
218        assert_hit("/p?id=1&id=2");
219        assert_hit("http://h/p?id=1&id=2");
220        // 空值但 key 重复,仍算污染
221        assert_hit("a=&a=");
222        // 重复 key 的结果要能对上原文切片
223        let r = det().detect("?x=1&y=2&x=3").expect("expected detection");
224        assert_eq!(
225            &"?x=1&y=2&x=3"[r.offset..r.offset + r.matched_pattern.len()],
226            r.matched_pattern
227        );
228        assert_eq!(r.matched_pattern, "x");
229    }
230}