security_rust/protocol/
hpp.rs1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static 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
30fn 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
40fn duplicate_key(input: &str) -> Option<(usize, usize)> {
44 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; if i >= MAX_TOKENS {
54 break;
55 }
56 let Some(eq) = token.find('=') else {
57 continue;
58 };
59 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 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 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 assert_clean(&det(), "GET /a?x=1&y=2;z=3 HTTP/1.1");
185 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 assert_clean(&det(), "=1&=2");
214 assert_clean(&det(), "a=1&a");
216 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 assert_hit("a=&a=");
222 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}