Skip to main content

guard_core_engine/patterns/
file_upload.rs

1//! File-upload matchers ported from
2//! `guard_core/handlers/_suspatterns_file_upload.py` (spec 4.0.2).
3//!
4//! All four patterns anchor on a `filename = "<quoted>"` token whose boundary
5//! is located by walking back over whitespace to a required boundary char.
6//! The dangerous-extension marker carries a `(?![A-Za-z0-9])` guard; the
7//! guard is enforced with per-branch suffix checks in the reference's
8//! alternation order (`php\d*` first with digit backtracking, then the
9//! literal branches), which reproduces the lookahead exactly.
10
11use super::chars_util::{char_at, char_before};
12use super::pyregex::Candidate;
13
14/// Dangerous extension alternation with `com` (terminal-extension pattern).
15pub const DANGEROUS_EXT: &[&str] = &[
16    "phtml", "shtml", "asax", "ascx", "ashx", "asmx", "aspx", "bash", "jspx", "phar", "phps",
17    "asa", "asp", "bat", "cer", "cfc", "cfm", "cgi", "cmd", "com", "exe", "hta", "jsp", "msi",
18    "pht", "vbe", "vbs", "war", "wsf", "js", "pl", "py", "rb", "sh", "ws",
19];
20
21/// Dangerous extension alternation for the double-extension marker (no `com`).
22pub const DOUBLE_EXT: &[&str] = &[
23    "phtml", "shtml", "asax", "ascx", "ashx", "asmx", "aspx", "bash", "jspx", "phar", "phps",
24    "asa", "asp", "bat", "cer", "cfc", "cfm", "cgi", "cmd", "exe", "hta", "jsp", "msi", "pht",
25    "vbe", "vbs", "war", "wsf", "js", "pl", "py", "rb", "sh", "ws",
26];
27
28/// Benign terminal extensions for the double-extension shape.
29pub const BENIGN_TERMINAL: &[&str] = &[
30    "docx", "jpeg", "pptx", "tiff", "webm", "webp", "xlsx", "avi", "bmp", "doc", "gif", "ico",
31    "jpg", "mkv", "mov", "mp3", "mp4", "odt", "pdf", "png", "ppt", "svg", "tif", "wav", "xls",
32];
33
34const fn is_whitespace(c: char) -> bool {
35    c.is_whitespace()
36}
37
38/// Anchored-`pos` matcher for `\.(?:php\d*|<alternation>)(?![A-Za-z0-9])`.
39///
40/// Returns the marker end. Branches are tried in reference order with the
41/// `php\d*` digit backtracking; the lookahead is a suffix check per branch.
42#[must_use]
43pub fn dangerous_marker_at(body: &str, pos: usize, extensions: &[&str]) -> Option<usize> {
44    if char_at(body, pos).map(|(_, c)| c) != Some('.') {
45        return None;
46    }
47    let after_dot = pos + 1;
48    // branch 1: php\d* with backtracking over the greedy digits
49    if body[after_dot..].starts_with("php") {
50        let mut end = after_dot + 3;
51        while char_at(body, end).is_some_and(|(_, c)| c.is_ascii_digit()) {
52            end += 1;
53        }
54        loop {
55            if char_at(body, end).is_none_or(|(_, c)| !c.is_ascii_alphanumeric()) {
56                return Some(end);
57            }
58            if end == after_dot + 3 {
59                break;
60            }
61            end -= 1;
62        }
63    }
64    for ext in extensions {
65        let end = after_dot + ext.len();
66        if body[after_dot..].starts_with(ext)
67            && char_at(body, end).is_none_or(|(_, c)| !c.is_ascii_alphanumeric())
68        {
69            return Some(end);
70        }
71    }
72    None
73}
74
75/// `\.(?:php\d*|<alternation>)\Z` on the body (case-insensitive).
76fn terminal_extension(body: &str, extensions: &[&str], include_php_digits: bool) -> bool {
77    let Some(dot) = body.rfind('.') else {
78        return false;
79    };
80    let tail = &body[dot + 1..];
81    if tail.is_empty() {
82        return false;
83    }
84    if tail.eq_ignore_ascii_case("php") {
85        return true;
86    }
87    if include_php_digits
88        && tail.len() > 3
89        && tail[..3].eq_ignore_ascii_case("php")
90        && tail[3..].bytes().all(|b| b.is_ascii_digit())
91    {
92        return true;
93    }
94    extensions.iter().any(|ext| tail.eq_ignore_ascii_case(ext))
95}
96
97fn benign_terminal(body: &str) -> bool {
98    let Some(dot) = body.rfind('.') else {
99        return false;
100    };
101    let tail = &body[dot + 1..];
102    BENIGN_TERMINAL
103        .iter()
104        .any(|ext| tail.eq_ignore_ascii_case(ext))
105}
106
107fn is_double_extension(body: &str) -> bool {
108    if !benign_terminal(body) {
109        return false;
110    }
111    let Some(final_dot) = body.rfind('.') else {
112        return false;
113    };
114    for (idx, c) in body.char_indices() {
115        if c != '.' || idx >= final_dot {
116            continue;
117        }
118        let Some(marker_end) = dangerous_marker_at(body, idx, DOUBLE_EXT) else {
119            continue;
120        };
121        if marker_end > final_dot {
122            continue;
123        }
124        let suffix_ok =
125            char_at(body, marker_end).is_none_or(|(_, c)| !matches!(c, ' ' | '"' | '\''));
126        if marker_end == final_dot || (marker_end < final_dot && suffix_ok) {
127            return true;
128        }
129    }
130    false
131}
132
133/// Truncation marker: `(?:%00|\u0000|\x00|\0|<NUL>|;)` raw, `(?:<NUL>|;)`
134/// decoded, each also allowing `.` + end of body.
135fn truncation_marker_at(body: &str, pos: usize, decoded: bool) -> bool {
136    if pos > body.len() {
137        return false;
138    }
139    if pos == body.len() - 1 && body.as_bytes()[pos] == b'.' {
140        return true;
141    }
142    if decoded {
143        return body[pos..].starts_with('\u{0}') || body[pos..].starts_with(';');
144    }
145    body[pos..].starts_with("%00")
146        || body[pos..].starts_with(r"\u0000")
147        || body[pos..].starts_with(r"\x00")
148        || body[pos..].starts_with(r"\0")
149        || body[pos..].starts_with('\u{0}')
150        || body[pos..].starts_with(';')
151}
152
153fn is_truncation(body: &str, decoded: bool) -> bool {
154    for (idx, c) in body.char_indices() {
155        if c != '.' {
156            continue;
157        }
158        let Some(marker_end) = dangerous_marker_at(body, idx, DOUBLE_EXT) else {
159            continue;
160        };
161        if truncation_marker_at(body, marker_end, decoded) {
162            return true;
163        }
164    }
165    false
166}
167
168fn kind_matches(
169    body: &str,
170    source: &str,
171    dangerous_source: &str,
172    double_source: &str,
173    trunc_source: &str,
174    decoded_trunc_source: &str,
175) -> bool {
176    if source == dangerous_source {
177        return terminal_extension(body, DANGEROUS_EXT, true);
178    }
179    if source == double_source {
180        return is_double_extension(body);
181    }
182    if source == trunc_source {
183        return is_truncation(body, false);
184    }
185    if source == decoded_trunc_source {
186        return is_truncation(body, true);
187    }
188    false
189}
190
191fn file_upload_match_start(content: &str, filename_start: usize) -> Option<usize> {
192    let mut cursor = filename_start;
193    let mut first_newline: Option<usize> = None;
194    loop {
195        if cursor == 0 {
196            return Some(0);
197        }
198        let Some((i, c)) = char_before(content, cursor) else {
199            return Some(0);
200        };
201        if is_whitespace(c) {
202            if c == '\n' {
203                first_newline = Some(i);
204            }
205            cursor = i;
206            continue;
207        }
208        if matches!(c, ';' | ',' | ':' | '\n') {
209            return Some(i);
210        }
211        return first_newline;
212    }
213}
214
215fn skip_whitespace(content: &str, mut cursor: usize) -> usize {
216    while let Some((i, c)) = char_at(content, cursor)
217        && is_whitespace(c)
218    {
219        cursor = i + c.len_utf8();
220    }
221    cursor
222}
223
224fn quoted_candidate(content: &str, filename_start: usize) -> Option<(usize, usize, usize)> {
225    let match_start = file_upload_match_start(content, filename_start)?;
226    let mut cursor = skip_whitespace(content, filename_start + "filename".len());
227    if char_at(content, cursor).map(|(_, c)| c) != Some('=') {
228        return None;
229    }
230    cursor = skip_whitespace(content, cursor + 1);
231    let open = char_at(content, cursor).map(|(_, c)| c)?;
232    if open != '"' && open != '\'' {
233        return None;
234    }
235    let body_start = cursor + 1;
236    let tail = &content[body_start..];
237    let quote_index = tail.find(['"', '\''])?;
238    Some((match_start, body_start, body_start + quote_index + 1))
239}
240
241/// `_file_upload_scan_matches`: candidates from `filename` tokens, classified
242/// per pattern; the emitted match spans the validated `[match_start, end)`.
243#[must_use]
244pub fn file_upload_scan_matches(
245    content: &str,
246    source: &str,
247    dangerous_source: &str,
248    double_source: &str,
249    trunc_source: &str,
250    decoded_trunc_source: &str,
251) -> Vec<Candidate> {
252    let mut matches = Vec::new();
253    let Ok(filename_re) = super::pyregex::PyRegex::compile("(?i)filename", false) else {
254        return matches;
255    };
256    let mut last_end = 0usize;
257    for m in filename_re.re().find_iter(content) {
258        let Some((start, body_start, end)) = quoted_candidate(content, m.start()) else {
259            continue;
260        };
261        if start < last_end {
262            continue;
263        }
264        let body = &content[body_start..end - 1];
265        if !kind_matches(
266            body,
267            source,
268            dangerous_source,
269            double_source,
270            trunc_source,
271            decoded_trunc_source,
272        ) {
273            continue;
274        }
275        matches.push(Candidate::new(start, end));
276        last_end = end;
277    }
278    matches
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    const DANGEROUS: &str = "DANGEROUS";
286    const DOUBLE: &str = "DOUBLE";
287    const TRUNC: &str = "TRUNC";
288    const DECODED_TRUNC: &str = "DECODED_TRUNC";
289
290    #[test]
291    fn dangerous_extension_fires() {
292        let ms = file_upload_scan_matches(
293            "; filename=\"shell.php\"",
294            DANGEROUS,
295            DANGEROUS,
296            DOUBLE,
297            TRUNC,
298            DECODED_TRUNC,
299        );
300        assert_eq!(ms.len(), 1);
301        assert_eq!(
302            ms[0].text("; filename=\"shell.php\""),
303            "; filename=\"shell.php\""
304        );
305    }
306
307    #[test]
308    fn double_extension_fires() {
309        let ms = file_upload_scan_matches(
310            "filename=\"report.php.jpg\"",
311            DOUBLE,
312            DANGEROUS,
313            DOUBLE,
314            TRUNC,
315            DECODED_TRUNC,
316        );
317        assert_eq!(ms.len(), 1);
318    }
319
320    #[test]
321    fn plain_image_is_benign() {
322        assert!(
323            file_upload_scan_matches(
324                "filename=\"photo.jpg\"",
325                DOUBLE,
326                DANGEROUS,
327                DOUBLE,
328                TRUNC,
329                DECODED_TRUNC
330            )
331            .is_empty()
332        );
333        assert!(
334            file_upload_scan_matches(
335                "filename=\"doc.pdf\"",
336                DANGEROUS,
337                DANGEROUS,
338                DOUBLE,
339                TRUNC,
340                DECODED_TRUNC
341            )
342            .is_empty()
343        );
344    }
345
346    #[test]
347    fn truncation_fires() {
348        let ms = file_upload_scan_matches(
349            "filename=\"shell.php%00.jpg\"",
350            TRUNC,
351            DANGEROUS,
352            DOUBLE,
353            TRUNC,
354            DECODED_TRUNC,
355        );
356        assert_eq!(ms.len(), 1);
357        let ms = file_upload_scan_matches(
358            "filename=\"shell.php;.jpg\"",
359            TRUNC,
360            DANGEROUS,
361            DOUBLE,
362            TRUNC,
363            DECODED_TRUNC,
364        );
365        assert_eq!(ms.len(), 1);
366    }
367
368    #[test]
369    fn phps_branch_reached_after_php_digits() {
370        // php\d* fails via the alnum suffix check; the phps branch must win
371        let ms = file_upload_scan_matches(
372            "filename=\"x.phps.jpg\"",
373            DOUBLE,
374            DANGEROUS,
375            DOUBLE,
376            TRUNC,
377            DECODED_TRUNC,
378        );
379        assert_eq!(ms.len(), 1);
380    }
381
382    #[test]
383    fn filename_needs_boundary() {
384        // "myfilename" has no boundary before `filename` at offset 2
385        assert!(
386            file_upload_scan_matches(
387                "myfilename=\"shell.php\"",
388                DANGEROUS,
389                DANGEROUS,
390                DOUBLE,
391                TRUNC,
392                DECODED_TRUNC
393            )
394            .is_empty()
395        );
396    }
397}