use regex::Regex;
use std::sync::LazyLock;
use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
&[
r"(?i)<\?php",
r"|<\?=",
r"|<%\s*@",
r"|<%\s*=",
r#"|<script\s+language\s*=\s*["']?(?:php|vbscript|jscript)["']?"#,
r"|eval\s*\(\s*\$",
r"|system\s*\(\s*\$",
r"|exec\s*\(\s*\$",
r"|passthru\s*\(\s*\$",
r"|shell_exec\s*\(\s*\$",
r"|\$_GET\[",
r"|\$_POST\[",
r"|\$_REQUEST\[",
r"|\$_SERVER\[",
r"|base64_decode\s*\(",
]
.concat(),
)
.unwrap()
});
static B64_NEEDLE: LazyLock<Regex> = LazyLock::new(|| Regex::new("(?i)base64_decode").unwrap());
pub struct UploadDetector;
impl Detector for UploadDetector {
fn name(&self) -> &'static str {
"upload"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
if !input.contains('<') && !input.contains('$') && !B64_NEEDLE.is_match(input) {
return None;
}
regex_detect(
std::slice::from_ref(&*PATTERNS),
self.name(),
AttackCategory::File,
Severity::Critical,
"Malicious file upload detected",
input,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_returns_attack_type() {
assert_eq!(UploadDetector.name(), "upload");
}
#[test]
fn detects_php_tags() {
for payload in [
"<?php system($_GET['cmd']); ?>",
"<?= shell_exec($_POST['cmd']) ?>",
"<?php echo 'hello';",
] {
let r = UploadDetector
.detect(payload)
.unwrap_or_else(|| panic!("expected detection for {:?}", payload));
assert_eq!(r.attack_type, "upload");
assert_eq!(r.category, AttackCategory::File);
assert_eq!(r.severity, Severity::Critical);
assert!(
!r.matched_pattern.is_empty(),
"matched_pattern empty for {:?}",
payload
);
assert!(
r.offset <= payload.len(),
"offset out of range for {:?}",
payload
);
}
}
#[test]
fn detects_asp_and_script_language_tags() {
for payload in [
"<% @ Page Language=\"C#\" %>",
"<% = response.write(1) %>",
"<script language='vbscript'>MsgBox 1</script>",
"<script language=\"jscript\">x()</script>",
"<script language=php>echo 1;</script>",
] {
let r = UploadDetector
.detect(payload)
.unwrap_or_else(|| panic!("expected detection for {:?}", payload));
assert!(
!r.matched_pattern.is_empty(),
"matched_pattern empty for {:?}",
payload
);
assert!(
r.offset <= payload.len(),
"offset out of range for {:?}",
payload
);
}
}
#[test]
fn detects_exec_functions_and_superglobals() {
for payload in [
"eval($code);",
"system($cmd);",
"exec($cmd);",
"passthru($cmd);",
"shell_exec($cmd);",
"$_GET['cmd']",
"$_POST['cmd']",
"$_REQUEST['cmd']",
"$_SERVER['REQUEST_URI']",
"base64_decode('aGVsbG8=')",
] {
let r = UploadDetector
.detect(payload)
.unwrap_or_else(|| panic!("expected detection for {:?}", payload));
assert!(
!r.matched_pattern.is_empty(),
"matched_pattern empty for {:?}",
payload
);
assert!(
r.offset <= payload.len(),
"offset out of range for {:?}",
payload
);
}
}
#[test]
fn ignores_benign_inputs() {
for input in [
"Hello, this is a normal text input.",
"php is a popular language",
"<script>alert(1)</script>",
"system('id')",
"exec('ls')",
"base64_decode",
"$_GET",
"eval()",
] {
assert!(
UploadDetector.detect(input).is_none(),
"false positive: {:?}",
input
);
}
}
#[test]
fn detects_case_insensitive_base64_decode() {
for payload in [
"BASE64_DECODE('aGVsbG8=')",
"BaSe64_DeCoDe('x')",
"ba\u{17f}e64_decode('x')", ] {
assert!(
UploadDetector.detect(payload).is_some(),
"missed {:?}",
payload
);
}
}
#[test]
fn trigger_free_long_input_stays_clean() {
let clean = "The quick brown fox jumps over the lazy dog and then walks back home again "
.repeat(2000);
assert!(UploadDetector.detect(&clean).is_none());
}
#[test]
fn edge_cases() {
assert!(UploadDetector.detect("").is_none());
assert!(UploadDetector.detect(" ").is_none());
assert!(UploadDetector.detect("<?php echo 1;").is_none()); assert!(UploadDetector.detect("<? phpx echo 1;").is_none()); }
}