1use super::chars_util::{char_at, char_before};
12use super::pyregex::Candidate;
13
14pub 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
21pub 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
28pub 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
38fn 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#[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 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
90fn 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))] 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))] 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
156fn 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 || 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))] 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#[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))] 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 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 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 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 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 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 assert_eq!(dangerous_marker_at("x.php333a", 1, ext), None);
493 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 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 assert_eq!(dangerous_marker_at("x.php333 ok", 1, ext), Some(8));
510 assert_eq!(dangerous_marker_at("x.php ok", 1, ext), Some(5));
512 assert_eq!(dangerous_marker_at("x.phpy", 1, ext), None);
514 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 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 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 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 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 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 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 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 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 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 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 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 assert!(is_double_extension("x.y.php.docx"));
657 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 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}