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::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
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(
37            &PATTERNS,
38            self.name(),
39            AttackCategory::File,
40            Severity::Critical,
41            "Malicious file upload detected",
42            input,
43        )
44    }
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    #[test]
52    fn name_returns_attack_type() {
53        assert_eq!(UploadDetector.name(), "upload");
54    }
55
56    #[test]
57    fn detects_php_tags() {
58        for payload in [
59            "<?php system($_GET['cmd']); ?>",
60            "<?= shell_exec($_POST['cmd']) ?>",
61            "<?php echo 'hello';",
62        ] {
63            let r = UploadDetector
64                .detect(payload)
65                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
66            assert_eq!(r.attack_type, "upload");
67            assert_eq!(r.category, AttackCategory::File);
68            assert_eq!(r.severity, Severity::Critical);
69            assert!(
70                !r.matched_pattern.is_empty(),
71                "matched_pattern empty for {:?}",
72                payload
73            );
74            assert!(
75                r.offset <= payload.len(),
76                "offset out of range for {:?}",
77                payload
78            );
79        }
80    }
81
82    #[test]
83    fn detects_asp_and_script_language_tags() {
84        for payload in [
85            "<% @ Page Language=\"C#\" %>",
86            "<% = response.write(1) %>",
87            "<script language='vbscript'>MsgBox 1</script>",
88            "<script language=\"jscript\">x()</script>",
89            "<script language=php>echo 1;</script>",
90        ] {
91            let r = UploadDetector
92                .detect(payload)
93                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
94            assert!(
95                !r.matched_pattern.is_empty(),
96                "matched_pattern empty for {:?}",
97                payload
98            );
99            assert!(
100                r.offset <= payload.len(),
101                "offset out of range for {:?}",
102                payload
103            );
104        }
105    }
106
107    #[test]
108    fn detects_exec_functions_and_superglobals() {
109        for payload in [
110            "eval($code);",
111            "system($cmd);",
112            "exec($cmd);",
113            "passthru($cmd);",
114            "shell_exec($cmd);",
115            "$_GET['cmd']",
116            "$_POST['cmd']",
117            "$_REQUEST['cmd']",
118            "$_SERVER['REQUEST_URI']",
119            "base64_decode('aGVsbG8=')",
120        ] {
121            let r = UploadDetector
122                .detect(payload)
123                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
124            assert!(
125                !r.matched_pattern.is_empty(),
126                "matched_pattern empty for {:?}",
127                payload
128            );
129            assert!(
130                r.offset <= payload.len(),
131                "offset out of range for {:?}",
132                payload
133            );
134        }
135    }
136
137    #[test]
138    fn ignores_benign_inputs() {
139        for input in [
140            "Hello, this is a normal text input.",
141            "php is a popular language",
142            "<script>alert(1)</script>",
143            "system('id')",
144            "exec('ls')",
145            "base64_decode",
146            "$_GET",
147            "eval()",
148        ] {
149            assert!(
150                UploadDetector.detect(input).is_none(),
151                "false positive: {:?}",
152                input
153            );
154        }
155    }
156
157    #[test]
158    fn edge_cases() {
159        assert!(UploadDetector.detect("").is_none());
160        assert!(UploadDetector.detect("   ").is_none());
161        assert!(UploadDetector.detect("<?php echo 1;").is_none()); // fullwidth angle bracket
162        assert!(UploadDetector.detect("<? phpx echo 1;").is_none()); // space breaks the tag
163    }
164}