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/// ASCII case-folded `starts_with` at a byte position: the reference's builtin
39/// `re.IGNORECASE` folding for the row's extension literals.
40fn ascii_starts_with_ignore_case(haystack: &str, pos: usize, needle: &str) -> bool {
41    let bytes = haystack.as_bytes();
42    let Some(window) = bytes.get(pos..pos + needle.len()) else {
43        return false;
44    };
45    window
46        .iter()
47        .zip(needle.bytes())
48        .all(|(a, b)| a.eq_ignore_ascii_case(&b))
49}
50
51/// Anchored-`pos` matcher for `\.(?:php\d*|<alternation>)(?![A-Za-z0-9])`.
52///
53/// Returns the marker end. Branches are tried in reference order with the
54/// `php\d*` digit backtracking; the lookahead is a suffix check per branch.
55/// The row carries the reference's builtin IGNORECASE, so the extension
56/// literals fold ASCII case.
57#[must_use]
58pub fn dangerous_marker_at(body: &str, pos: usize, extensions: &[&str]) -> Option<usize> {
59    if char_at(body, pos).map(|(_, c)| c) != Some('.') {
60        return None;
61    }
62    let after_dot = pos + 1;
63    // branch 1: php\d* with backtracking over the greedy digits
64    if ascii_starts_with_ignore_case(body, after_dot, "php") {
65        let mut end = after_dot + 3;
66        while char_at(body, end).is_some_and(|(_, c)| c.is_ascii_digit()) {
67            end += 1;
68        }
69        loop {
70            if char_at(body, end).is_none_or(|(_, c)| !c.is_ascii_alphanumeric()) {
71                return Some(end);
72            }
73            if end == after_dot + 3 {
74                break;
75            }
76            end -= 1;
77        }
78    }
79    for ext in extensions {
80        let end = after_dot + ext.len();
81        if ascii_starts_with_ignore_case(body, after_dot, ext)
82            && char_at(body, end).is_none_or(|(_, c)| !c.is_ascii_alphanumeric())
83        {
84            return Some(end);
85        }
86    }
87    None
88}
89
90/// `\.(?:php\d*|<alternation>)\Z` on the body (case-insensitive).
91fn terminal_extension(body: &str, extensions: &[&str], include_php_digits: bool) -> bool {
92    let Some(dot) = body.rfind('.') else {
93        return false;
94    };
95    let tail = &body[dot + 1..];
96    if tail.is_empty() {
97        return false;
98    }
99    if tail.eq_ignore_ascii_case("php") {
100        return true;
101    }
102    if include_php_digits
103        && tail.len() > 3
104        && tail[..3].eq_ignore_ascii_case("php")
105        && tail[3..].bytes().all(|b| b.is_ascii_digit())
106    {
107        return true;
108    }
109    extensions.iter().any(|ext| tail.eq_ignore_ascii_case(ext))
110}
111
112fn benign_terminal(body: &str) -> bool {
113    let Some(dot) = body.rfind('.') else {
114        return false;
115    };
116    let tail = &body[dot + 1..];
117    BENIGN_TERMINAL
118        .iter()
119        .any(|ext| tail.eq_ignore_ascii_case(ext))
120}
121
122fn is_double_extension(body: &str) -> bool {
123    if !benign_terminal(body) {
124        return false;
125    }
126    let Some(final_dot) = body.rfind('.') else {
127        return false;
128    };
129    for (idx, c) in body.char_indices() {
130        if c != '.' || idx >= final_dot {
131            continue;
132        }
133        let Some(marker_end) = dangerous_marker_at(body, idx, DOUBLE_EXT) else {
134            continue;
135        };
136        if marker_end > final_dot {
137            continue;
138        }
139        let suffix_ok =
140            char_at(body, marker_end).is_none_or(|(_, c)| !matches!(c, ' ' | '"' | '\''));
141        if marker_end == final_dot || (marker_end < final_dot && suffix_ok) {
142            return true;
143        }
144    }
145    false
146}
147
148/// Truncation marker: `(?:%00|\u0000|\x00|\0|<NUL>|;)` raw, `(?:<NUL>|;)`
149/// decoded, each also allowing `.` + end of body.
150fn truncation_marker_at(body: &str, pos: usize, decoded: bool) -> bool {
151    if pos > body.len() {
152        return false;
153    }
154    if pos == body.len() - 1 && body.as_bytes()[pos] == b'.' {
155        return true;
156    }
157    if decoded {
158        return body[pos..].starts_with('\u{0}') || body[pos..].starts_with(';');
159    }
160    body[pos..].starts_with("%00")
161        // the `\u0000`/`\x00` escape texts fold case under the row's builtin
162        // IGNORECASE (`\U0000`, `\X00`)
163        || ascii_starts_with_ignore_case(body, pos, r"\u0000")
164        || ascii_starts_with_ignore_case(body, pos, r"\x00")
165        || body[pos..].starts_with(r"\0")
166        || body[pos..].starts_with('\u{0}')
167        || body[pos..].starts_with(';')
168}
169
170fn is_truncation(body: &str, decoded: bool) -> bool {
171    for (idx, c) in body.char_indices() {
172        if c != '.' {
173            continue;
174        }
175        let Some(marker_end) = dangerous_marker_at(body, idx, DOUBLE_EXT) else {
176            continue;
177        };
178        if truncation_marker_at(body, marker_end, decoded) {
179            return true;
180        }
181    }
182    false
183}
184
185fn kind_matches(
186    body: &str,
187    source: &str,
188    dangerous_source: &str,
189    double_source: &str,
190    trunc_source: &str,
191    decoded_trunc_source: &str,
192) -> bool {
193    if source == dangerous_source {
194        return terminal_extension(body, DANGEROUS_EXT, true);
195    }
196    if source == double_source {
197        return is_double_extension(body);
198    }
199    if source == trunc_source {
200        return is_truncation(body, false);
201    }
202    if source == decoded_trunc_source {
203        return is_truncation(body, true);
204    }
205    false
206}
207
208fn file_upload_match_start(content: &str, filename_start: usize) -> Option<usize> {
209    let mut cursor = filename_start;
210    let mut first_newline: Option<usize> = None;
211    loop {
212        if cursor == 0 {
213            return Some(0);
214        }
215        let Some((i, c)) = char_before(content, cursor) else {
216            return Some(0);
217        };
218        if is_whitespace(c) {
219            if c == '\n' {
220                first_newline = Some(i);
221            }
222            cursor = i;
223            continue;
224        }
225        if matches!(c, ';' | ',' | ':' | '\n') {
226            return Some(i);
227        }
228        return first_newline;
229    }
230}
231
232fn skip_whitespace(content: &str, mut cursor: usize) -> usize {
233    while let Some((i, c)) = char_at(content, cursor)
234        && is_whitespace(c)
235    {
236        cursor = i + c.len_utf8();
237    }
238    cursor
239}
240
241fn quoted_candidate(content: &str, filename_start: usize) -> Option<(usize, usize, usize)> {
242    let match_start = file_upload_match_start(content, filename_start)?;
243    let mut cursor = skip_whitespace(content, filename_start + "filename".len());
244    if char_at(content, cursor).map(|(_, c)| c) != Some('=') {
245        return None;
246    }
247    cursor = skip_whitespace(content, cursor + 1);
248    let open = char_at(content, cursor).map(|(_, c)| c)?;
249    if open != '"' && open != '\'' {
250        return None;
251    }
252    let body_start = cursor + 1;
253    let tail = &content[body_start..];
254    let quote_index = tail.find(['"', '\''])?;
255    Some((match_start, body_start, body_start + quote_index + 1))
256}
257
258/// `_file_upload_scan_matches`: candidates from `filename` tokens, classified
259/// per pattern; the emitted match spans the validated `[match_start, end)`.
260#[must_use]
261pub fn file_upload_scan_matches(
262    content: &str,
263    source: &str,
264    dangerous_source: &str,
265    double_source: &str,
266    trunc_source: &str,
267    decoded_trunc_source: &str,
268) -> Vec<Candidate> {
269    let mut matches = Vec::new();
270    let Ok(filename_re) = super::pyregex::PyRegex::compile("(?i)filename", false) else {
271        return matches;
272    };
273    let mut last_end = 0usize;
274    for m in filename_re.re().find_iter(content) {
275        let Some((start, body_start, end)) = quoted_candidate(content, m.start()) else {
276            continue;
277        };
278        if start < last_end {
279            continue;
280        }
281        let body = &content[body_start..end - 1];
282        if !kind_matches(
283            body,
284            source,
285            dangerous_source,
286            double_source,
287            trunc_source,
288            decoded_trunc_source,
289        ) {
290            continue;
291        }
292        matches.push(Candidate::new(start, end));
293        last_end = end;
294    }
295    matches
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    const DANGEROUS: &str = "DANGEROUS";
303    const DOUBLE: &str = "DOUBLE";
304    const TRUNC: &str = "TRUNC";
305    const DECODED_TRUNC: &str = "DECODED_TRUNC";
306
307    #[test]
308    fn dangerous_extension_fires() {
309        let ms = file_upload_scan_matches(
310            "; filename=\"shell.php\"",
311            DANGEROUS,
312            DANGEROUS,
313            DOUBLE,
314            TRUNC,
315            DECODED_TRUNC,
316        );
317        assert_eq!(ms.len(), 1);
318        assert_eq!(
319            ms[0].text("; filename=\"shell.php\""),
320            "; filename=\"shell.php\""
321        );
322    }
323
324    #[test]
325    fn double_extension_fires() {
326        let ms = file_upload_scan_matches(
327            "filename=\"report.php.jpg\"",
328            DOUBLE,
329            DANGEROUS,
330            DOUBLE,
331            TRUNC,
332            DECODED_TRUNC,
333        );
334        assert_eq!(ms.len(), 1);
335    }
336
337    #[test]
338    fn plain_image_is_benign() {
339        assert!(
340            file_upload_scan_matches(
341                "filename=\"photo.jpg\"",
342                DOUBLE,
343                DANGEROUS,
344                DOUBLE,
345                TRUNC,
346                DECODED_TRUNC
347            )
348            .is_empty()
349        );
350        assert!(
351            file_upload_scan_matches(
352                "filename=\"doc.pdf\"",
353                DANGEROUS,
354                DANGEROUS,
355                DOUBLE,
356                TRUNC,
357                DECODED_TRUNC
358            )
359            .is_empty()
360        );
361    }
362
363    #[test]
364    fn truncation_fires() {
365        let ms = file_upload_scan_matches(
366            "filename=\"shell.php%00.jpg\"",
367            TRUNC,
368            DANGEROUS,
369            DOUBLE,
370            TRUNC,
371            DECODED_TRUNC,
372        );
373        assert_eq!(ms.len(), 1);
374        let ms = file_upload_scan_matches(
375            "filename=\"shell.php;.jpg\"",
376            TRUNC,
377            DANGEROUS,
378            DOUBLE,
379            TRUNC,
380            DECODED_TRUNC,
381        );
382        assert_eq!(ms.len(), 1);
383    }
384
385    #[test]
386    fn phps_branch_reached_after_php_digits() {
387        // php\d* fails via the alnum suffix check; the phps branch must win
388        let ms = file_upload_scan_matches(
389            "filename=\"x.phps.jpg\"",
390            DOUBLE,
391            DANGEROUS,
392            DOUBLE,
393            TRUNC,
394            DECODED_TRUNC,
395        );
396        assert_eq!(ms.len(), 1);
397    }
398
399    #[test]
400    fn filename_needs_boundary() {
401        // "myfilename" has no boundary before `filename` at offset 2
402        assert!(
403            file_upload_scan_matches(
404                "myfilename=\"shell.php\"",
405                DANGEROUS,
406                DANGEROUS,
407                DOUBLE,
408                TRUNC,
409                DECODED_TRUNC
410            )
411            .is_empty()
412        );
413    }
414}