Skip to main content

security_rust/file/
upload.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{regex_detect, AttackCategory, DetectionResult, Detector, Severity};
7
8static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
9    vec![
10        Regex::new(r"(?i)<\?php").unwrap(),
11        Regex::new(r"(?i)<\?=").unwrap(),
12        Regex::new(r"(?i)<%\s*@").unwrap(),
13        Regex::new(r"(?i)<%\s*=").unwrap(),
14        Regex::new(r#"(?i)<script\s+language\s*=\s*["']?(?:php|vbscript|jscript)["']?"#).unwrap(),
15        Regex::new(r"(?i)eval\s*\(\s*\$").unwrap(),
16        Regex::new(r"(?i)system\s*\(\s*\$").unwrap(),
17        Regex::new(r"(?i)exec\s*\(\s*\$").unwrap(),
18        Regex::new(r"(?i)passthru\s*\(\s*\$").unwrap(),
19        Regex::new(r"(?i)shell_exec\s*\(\s*\$").unwrap(),
20        Regex::new(r"(?i)\$_GET\[").unwrap(),
21        Regex::new(r"(?i)\$_POST\[").unwrap(),
22        Regex::new(r"(?i)\$_REQUEST\[").unwrap(),
23        Regex::new(r"(?i)\$_SERVER\[").unwrap(),
24        Regex::new(r"(?i)base64_decode\s*\(").unwrap(),
25    ]
26});
27
28pub struct UploadDetector;
29
30impl Detector for UploadDetector {
31    fn name(&self) -> &'static str {
32        "upload"
33    }
34
35    fn detect(&self, input: &str) -> Option<DetectionResult> {
36        regex_detect(&PATTERNS, self.name(), AttackCategory::File, Severity::Critical, "Malicious file upload detected", input)
37    }
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43
44    #[test]
45    fn name_returns_attack_type() {
46        assert_eq!(UploadDetector.name(), "upload");
47    }
48
49    #[test]
50    fn detects_php_tags() {
51        for payload in [
52            "<?php system($_GET['cmd']); ?>",
53            "<?= shell_exec($_POST['cmd']) ?>",
54            "<?php echo 'hello';",
55        ] {
56            let r = UploadDetector
57                .detect(payload)
58                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
59            assert_eq!(r.attack_type, "upload");
60            assert_eq!(r.category, AttackCategory::File);
61            assert_eq!(r.severity, Severity::Critical);
62            assert!(
63                !r.matched_pattern.is_empty(),
64                "matched_pattern empty for {:?}",
65                payload
66            );
67            assert!(
68                r.offset <= payload.len(),
69                "offset out of range for {:?}",
70                payload
71            );
72        }
73    }
74
75    #[test]
76    fn detects_asp_and_script_language_tags() {
77        for payload in [
78            "<% @ Page Language=\"C#\" %>",
79            "<% = response.write(1) %>",
80            "<script language='vbscript'>MsgBox 1</script>",
81            "<script language=\"jscript\">x()</script>",
82            "<script language=php>echo 1;</script>",
83        ] {
84            let r = UploadDetector
85                .detect(payload)
86                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
87            assert!(
88                !r.matched_pattern.is_empty(),
89                "matched_pattern empty for {:?}",
90                payload
91            );
92            assert!(
93                r.offset <= payload.len(),
94                "offset out of range for {:?}",
95                payload
96            );
97        }
98    }
99
100    #[test]
101    fn detects_exec_functions_and_superglobals() {
102        for payload in [
103            "eval($code);",
104            "system($cmd);",
105            "exec($cmd);",
106            "passthru($cmd);",
107            "shell_exec($cmd);",
108            "$_GET['cmd']",
109            "$_POST['cmd']",
110            "$_REQUEST['cmd']",
111            "$_SERVER['REQUEST_URI']",
112            "base64_decode('aGVsbG8=')",
113        ] {
114            let r = UploadDetector
115                .detect(payload)
116                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
117            assert!(
118                !r.matched_pattern.is_empty(),
119                "matched_pattern empty for {:?}",
120                payload
121            );
122            assert!(
123                r.offset <= payload.len(),
124                "offset out of range for {:?}",
125                payload
126            );
127        }
128    }
129
130    #[test]
131    fn ignores_benign_inputs() {
132        for input in [
133            "Hello, this is a normal text input.",
134            "php is a popular language",
135            "<script>alert(1)</script>",
136            "system('id')",
137            "exec('ls')",
138            "base64_decode",
139            "$_GET",
140            "eval()",
141        ] {
142            assert!(
143                UploadDetector.detect(input).is_none(),
144                "false positive: {:?}",
145                input
146            );
147        }
148    }
149
150    #[test]
151    fn edge_cases() {
152        assert!(UploadDetector.detect("").is_none());
153        assert!(UploadDetector.detect("   ").is_none());
154        assert!(UploadDetector.detect("<?php echo 1;").is_none()); // fullwidth angle bracket
155        assert!(UploadDetector.detect("<? phpx echo 1;").is_none()); // space breaks the tag
156    }
157}