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
38#[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 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
75fn 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
133fn 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#[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 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 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}