use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
use regex::Regex;
use std::sync::LazyLock;
static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
&[
r"(?i)&[a-z0-9_%.\-]{1,64}=[^&;\s?]{0,200};[a-z0-9_%.\-]{1,64}=[^&;\s?]{0,200}(?:&|$)",
r"|;[a-z0-9_%.\-]{1,64}=[^&;\s?]{0,200}&[a-z0-9_%.\-]{1,64}=[^&;\s?]{0,200}(?:&|$)",
]
.concat(),
)
.unwrap()
});
fn has_matrix_session(input: &str) -> bool {
const NEEDLE: &[u8] = b";jsessionid=";
input
.as_bytes()
.windows(NEEDLE.len())
.any(|w| w.eq_ignore_ascii_case(NEEDLE))
}
fn duplicate_key(input: &str) -> Option<(usize, usize)> {
const MAX_TOKENS: usize = 64;
let mut keys: Vec<&str> = Vec::new();
let mut token_start = 0usize;
for (i, token) in input.split('&').enumerate() {
let start = token_start;
token_start = start + token.len() + 1; if i >= MAX_TOKENS {
break;
}
let Some(eq) = token.find('=') else {
continue;
};
let raw = &token[..eq];
let key_head = raw.rfind(['?', '#']).map_or(0, |p| p + 1);
let tail = &raw[key_head..];
let key = tail.trim();
if key.is_empty() {
continue;
}
if keys.contains(&key) {
let lead = tail.len() - tail.trim_start().len();
let at = start + key_head + lead;
return Some((at, key.len()));
}
keys.push(key);
}
None
}
pub struct HttpParameterPollutionDetector;
impl Detector for HttpParameterPollutionDetector {
fn name(&self) -> &'static str {
"hpp"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
if !input.contains('&') && !input.contains(';') {
return None;
}
if !has_matrix_session(input)
&& let Some(hit) = regex_detect(
std::slice::from_ref(&*PATTERNS),
self.name(),
AttackCategory::Protocol,
Severity::Medium,
"HTTP parameter pollution detected",
input,
)
{
return Some(hit);
}
duplicate_key(input).map(|(offset, len)| DetectionResult {
attack_type: self.name().to_string(),
category: AttackCategory::Protocol,
severity: Severity::Low,
matched_pattern: input[offset..offset + len].to_string(),
offset,
message: "HTTP parameter pollution detected".into(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{assert_clean, assert_detected};
fn det() -> HttpParameterPollutionDetector {
HttpParameterPollutionDetector
}
fn assert_hit(input: &str) {
assert_hit_at(input, Severity::Medium);
}
fn assert_hit_at(input: &str, severity: Severity) {
assert_detected(&det(), input, AttackCategory::Protocol, severity);
}
#[test]
fn name_is_hpp() {
assert_eq!(det().name(), "hpp");
}
#[test]
fn detects_repeated_key() {
for input in [
"a=1&a=2",
"?id=1&id=2",
"id=1&id=2&id=3",
"user=admin&user=guest",
"a=1&b=2&a=3",
] {
assert_hit_at(input, Severity::Low);
}
}
#[test]
fn detects_repeated_array_key() {
for input in [
"a[]=1&a[]=2",
"ids[]=1&ids[]=2&ids[]=3",
"?filter[role]=admin&filter[role]=user",
] {
assert_hit_at(input, Severity::Low);
}
}
#[test]
fn multi_value_params_hit_only_the_weak_tier() {
for input in [
"GET /search?tag=rust&tag=web&page=2 HTTP/1.1\r\nHost: example.com\r\n\r\n",
"?ids[]=1&ids[]=2&ids[]=3",
"https://example.com/log?a=1&a=2",
] {
assert_hit_at(input, Severity::Low);
}
}
#[test]
fn detects_mixed_delimiters() {
for input in [
"a=1&b=2;c=3",
"a=1;b=2&c=3",
"?x=1&y=2;z=3",
"a=1&b=2;c=3&d=4",
"a=1;b=2&c=3&d=4",
] {
assert_hit(input);
}
}
#[test]
fn ignores_distinct_keys() {
for input in [
"a=1&b=2",
"?page=2&size=20&sort=name",
"user=admin&pass=123",
"a=1&b=2&c=3&d=4",
] {
assert_clean(&det(), input);
}
}
#[test]
fn ignores_matrix_parameter_and_request_line() {
assert_clean(&det(), "http://x.com/app;jsessionid=ABC123?p=1&q=2");
assert_clean(&det(), "http://x.com/app;JSESSIONID=ABC123?p=1&q=2");
assert_clean(&det(), "http://x.com/app;jsessionid=ABC123&p=1");
assert_clean(&det(), "GET /a?x=1&y=2;z=3 HTTP/1.1");
assert_clean(&det(), "a=1&b=2;c=3 说明");
}
#[test]
fn ignores_benign_inputs() {
for input in [
"Hello, this is a normal text input. Nothing suspicious here.",
"q=donation=5",
"q=2024--2025",
"a=1;b=2",
"Content-Type: multipart/mixed; boundary=abc123",
"1 & 1 == 2",
"a + b; c + d",
"name=John Doe",
"穿越之霸道总裁爱上我--重生之都市修仙",
] {
assert_clean(&det(), input);
}
}
#[test]
fn ignores_real_world_text() {
assert_clean(&det(), "PATH=/usr/bin; PATH=/opt/bin");
assert_clean(&det(), "JAVA_HOME=/usr/lib/jvm; JAVA_HOME=/opt/jdk");
assert_clean(
&det(),
"env LANG=zh_CN.UTF-8; JAVA_OPTS=-Xmx2g; JAVA_OPTS=-Dfile.encoding=UTF-8",
);
assert_clean(&det(), "for (i=0; i<n; i++) { sum += i; }");
assert_clean(&det(), "作业:把 a=1; b=2 代入公式,另外 cc: 一下组长。");
assert_clean(&det(), "/search?q=rust&page=2&sort=recent");
assert_clean(&det(), "GET /search?q=rust&page=2 HTTP/1.1");
}
#[test]
fn edge_cases() {
assert_clean(&det(), "");
assert_clean(&det(), " ");
assert_clean(&det(), "&");
assert_clean(&det(), "&=");
assert_clean(&det(), "=1&=2");
assert_clean(&det(), "a=1&a");
assert_hit_at("?id=1&id=2", Severity::Low);
assert_hit_at("/p?id=1&id=2", Severity::Low);
assert_hit_at("http://h/p?id=1&id=2", Severity::Low);
assert_hit_at("a=&a=", Severity::Low);
let r = det().detect("?x=1&y=2&x=3").expect("expected detection");
assert_eq!(
&"?x=1&y=2&x=3"[r.offset..r.offset + r.matched_pattern.len()],
r.matched_pattern
);
assert_eq!(r.matched_pattern, "x");
}
}