1use crate::detect::DetectConfig;
39use crate::json_walk::{append_json_walk_entries, parse_ordered_json};
40use crate::multipart_scan::{MultipartPart, parse_multipart_parts};
41
42pub const FORM_FIELD_CONTEXT: &str = "request_body:form_field";
44pub const MULTIPART_FIELD_CONTEXT: &str = "request_body:multipart_field";
46pub const REQUEST_BODY_CONTEXT: &str = "request_body";
48pub const MULTIPART_FILE_LABEL: &str = "file";
50pub const MONGO_OPERATOR_CATEGORY: &str = "nosql";
52
53#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct BodyScanValue {
57 pub content: String,
58 pub context: String,
59 pub forced_category: Option<&'static str>,
62}
63
64impl BodyScanValue {
65 pub(crate) fn plain(text: impl Into<String>, ctx: &str) -> Self {
66 Self {
67 content: text.into(),
68 context: ctx.to_owned(),
69 forced_category: None,
70 }
71 }
72
73 pub(crate) fn forced(content: impl Into<String>, category: &'static str) -> Self {
74 Self {
75 content: content.into(),
76 context: REQUEST_BODY_CONTEXT.to_owned(),
77 forced_category: Some(category),
78 }
79 }
80}
81
82#[must_use]
89pub fn extract_body_scan_values(
90 raw_body: &str,
91 content_type: &str,
92 config: &DetectConfig,
93) -> Vec<BodyScanValue> {
94 let lowered = content_type.to_ascii_lowercase();
95 if lowered.contains("application/x-www-form-urlencoded") {
96 return append_form_body_values(Vec::new(), raw_body);
97 }
98 if lowered.contains("multipart/form-data") {
99 return append_multipart_body_values(Vec::new(), raw_body, content_type, config);
100 }
101 if lowered.contains("json")
102 && let Some(root) = parse_ordered_json(raw_body)
103 {
104 return append_json_walk_entries(Vec::new(), &root, REQUEST_BODY_CONTEXT);
105 }
106 vec![BodyScanValue::plain(raw_body, REQUEST_BODY_CONTEXT)]
107}
108
109fn append_form_body_values(mut values: Vec<BodyScanValue>, raw_body: &str) -> Vec<BodyScanValue> {
115 for pair in parse_form_pairs(raw_body) {
116 values.push(BodyScanValue::plain(pair.name, REQUEST_BODY_CONTEXT));
117 values = append_field_body_value(values, &pair.value, FORM_FIELD_CONTEXT);
118 }
119 values
120}
121
122fn append_field_body_value(
130 mut values: Vec<BodyScanValue>,
131 text: &str,
132 ctx: &str,
133) -> Vec<BodyScanValue> {
134 if let Some(root) = parse_ordered_json(text) {
135 let walk_context = format!("{ctx}{}", crate::detect::EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX);
136 values = append_json_walk_entries(values, &root, &walk_context);
137 }
138 values.push(BodyScanValue::plain(text, ctx));
139 values
140}
141
142fn append_multipart_body_values(
147 mut values: Vec<BodyScanValue>,
148 raw_body: &str,
149 content_type: &str,
150 config: &DetectConfig,
151) -> Vec<BodyScanValue> {
152 let (_, params) = parse_media_type_params(content_type);
153 let boundary = param_lookup(¶ms, "boundary").unwrap_or_default();
154 let parts = parse_multipart_parts(raw_body, boundary);
155 if parts.is_empty() {
156 values.push(BodyScanValue::plain(raw_body, REQUEST_BODY_CONTEXT));
157 return values;
158 }
159 for part in parts {
160 append_multipart_part_values(&mut values, &part, config);
161 }
162 values
163}
164
165fn append_multipart_part_values(
174 values: &mut Vec<BodyScanValue>,
175 part: &MultipartPart<'_>,
176 config: &DetectConfig,
177) {
178 let (name, has_name) = part_disposition_param(part, "name");
179 let (plain_filename, plain_found) = part_disposition_param(part, "filename");
180 let (filename, has_filename) = if plain_found {
181 (plain_filename, true)
182 } else {
183 part_rfc2231_filename(part).map_or((String::new(), false), |f| (f, true))
184 };
185 let label: std::borrow::Cow<'_, str> = if has_name {
186 std::borrow::Cow::Borrowed(name.as_str())
187 } else {
188 std::borrow::Cow::Borrowed(MULTIPART_FILE_LABEL)
189 };
190
191 let mut entries: Vec<String> = Vec::new();
192 if has_filename {
193 let sanitized = filename.replace(['"', '\''], "");
194 entries.push(format!("filename=\"{sanitized}\""));
195 }
196 for header in &part.headers {
197 entries.push(format!("{}: {}", header.name, header.value));
198 }
199 let payload = part.payload;
200 if has_filename && crate::binary_islands::value_is_binary_like(payload) {
201 entries.extend(
202 crate::binary_islands::extract_binary_islands(payload, config.binary_min_run_length)
203 .into_iter()
204 .map(String::from),
205 );
206 } else if !payload.is_empty() {
207 entries.push(payload.to_owned());
208 }
209 if entries.is_empty() {
210 return;
211 }
212 values.push(BodyScanValue::plain(label.as_ref(), REQUEST_BODY_CONTEXT));
213 for entry in &entries {
214 *values = append_field_body_value(std::mem::take(values), entry, MULTIPART_FIELD_CONTEXT);
215 }
216}
217
218fn part_disposition_param(part: &MultipartPart<'_>, param: &str) -> (String, bool) {
223 first_header_value(part, "content-disposition").map_or_else(
224 || (String::new(), false),
225 |value| {
226 let (_, params) = parse_header_params(&value);
227 param_lookup(¶ms, param)
228 .map_or_else(|| (String::new(), false), |found| (found.to_owned(), true))
229 },
230 )
231}
232
233fn part_rfc2231_filename(part: &MultipartPart<'_>) -> Option<String> {
238 let value = first_header_value(part, "content-disposition")?;
239 let (_, params) = parse_header_params(&value);
240 if let Some(extended) = param_lookup(¶ms, "filename*") {
241 return Some(decode_rfc2231_value(extended));
242 }
243 let mut pieces = String::new();
244 let mut index = 0_usize;
245 while let Some(piece) = param_lookup(¶ms, &format!("filename*{index}*")) {
246 pieces.push_str(piece);
247 index += 1;
248 }
249 if index == 0 {
250 return None;
251 }
252 Some(decode_rfc2231_value(&pieces))
253}
254
255fn decode_rfc2231_value(value: &str) -> String {
258 let rest = value.split_once('\'').map_or(value, |(_, after)| {
261 after.split_once('\'').map_or(value, |(_, tail)| tail)
262 });
263 percent_decode_tolerant(rest)
264}
265
266fn first_header_value(part: &MultipartPart<'_>, lower_name: &str) -> Option<String> {
267 part.headers
268 .iter()
269 .find(|h| h.name.eq_ignore_ascii_case(lower_name))
270 .map(|h| h.value.clone())
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct FormPair {
276 pub name: String,
277 pub value: String,
278}
279
280#[must_use]
287pub fn parse_form_pairs(raw_body: &str) -> Vec<FormPair> {
288 let mut pairs = Vec::new();
289 for chunk in raw_body.split('&') {
290 if chunk.is_empty() {
291 continue;
292 }
293 let (name, value) = chunk
294 .find('=')
295 .map_or((chunk, ""), |idx| (&chunk[..idx], &chunk[idx + 1..]));
296 pairs.push(FormPair {
297 name: unquote_plus(name),
298 value: unquote_plus(value),
299 });
300 }
301 pairs
302}
303
304#[must_use]
310pub fn unquote_plus(s: &str) -> String {
311 if !s.contains('+') && !s.contains('%') {
312 return s.to_owned();
313 }
314 let mut out = Vec::with_capacity(s.len());
315 let bytes = s.as_bytes();
316 let mut i = 0;
317 while i < bytes.len() {
318 match bytes[i] {
319 b'+' => out.push(b' '),
320 b'%' if i + 2 < bytes.len() => {
321 if let (Some(h1), Some(h2)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
322 out.push(h1 << 4 | h2);
323 i += 3;
324 continue;
325 }
326 out.push(b'%');
327 }
328 b => out.push(b),
329 }
330 i += 1;
331 }
332 String::from_utf8_lossy(&out).into_owned()
333}
334
335const fn hex_val(c: u8) -> Option<u8> {
336 match c {
337 b'0'..=b'9' => Some(c - b'0'),
338 b'a'..=b'f' => Some(c - b'a' + 10),
339 b'A'..=b'F' => Some(c - b'A' + 10),
340 _ => None,
341 }
342}
343
344#[must_use]
347pub fn percent_decode_tolerant(s: &str) -> String {
348 if !s.contains('%') {
349 return s.to_owned();
350 }
351 let bytes = s.as_bytes();
352 let mut out = Vec::with_capacity(bytes.len());
353 let mut i = 0;
354 while i < bytes.len() {
355 if bytes[i] == b'%'
356 && i + 2 < bytes.len()
357 && let (Some(h1), Some(h2)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
358 {
359 out.push(h1 << 4 | h2);
360 i += 3;
361 continue;
362 }
363 out.push(bytes[i]);
364 i += 1;
365 }
366 String::from_utf8_lossy(&out).into_owned()
367}
368
369pub type HeaderParams = Vec<(String, String)>;
371
372#[must_use]
379pub fn parse_media_type_params(value: &str) -> (String, HeaderParams) {
380 let mut main_type = String::new();
381 let mut params = HeaderParams::new();
382 for (index, piece) in split_header_params(value).into_iter().enumerate() {
383 if index == 0 {
384 main_type = piece.trim().to_ascii_lowercase();
385 continue;
386 }
387 if let Some((name, param)) = split_param_piece(&piece) {
388 params.push((name, param));
389 }
390 }
391 (main_type, params)
392}
393
394#[must_use]
398pub fn parse_header_params(value: &str) -> (String, HeaderParams) {
399 let pieces = split_header_params(value);
400 let mut params = HeaderParams::new();
401 for piece in pieces.iter().skip(1) {
402 if let Some((name, param)) = split_param_piece(piece) {
403 params.push((name, param));
404 }
405 }
406 let main = pieces.first().map_or_else(String::new, String::clone);
407 (main.trim().to_owned(), params)
408}
409
410#[must_use]
412pub fn param_lookup<'a>(params: &'a HeaderParams, name: &str) -> Option<&'a str> {
413 params
414 .iter()
415 .rev()
416 .find(|(key, _)| key == name)
417 .map(|(_, value)| value.as_str())
418}
419
420fn split_param_piece(piece: &str) -> Option<(String, String)> {
421 let idx = piece.find('=')?;
422 let name = piece[..idx].trim().to_ascii_lowercase();
423 let value = unquote_header_param(piece[idx + 1..].trim());
424 Some((name, value))
425}
426
427fn unquote_header_param(value: &str) -> String {
431 let bytes = value.as_bytes();
432 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
433 return value.to_owned();
434 }
435 let inner = &value[1..value.len() - 1];
436 let mut out = Vec::with_capacity(inner.len());
437 let chars = inner.as_bytes();
438 let mut i = 0;
439 while i < chars.len() {
440 if chars[i] == b'\\'
441 && i + 1 < chars.len()
442 && (chars[i + 1] == b'"' || chars[i + 1] == b'\\')
443 {
444 out.push(chars[i + 1]);
445 i += 2;
446 continue;
447 }
448 out.push(chars[i]);
449 i += 1;
450 }
451 String::from_utf8_lossy(&out).into_owned()
452}
453
454#[must_use]
457pub fn split_header_params(value: &str) -> Vec<String> {
458 let mut pieces = Vec::new();
459 let mut start = 0_usize;
460 let mut in_quotes = false;
461 let bytes = value.as_bytes();
462 let mut i = 0;
463 while i < bytes.len() {
464 let c = bytes[i];
465 if in_quotes && c == b'\\' {
466 i += 2;
467 continue;
468 }
469 match c {
470 b'"' => in_quotes = !in_quotes,
471 b';' if !in_quotes => {
472 pieces.push(value[start..i].to_owned());
473 start = i + 1;
474 }
475 _ => {}
476 }
477 i += 1;
478 }
479 pieces.push(value[start..].to_owned());
480 pieces
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486 use crate::detect::DetectConfig;
487
488 fn corpus_config() -> DetectConfig {
489 DetectConfig {
490 max_content_length: 10_000,
491 max_full_scan_bytes: 262_144,
492 preserve_attack_patterns: true,
493 semantic_threshold: 0.7,
494 threat_score_threshold: 1.0,
495 binary_min_run_length: 16,
496 }
497 }
498
499 fn extract(raw_body: &str, content_type: &str) -> Vec<(String, String)> {
500 extract_body_scan_values(raw_body, content_type, &corpus_config())
501 .into_iter()
502 .map(|v| (v.context, v.content))
503 .collect()
504 }
505
506 #[test]
507 fn parse_form_pairs_mirrors_parse_qsl() {
508 let cases: &[(&str, Vec<(&str, &str)>)] = &[
509 ("a=b&c", vec![("a", "b"), ("c", "")]),
510 (
511 "a=b=c&d=&=v&+x=%2F%zz&a=b",
512 vec![
513 ("a", "b=c"),
514 ("d", ""),
515 ("", "v"),
516 (" x", "/%zz"),
517 ("a", "b"),
518 ],
519 ),
520 ("a=1&&b=2&", vec![("a", "1"), ("b", "2")]),
521 ("", vec![]),
522 ];
523 for (body, want) in cases {
524 let got = parse_form_pairs(body);
525 let got: Vec<(&str, &str)> = got
526 .iter()
527 .map(|p| (p.name.as_str(), p.value.as_str()))
528 .collect();
529 let want: Vec<(&str, &str)> = want.iter().map(|(a, b)| (*a, *b)).collect();
530 assert_eq!(got, want, "parse_form_pairs({body:?})");
531 }
532 }
533
534 #[test]
535 fn form_body_extraction_contexts_and_order() {
536 let values = extract(
537 "system=<script>alert(1)</script>¬e=hello",
538 "application/x-www-form-urlencoded",
539 );
540 assert_eq!(
541 values,
542 vec![
543 (REQUEST_BODY_CONTEXT.to_owned(), "system".to_owned()),
544 (
545 FORM_FIELD_CONTEXT.to_owned(),
546 "<script>alert(1)</script>".to_owned()
547 ),
548 (REQUEST_BODY_CONTEXT.to_owned(), "note".to_owned()),
549 (FORM_FIELD_CONTEXT.to_owned(), "hello".to_owned()),
550 ]
551 );
552 }
553
554 #[test]
555 fn form_field_embedded_json_walk_contexts() {
556 let values = extract(
557 "data={\"a\":\"<script>alert(1)</script>\"}",
558 "application/x-www-form-urlencoded",
559 );
560 assert_eq!(
561 values,
562 vec![
563 (REQUEST_BODY_CONTEXT.to_owned(), "data".to_owned()),
564 (REQUEST_BODY_CONTEXT.to_owned(), "a".to_owned()),
565 (
566 "request_body:form_field:embedded_json".to_owned(),
567 "<script>alert(1)</script>".to_owned()
568 ),
569 (
572 "request_body:form_field".to_owned(),
573 "{\"a\":\"<script>alert(1)</script>\"}".to_owned()
574 ),
575 ]
576 );
577 }
578
579 #[test]
580 fn multipart_text_part_entries() {
581 let body =
582 "--B0\r\nContent-Disposition: form-data; name=\"note\"\r\n\r\nhello\r\n--B0--\r\n";
583 let values = extract(body, "multipart/form-data; boundary=B0");
584 assert_eq!(
585 values,
586 vec![
587 (REQUEST_BODY_CONTEXT.to_owned(), "note".to_owned()),
588 (
589 MULTIPART_FIELD_CONTEXT.to_owned(),
590 "Content-Disposition: form-data; name=\"note\"".to_owned()
591 ),
592 (MULTIPART_FIELD_CONTEXT.to_owned(), "hello".to_owned()),
593 ]
594 );
595 }
596
597 #[test]
598 fn multipart_file_part_entries_include_filename_and_headers() {
599 let body = "--B0\r\nContent-Disposition: form-data; name=\"upload\"; filename=\"report.pdf\"\r\n\r\nbinary-file-payload\r\n--B0--\r\n";
600 let values = extract(body, "multipart/form-data; boundary=B0");
601 assert_eq!(
602 values,
603 vec![
604 (REQUEST_BODY_CONTEXT.to_owned(), "upload".to_owned()),
605 (
606 MULTIPART_FIELD_CONTEXT.to_owned(),
607 "filename=\"report.pdf\"".to_owned()
608 ),
609 (
610 MULTIPART_FIELD_CONTEXT.to_owned(),
611 "Content-Disposition: form-data; name=\"upload\"; filename=\"report.pdf\""
612 .to_owned()
613 ),
614 (
615 MULTIPART_FIELD_CONTEXT.to_owned(),
616 "binary-file-payload".to_owned()
617 ),
618 ]
619 );
620 }
621
622 #[test]
623 fn multipart_json_body_walk_leaf_contexts() {
624 let values = extract(
625 "{\"system\":\"1 OR 1=1\",\"meta\":{\"deep\":\"x\"}}",
626 "application/json",
627 );
628 assert_eq!(
629 values,
630 vec![
631 (REQUEST_BODY_CONTEXT.to_owned(), "system".to_owned()),
632 (REQUEST_BODY_CONTEXT.to_owned(), "1 OR 1=1".to_owned()),
633 (REQUEST_BODY_CONTEXT.to_owned(), "meta".to_owned()),
634 (REQUEST_BODY_CONTEXT.to_owned(), "deep".to_owned()),
635 (REQUEST_BODY_CONTEXT.to_owned(), "x".to_owned()),
636 ]
637 );
638 }
639
640 #[test]
641 fn mongo_operator_key_forces_a_nosql_hit() {
642 let values = extract_body_scan_values(
643 "{\"$where\": \"1 OR 1=1\"}",
644 "application/json",
645 &corpus_config(),
646 );
647 assert_eq!(values.len(), 1);
648 assert_eq!(values[0].content, "$where");
649 assert_eq!(values[0].forced_category, Some(MONGO_OPERATOR_CATEGORY));
650 }
651
652 #[test]
653 fn non_json_body_with_json_content_type_falls_back_to_blob() {
654 let values = extract("1 OR 1=1", "application/json");
655 assert_eq!(
656 values,
657 vec![(REQUEST_BODY_CONTEXT.to_owned(), "1 OR 1=1".to_owned())]
658 );
659 }
660
661 #[test]
662 fn unknown_content_type_falls_back_to_blob() {
663 let values = extract("anything at all", "application/octet-stream");
664 assert_eq!(
665 values,
666 vec![(
667 REQUEST_BODY_CONTEXT.to_owned(),
668 "anything at all".to_owned()
669 )]
670 );
671 }
672
673 #[test]
674 fn empty_file_part_content_yields_no_scan_for_that_part() {
675 let body = "--B0\r\nContent-Disposition: form-data; name=\"f\"; filename=\"empty.bin\"\r\n\r\n\r\n--B0--\r\n";
676 let values = extract(body, "multipart/form-data; boundary=B0");
677 assert_eq!(values.len(), 3);
679 assert_eq!(values[0].1, "f");
680 assert_eq!(values[1].1, "filename=\"empty.bin\"");
681 assert!(!values.iter().any(|(_, content)| content == "\u{0}"));
682 }
683
684 #[test]
685 fn part_without_content_disposition_still_yields_entries() {
686 let body =
687 "--B0\r\nContent-Type: text/plain\r\n\r\n<script>alert(1)</script>\r\n--B0--\r\n";
688 let values = extract(body, "multipart/form-data; boundary=B0");
689 assert_eq!(
690 values,
691 vec![
692 (
693 REQUEST_BODY_CONTEXT.to_owned(),
694 MULTIPART_FILE_LABEL.to_owned()
695 ),
696 (
697 MULTIPART_FIELD_CONTEXT.to_owned(),
698 "Content-Type: text/plain".to_owned()
699 ),
700 (
701 MULTIPART_FIELD_CONTEXT.to_owned(),
702 "<script>alert(1)</script>".to_owned()
703 ),
704 ]
705 );
706 }
707
708 #[test]
709 fn rfc2231_extended_filename_is_detected() {
710 let body = "--B0\r\nContent-Disposition: form-data; name=\"file\"; filename*=UTF-8''shell.php%0A.jpg\r\n\r\nharmless\r\n--B0--\r\n";
711 let values = extract(body, "multipart/form-data; boundary=B0");
712 assert_eq!(values[1].1, "filename=\"shell.php\n.jpg\"");
713 }
714
715 #[test]
716 fn escaped_quote_in_filename_is_unescaped_and_stripped() {
717 let body = "--B0\r\nContent-Disposition: form-data; name=\"file\"; filename=\"shell\\\".php%00.jpg\"\r\n\r\nharmless\r\n--B0--\r\n";
718 let values = extract(body, "multipart/form-data; boundary=B0");
719 assert_eq!(values[1].1, "filename=\"shell.php%00.jpg\"");
720 }
721
722 #[test]
723 fn binary_like_file_part_reduces_to_islands() {
724 let payload = "\u{FFFD}".repeat(8) + &"a".repeat(20) + &"\u{FFFD}".repeat(8);
725 let body = format!(
726 "--B0\r\nContent-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n\r\n{payload}\r\n--B0--\r\n"
727 );
728 let values = extract(&body, "multipart/form-data; boundary=B0");
729 let last = values.last().expect("island entry");
730 assert_eq!(last.0, MULTIPART_FIELD_CONTEXT);
731 assert_eq!(last.1, "a".repeat(20));
732 }
733
734 #[test]
735 fn mostly_text_file_part_keeps_full_payload_scan() {
736 let payload = "one null\u{0}byte in text".to_owned();
737 let body = format!(
738 "--B0\r\nContent-Disposition: form-data; name=\"file\"; filename=\"note.txt\"\r\n\r\n{payload}\r\n--B0--\r\n"
739 );
740 let values = extract(&body, "multipart/form-data; boundary=B0");
741 assert_eq!(values.last().expect("payload").1, payload);
742 }
743
744 #[test]
745 fn boundary_mismatch_falls_back_to_whole_body_blob() {
746 let raw =
747 "--B0\r\nContent-Disposition: form-data; name=\"file\"\r\n\r\npayload\r\n--B0--\r\n";
748 let values = extract(raw, "multipart/form-data; boundary=DOES-NOT-MATCH");
749 assert_eq!(values.len(), 1);
750 assert_eq!(values[0].0, REQUEST_BODY_CONTEXT);
751 assert!(values[0].1.starts_with("--B0"));
752 }
753
754 #[test]
755 fn header_param_splitting_respects_quotes() {
756 let (main, params) = parse_header_params("form-data; name=\"a;b\"; filename=\"x'.y\"");
757 assert_eq!(main, "form-data");
758 assert_eq!(param_lookup(¶ms, "name"), Some("a;b"));
759 assert_eq!(param_lookup(¶ms, "filename"), Some("x'.y"));
760 }
761
762 #[test]
763 fn media_type_params_lowercases_main_type_and_names() {
764 let (main, params) = parse_media_type_params("Multipart/Form-Data; Boundary=B0");
765 assert_eq!(main, "multipart/form-data");
766 assert_eq!(param_lookup(¶ms, "boundary"), Some("B0"));
767 }
768
769 #[test]
770 fn unquote_header_param_keeps_asymmetric_quotes() {
771 assert_eq!(unquote_header_param("\"a\""), "a");
772 assert_eq!(unquote_header_param("\"a"), "\"a");
773 assert_eq!(unquote_header_param("a"), "a");
774 }
775}