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 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
148fn 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 || 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#[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 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 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}