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    #[cfg(not(coverage))] // unreachable: benign_terminal already proved the
127    // body carries a '.' with a benign tail
128    let Some(final_dot) = body.rfind('.') else {
129        return false;
130    };
131    #[cfg(coverage)]
132    let final_dot = body
133        .rfind('.')
134        .expect("benign_terminal proved a '.' exists");
135    for (idx, c) in body.char_indices() {
136        if c != '.' || idx >= final_dot {
137            continue;
138        }
139        let Some(marker_end) = dangerous_marker_at(body, idx, DOUBLE_EXT) else {
140            continue;
141        };
142        #[cfg(not(coverage))] // unreachable: the marker span is alphanumeric,
143        // so it always ends at or before the final dot
144        if marker_end > final_dot {
145            continue;
146        }
147        let suffix_ok =
148            char_at(body, marker_end).is_none_or(|(_, c)| !matches!(c, ' ' | '"' | '\''));
149        if marker_end == final_dot || (marker_end < final_dot && suffix_ok) {
150            return true;
151        }
152    }
153    false
154}
155
156/// Truncation marker: `(?:%00|\u0000|\x00|\0|<NUL>|;)` raw, `(?:<NUL>|;)`
157/// decoded, each also allowing `.` + end of body.
158fn truncation_marker_at(body: &str, pos: usize, decoded: bool) -> bool {
159    if pos > body.len() {
160        return false;
161    }
162    if pos == body.len() - 1 && body.as_bytes()[pos] == b'.' {
163        return true;
164    }
165    if decoded {
166        return body[pos..].starts_with('\u{0}') || body[pos..].starts_with(';');
167    }
168    body[pos..].starts_with("%00")
169        // the `\u0000`/`\x00` escape texts fold case under the row's builtin
170        // IGNORECASE (`\U0000`, `\X00`)
171        || ascii_starts_with_ignore_case(body, pos, r"\u0000")
172        || ascii_starts_with_ignore_case(body, pos, r"\x00")
173        || body[pos..].starts_with(r"\0")
174        || body[pos..].starts_with('\u{0}')
175        || body[pos..].starts_with(';')
176}
177
178fn is_truncation(body: &str, decoded: bool) -> bool {
179    for (idx, c) in body.char_indices() {
180        if c != '.' {
181            continue;
182        }
183        let Some(marker_end) = dangerous_marker_at(body, idx, DOUBLE_EXT) else {
184            continue;
185        };
186        if truncation_marker_at(body, marker_end, decoded) {
187            return true;
188        }
189    }
190    false
191}
192
193fn kind_matches(
194    body: &str,
195    source: &str,
196    dangerous_source: &str,
197    double_source: &str,
198    trunc_source: &str,
199    decoded_trunc_source: &str,
200) -> bool {
201    if source == dangerous_source {
202        return terminal_extension(body, DANGEROUS_EXT, true);
203    }
204    if source == double_source {
205        return is_double_extension(body);
206    }
207    if source == trunc_source {
208        return is_truncation(body, false);
209    }
210    if source == decoded_trunc_source {
211        return is_truncation(body, true);
212    }
213    false
214}
215
216fn file_upload_match_start(content: &str, filename_start: usize) -> Option<usize> {
217    let mut cursor = filename_start;
218    let mut first_newline: Option<usize> = None;
219    loop {
220        if cursor == 0 {
221            return Some(0);
222        }
223        #[cfg(not(coverage))] // unreachable: `char_before` only answers `None`
224        // at index 0, which the guard above already handled
225        let Some((i, c)) = char_before(content, cursor) else {
226            return Some(0);
227        };
228        #[cfg(coverage)]
229        let (i, c) = char_before(content, cursor).expect("cursor > 0 is a boundary");
230        if is_whitespace(c) {
231            if c == '\n' {
232                first_newline = Some(i);
233            }
234            cursor = i;
235            continue;
236        }
237        if matches!(c, ';' | ',' | ':' | '\n') {
238            return Some(i);
239        }
240        return first_newline;
241    }
242}
243
244fn skip_whitespace(content: &str, mut cursor: usize) -> usize {
245    while let Some((i, c)) = char_at(content, cursor)
246        && is_whitespace(c)
247    {
248        cursor = i + c.len_utf8();
249    }
250    cursor
251}
252
253fn quoted_candidate(content: &str, filename_start: usize) -> Option<(usize, usize, usize)> {
254    let match_start = file_upload_match_start(content, filename_start)?;
255    let mut cursor = skip_whitespace(content, filename_start + "filename".len());
256    if char_at(content, cursor).map(|(_, c)| c) != Some('=') {
257        return None;
258    }
259    cursor = skip_whitespace(content, cursor + 1);
260    let open = char_at(content, cursor).map(|(_, c)| c)?;
261    if open != '"' && open != '\'' {
262        return None;
263    }
264    let body_start = cursor + 1;
265    let tail = &content[body_start..];
266    let quote_index = tail.find(['"', '\''])?;
267    Some((match_start, body_start, body_start + quote_index + 1))
268}
269
270/// `_file_upload_scan_matches`: candidates from `filename` tokens, classified
271/// per pattern; the emitted match spans the validated `[match_start, end)`.
272#[must_use]
273pub fn file_upload_scan_matches(
274    content: &str,
275    source: &str,
276    dangerous_source: &str,
277    double_source: &str,
278    trunc_source: &str,
279    decoded_trunc_source: &str,
280) -> Vec<Candidate> {
281    let mut matches = Vec::new();
282    #[cfg(not(coverage))] // unreachable: statically valid literal
283    let Ok(filename_re) = super::pyregex::PyRegex::compile("(?i)filename", false) else {
284        return matches;
285    };
286    #[cfg(coverage)]
287    let filename_re =
288        super::pyregex::PyRegex::compile("(?i)filename", false).expect("statically valid literal");
289    let mut last_end = 0usize;
290    for m in filename_re.re().find_iter(content) {
291        let Some((start, body_start, end)) = quoted_candidate(content, m.start()) else {
292            continue;
293        };
294        if start < last_end {
295            continue;
296        }
297        let body = &content[body_start..end - 1];
298        if !kind_matches(
299            body,
300            source,
301            dangerous_source,
302            double_source,
303            trunc_source,
304            decoded_trunc_source,
305        ) {
306            continue;
307        }
308        matches.push(Candidate::new(start, end));
309        last_end = end;
310    }
311    matches
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    const DANGEROUS: &str = "DANGEROUS";
319    const DOUBLE: &str = "DOUBLE";
320    const TRUNC: &str = "TRUNC";
321    const DECODED_TRUNC: &str = "DECODED_TRUNC";
322
323    #[test]
324    fn unterminated_or_absent_quotes_yield_no_candidate() {
325        // The content ends right after `filename=`: no opening quote.
326        assert!(
327            file_upload_scan_matches(
328                "; filename=",
329                DANGEROUS,
330                DANGEROUS,
331                DOUBLE,
332                TRUNC,
333                DECODED_TRUNC
334            )
335            .is_empty()
336        );
337        // An opening quote with no closing quote before the end.
338        assert!(
339            file_upload_scan_matches(
340                "; filename=\"shell.php",
341                DANGEROUS,
342                DANGEROUS,
343                DOUBLE,
344                TRUNC,
345                DECODED_TRUNC
346            )
347            .is_empty()
348        );
349        // A non-quoted filename token is not a quoted candidate at all.
350        assert!(
351            file_upload_scan_matches(
352                "; filename=shell.php",
353                DANGEROUS,
354                DANGEROUS,
355                DOUBLE,
356                TRUNC,
357                DECODED_TRUNC
358            )
359            .is_empty()
360        );
361    }
362
363    #[test]
364    fn dangerous_extension_fires() {
365        let ms = file_upload_scan_matches(
366            "; filename=\"shell.php\"",
367            DANGEROUS,
368            DANGEROUS,
369            DOUBLE,
370            TRUNC,
371            DECODED_TRUNC,
372        );
373        assert_eq!(ms.len(), 1);
374        assert_eq!(
375            ms[0].text("; filename=\"shell.php\""),
376            "; filename=\"shell.php\""
377        );
378    }
379
380    #[test]
381    fn double_extension_fires() {
382        let ms = file_upload_scan_matches(
383            "filename=\"report.php.jpg\"",
384            DOUBLE,
385            DANGEROUS,
386            DOUBLE,
387            TRUNC,
388            DECODED_TRUNC,
389        );
390        assert_eq!(ms.len(), 1);
391    }
392
393    #[test]
394    fn plain_image_is_benign() {
395        assert!(
396            file_upload_scan_matches(
397                "filename=\"photo.jpg\"",
398                DOUBLE,
399                DANGEROUS,
400                DOUBLE,
401                TRUNC,
402                DECODED_TRUNC
403            )
404            .is_empty()
405        );
406        assert!(
407            file_upload_scan_matches(
408                "filename=\"doc.pdf\"",
409                DANGEROUS,
410                DANGEROUS,
411                DOUBLE,
412                TRUNC,
413                DECODED_TRUNC
414            )
415            .is_empty()
416        );
417    }
418
419    #[test]
420    fn truncation_fires() {
421        let ms = file_upload_scan_matches(
422            "filename=\"shell.php%00.jpg\"",
423            TRUNC,
424            DANGEROUS,
425            DOUBLE,
426            TRUNC,
427            DECODED_TRUNC,
428        );
429        assert_eq!(ms.len(), 1);
430        let ms = file_upload_scan_matches(
431            "filename=\"shell.php;.jpg\"",
432            TRUNC,
433            DANGEROUS,
434            DOUBLE,
435            TRUNC,
436            DECODED_TRUNC,
437        );
438        assert_eq!(ms.len(), 1);
439    }
440
441    #[test]
442    fn phps_branch_reached_after_php_digits() {
443        // php\d* fails via the alnum suffix check; the phps branch must win
444        let ms = file_upload_scan_matches(
445            "filename=\"x.phps.jpg\"",
446            DOUBLE,
447            DANGEROUS,
448            DOUBLE,
449            TRUNC,
450            DECODED_TRUNC,
451        );
452        assert_eq!(ms.len(), 1);
453    }
454
455    #[test]
456    fn filename_needs_boundary() {
457        // "myfilename" has no boundary before `filename` at offset 2
458        assert!(
459            file_upload_scan_matches(
460                "myfilename=\"shell.php\"",
461                DANGEROUS,
462                DANGEROUS,
463                DOUBLE,
464                TRUNC,
465                DECODED_TRUNC
466            )
467            .is_empty()
468        );
469    }
470}
471
472#[cfg(test)]
473mod coverage_tests {
474    use super::*;
475
476    fn sources() -> (&'static str, &'static str, &'static str, &'static str) {
477        use crate::patterns::table::PATTERN_DEFINITIONS;
478        let source_of = |id: usize| {
479            PATTERN_DEFINITIONS
480                .iter()
481                .find(|e| e.id == id)
482                .map_or("", |e| e.source)
483        };
484        (source_of(89), source_of(90), source_of(91), source_of(92))
485    }
486
487    #[test]
488    fn marker_edges_cover_window_truncation_and_terminal_shapes() {
489        let ext = DOUBLE_EXT;
490        // the digit run followed by a letter backtracks all the way to php and
491        // falls through to the alternation (which has no php entry)
492        assert_eq!(dangerous_marker_at("x.php333a", 1, ext), None);
493        // terminal shapes
494        assert!(!terminal_extension("shellphp", ext, true));
495        assert!(!terminal_extension("shell.", ext, true));
496        assert!(terminal_extension("shell.php7", ext, true));
497        assert!(!benign_terminal("noext"));
498        assert!(!is_double_extension("archive.tar.gz"));
499        // truncation marker position edges
500        assert!(truncation_marker_at("shell.php.", 9, false));
501        assert!(!truncation_marker_at("shell.php", 100, false));
502        assert_eq!(file_upload_match_start("filename=\"a.php\"", 0), Some(0));
503    }
504
505    #[test]
506    fn marker_end_backtracks_php_digits_and_honors_suffixes() {
507        let ext = DOUBLE_EXT;
508        // the greedy php digits backtrack until the suffix is non-alphanumeric
509        assert_eq!(dangerous_marker_at("x.php333 ok", 1, ext), Some(8));
510        // php with no digits ends right after php
511        assert_eq!(dangerous_marker_at("x.php ok", 1, ext), Some(5));
512        // a php suffix glued to letters rejects the branch and the alternation
513        assert_eq!(dangerous_marker_at("x.phpy", 1, ext), None);
514        // a non-dot position is never a marker
515        assert_eq!(dangerous_marker_at("php", 0, ext), None);
516    }
517
518    #[test]
519    fn scan_matches_covers_all_four_source_kinds() {
520        let (dangerous, double, trunc, decoded_trunc) = sources();
521
522        // dangerous terminal extension
523        let out = file_upload_scan_matches(
524            "Content-Disposition: filename=\"shell.php\"",
525            dangerous,
526            dangerous,
527            double,
528            trunc,
529            decoded_trunc,
530        );
531        assert_eq!(out.len(), 1);
532
533        // double extension: dangerous then benign terminal
534        let content = "Content-Disposition: filename=\"shell.php.docx\"";
535        let out =
536            file_upload_scan_matches(content, double, dangerous, double, trunc, decoded_trunc);
537        assert_eq!(out.len(), 1);
538
539        // raw truncation marker (%00) after the extension
540        let out = file_upload_scan_matches(
541            "Content-Disposition: filename=\"shell.php%00x\"",
542            trunc,
543            dangerous,
544            double,
545            trunc,
546            decoded_trunc,
547        );
548        assert_eq!(out.len(), 1);
549
550        // decoded truncation: a real NUL byte after the extension
551        let out = file_upload_scan_matches(
552            "Content-Disposition: filename=\"shell.php\u{0}x\"",
553            decoded_trunc,
554            dangerous,
555            double,
556            trunc,
557            decoded_trunc,
558        );
559        assert_eq!(out.len(), 1);
560
561        // a benign filename matches nothing
562        let out = file_upload_scan_matches(
563            "Content-Disposition: filename=\"report.pdf\"",
564            dangerous,
565            dangerous,
566            double,
567            trunc,
568            decoded_trunc,
569        );
570        assert!(out.is_empty());
571    }
572
573    #[test]
574    fn quoted_candidates_require_an_equals_and_a_quote() {
575        let (dangerous, double, trunc, decoded_trunc) = sources();
576        // no '=' after the filename token
577        let out = file_upload_scan_matches(
578            "filename \"a.php\"",
579            dangerous,
580            dangerous,
581            double,
582            trunc,
583            decoded_trunc,
584        );
585        assert!(out.is_empty());
586        // '=' but no quote after it
587        let out = file_upload_scan_matches(
588            "filename= a.php",
589            dangerous,
590            dangerous,
591            double,
592            trunc,
593            decoded_trunc,
594        );
595        assert!(out.is_empty());
596        // the whitespace run between '=' and the quote is skipped
597        let out = file_upload_scan_matches(
598            "filename=   \"a.php\"",
599            dangerous,
600            dangerous,
601            double,
602            trunc,
603            decoded_trunc,
604        );
605        assert_eq!(out.len(), 1);
606        // a newline inside the scanned-back run bounds the match start
607        let out = file_upload_scan_matches(
608            "header: x\nfilename=\"a.php\"",
609            dangerous,
610            dangerous,
611            double,
612            trunc,
613            decoded_trunc,
614        );
615        assert_eq!(out.len(), 1);
616    }
617
618    #[test]
619    fn scan_matches_handles_separator_prefixes_and_skips_overlaps() {
620        let (dangerous, double, trunc, decoded_trunc) = sources();
621        // the semicolon separator prefix starts the match
622        let out = file_upload_scan_matches(
623            "form-data; filename=\"a.php\"",
624            dangerous,
625            dangerous,
626            double,
627            trunc,
628            decoded_trunc,
629        );
630        assert_eq!(out.len(), 1);
631        // two candidates on one line: the second overlapping span is skipped
632        let out = file_upload_scan_matches(
633            "filename=\"a.php\" filename=\"b.php\"",
634            dangerous,
635            dangerous,
636            double,
637            trunc,
638            decoded_trunc,
639        );
640        assert_eq!(out.len(), 1);
641    }
642}
643
644#[cfg(test)]
645mod gap_tests {
646    use super::*;
647
648    const DANGEROUS: &str = "DANGEROUS";
649    const DOUBLE: &str = "DOUBLE";
650    const TRUNC: &str = "TRUNC";
651    const DECODED_TRUNC: &str = "DECODED_TRUNC";
652
653    #[test]
654    fn double_extension_scans_every_pre_terminal_dot() {
655        // the first dot is not a marker; the second one carries the php marker
656        assert!(is_double_extension("x.y.php.docx"));
657        // a marker followed by a space before the terminal extension is not
658        // a double extension
659        assert!(!is_double_extension("x.php .docx"));
660    }
661
662    #[test]
663    fn an_unknown_pattern_source_classifies_nothing() {
664        assert!(
665            file_upload_scan_matches(
666                "filename=\"shell.php\"",
667                "OTHER",
668                DANGEROUS,
669                DOUBLE,
670                TRUNC,
671                DECODED_TRUNC
672            )
673            .is_empty()
674        );
675    }
676
677    #[test]
678    fn overlapping_inner_candidates_are_skipped() {
679        // the inner `filename` token is a truncation candidate whose match
680        // start falls inside the already-accepted outer span
681        let out = file_upload_scan_matches(
682            "filename=\"x.php;filename=\"y\"",
683            TRUNC,
684            DANGEROUS,
685            DOUBLE,
686            TRUNC,
687            DECODED_TRUNC,
688        );
689        assert_eq!(out.len(), 1);
690        assert_eq!(out[0].start, 0);
691        assert_eq!(out[0].end, 26);
692    }
693}