1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static 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
32fn 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
42fn duplicate_key(input: &str) -> Option<(usize, usize)> {
49 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; if i >= MAX_TOKENS {
59 break;
60 }
61 let Some(eq) = token.find('=') else {
62 continue;
63 };
64 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 if !input.contains('&') && !input.contains(';') {
93 return None;
94 }
95 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 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 #[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 #[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 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 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 assert_clean(&det(), "GET /a?x=1&y=2;z=3 HTTP/1.1");
221 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 #[test]
245 fn ignores_real_world_text() {
246 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 assert_clean(&det(), "for (i=0; i<n; i++) { sum += i; }");
255 assert_clean(&det(), "作业:把 a=1; b=2 代入公式,另外 cc: 一下组长。");
256 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 assert_clean(&det(), "=1&=2");
269 assert_clean(&det(), "a=1&a");
271 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 assert_hit_at("a=&a=", Severity::Low);
277 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}