1use crate::detect::DetectConfig;
40use crate::json_walk::{append_json_walk_entries, parse_ordered_json};
41use crate::multipart_scan::{MultipartPart, parse_multipart_parts};
42
43pub const FORM_FIELD_CONTEXT: &str = "request_body:form_field";
45pub const MULTIPART_FIELD_CONTEXT: &str = "request_body:multipart_field";
47pub const REQUEST_BODY_CONTEXT: &str = "request_body";
49pub const MULTIPART_FILE_LABEL: &str = "file";
51pub const MONGO_OPERATOR_CATEGORY: &str = "nosql";
53
54#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct BodyScanValue {
58 pub content: String,
59 pub context: String,
60 pub forced_category: Option<&'static str>,
63}
64
65impl BodyScanValue {
66 pub(crate) fn plain(text: impl Into<String>, ctx: &str) -> Self {
67 Self {
68 content: text.into(),
69 context: ctx.to_owned(),
70 forced_category: None,
71 }
72 }
73
74 pub(crate) fn forced(content: impl Into<String>, category: &'static str) -> Self {
75 Self {
76 content: content.into(),
77 context: REQUEST_BODY_CONTEXT.to_owned(),
78 forced_category: Some(category),
79 }
80 }
81}
82
83#[derive(Debug, Clone, Copy)]
91pub struct ExcludedBodyFields<'a>(&'a std::collections::HashSet<String>);
92
93impl Default for ExcludedBodyFields<'_> {
94 fn default() -> Self {
95 static EMPTY: std::sync::OnceLock<std::collections::HashSet<String>> =
96 std::sync::OnceLock::new();
97 Self(EMPTY.get_or_init(std::collections::HashSet::new))
98 }
99}
100
101impl<'a> ExcludedBodyFields<'a> {
102 #[must_use]
104 pub const fn new(set: &'a std::collections::HashSet<String>) -> Self {
105 Self(set)
106 }
107
108 #[must_use]
110 pub fn is_empty(self) -> bool {
111 self.0.is_empty()
112 }
113
114 pub(crate) fn contains(self, name: &str) -> bool {
117 self.0.contains(name.to_ascii_lowercase().as_str())
118 }
119}
120
121#[must_use]
132pub fn extract_body_scan_values(
133 raw_body: &str,
134 content_type: &str,
135 config: &DetectConfig,
136) -> Vec<BodyScanValue> {
137 extract_body_scan_values_with_exclusions(
138 raw_body,
139 content_type,
140 config,
141 ExcludedBodyFields::default(),
142 )
143}
144
145#[must_use]
149pub fn extract_body_scan_values_with_exclusions(
150 raw_body: &str,
151 content_type: &str,
152 config: &DetectConfig,
153 excluded: ExcludedBodyFields<'_>,
154) -> Vec<BodyScanValue> {
155 let lowered = content_type.to_ascii_lowercase();
156 if lowered.contains("application/x-www-form-urlencoded") {
157 return append_form_body_values(Vec::new(), raw_body, excluded, config.max_json_depth);
158 }
159 if lowered.contains("multipart/form-data") {
160 return append_multipart_body_values(Vec::new(), raw_body, content_type, config, excluded);
161 }
162 if lowered.contains("json")
163 && let Some(root) = parse_ordered_json(raw_body)
164 {
165 return append_json_walk_entries(
166 Vec::new(),
167 &root,
168 REQUEST_BODY_CONTEXT,
169 excluded,
170 config.max_json_depth,
171 );
172 }
173 vec![BodyScanValue::plain(raw_body, REQUEST_BODY_CONTEXT)]
174}
175
176fn append_form_body_values(
182 mut values: Vec<BodyScanValue>,
183 raw_body: &str,
184 excluded: ExcludedBodyFields<'_>,
185 depth_cap: usize,
186) -> Vec<BodyScanValue> {
187 for pair in parse_form_pairs(raw_body) {
188 if excluded.contains(&pair.name) {
190 continue;
191 }
192 values.push(BodyScanValue::plain(pair.name, REQUEST_BODY_CONTEXT));
193 values =
194 append_field_body_value(values, &pair.value, FORM_FIELD_CONTEXT, excluded, depth_cap);
195 }
196 values
197}
198
199fn append_field_body_value(
207 mut values: Vec<BodyScanValue>,
208 text: &str,
209 ctx: &str,
210 excluded: ExcludedBodyFields<'_>,
211 depth_cap: usize,
212) -> Vec<BodyScanValue> {
213 if let Some(root) = parse_ordered_json(text) {
214 let walk_context = format!("{ctx}{}", crate::detect::EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX);
215 values = append_json_walk_entries(values, &root, &walk_context, excluded, depth_cap);
216 }
217 values.push(BodyScanValue::plain(text, ctx));
218 values
219}
220
221fn append_multipart_body_values(
226 mut values: Vec<BodyScanValue>,
227 raw_body: &str,
228 content_type: &str,
229 config: &DetectConfig,
230 excluded: ExcludedBodyFields<'_>,
231) -> Vec<BodyScanValue> {
232 let (_, params) = parse_media_type_params(content_type);
233 let boundary = param_lookup(¶ms, "boundary").unwrap_or_default();
234 let parts = parse_multipart_parts(raw_body, boundary);
235 if parts.is_empty() {
236 values.push(BodyScanValue::plain(raw_body, REQUEST_BODY_CONTEXT));
237 return values;
238 }
239 for part in parts {
240 append_multipart_part_values(&mut values, &part, config, excluded);
241 }
242 values
243}
244
245fn append_multipart_part_values(
254 values: &mut Vec<BodyScanValue>,
255 part: &MultipartPart<'_>,
256 config: &DetectConfig,
257 excluded: ExcludedBodyFields<'_>,
258) {
259 let (name, has_name) = part_disposition_param(part, "name");
260 if has_name && excluded.contains(&name) {
263 return;
264 }
265 let (plain_filename, plain_found) = part_disposition_param(part, "filename");
266 let (filename, has_filename) = if plain_found {
267 (plain_filename, true)
268 } else {
269 part_rfc2231_filename(part).map_or((String::new(), false), |f| (f, true))
270 };
271 let label: std::borrow::Cow<'_, str> = if has_name {
272 std::borrow::Cow::Borrowed(name.as_str())
273 } else {
274 std::borrow::Cow::Borrowed(MULTIPART_FILE_LABEL)
275 };
276
277 let mut entries: Vec<String> = Vec::new();
278 if has_filename {
279 let sanitized = filename.replace(['"', '\''], "");
280 entries.push(format!("filename=\"{sanitized}\""));
281 }
282 for header in &part.headers {
283 entries.push(format!("{}: {}", header.name, header.value));
284 }
285 let payload = part.payload;
286 if has_filename && crate::binary_islands::value_is_binary_like(payload) {
287 entries.extend(
288 crate::binary_islands::extract_binary_islands(payload, config.binary_min_run_length)
289 .into_iter()
290 .map(String::from),
291 );
292 } else if !payload.is_empty() {
293 entries.push(payload.to_owned());
294 }
295 if entries.is_empty() {
296 return;
297 }
298 values.push(BodyScanValue::plain(label.as_ref(), REQUEST_BODY_CONTEXT));
299 for entry in &entries {
300 *values = append_field_body_value(
301 std::mem::take(values),
302 entry,
303 MULTIPART_FIELD_CONTEXT,
304 excluded,
305 config.max_json_depth,
306 );
307 }
308}
309
310fn part_disposition_param(part: &MultipartPart<'_>, param: &str) -> (String, bool) {
315 first_header_value(part, "content-disposition").map_or_else(
316 || (String::new(), false),
317 |value| {
318 let (_, params) = parse_header_params(&value);
319 param_lookup(¶ms, param)
320 .map_or_else(|| (String::new(), false), |found| (found.to_owned(), true))
321 },
322 )
323}
324
325fn part_rfc2231_filename(part: &MultipartPart<'_>) -> Option<String> {
330 let value = first_header_value(part, "content-disposition")?;
331 let (_, params) = parse_header_params(&value);
332 if let Some(extended) = param_lookup(¶ms, "filename*") {
333 return Some(decode_rfc2231_value(extended));
334 }
335 let mut pieces = String::new();
336 let mut index = 0_usize;
337 while let Some(piece) = param_lookup(¶ms, &format!("filename*{index}*")) {
338 pieces.push_str(piece);
339 index += 1;
340 }
341 if index == 0 {
342 return None;
343 }
344 Some(decode_rfc2231_value(&pieces))
345}
346
347fn decode_rfc2231_value(value: &str) -> String {
350 let rest = value.split_once('\'').map_or(value, |(_, after)| {
353 after.split_once('\'').map_or(value, |(_, tail)| tail)
354 });
355 percent_decode_tolerant(rest)
356}
357
358fn first_header_value(part: &MultipartPart<'_>, lower_name: &str) -> Option<String> {
359 part.headers
360 .iter()
361 .find(|h| h.name.eq_ignore_ascii_case(lower_name))
362 .map(|h| h.value.clone())
363}
364
365#[derive(Debug, Clone, PartialEq, Eq)]
367pub struct FormPair {
368 pub name: String,
369 pub value: String,
370}
371
372#[must_use]
379pub fn parse_form_pairs(raw_body: &str) -> Vec<FormPair> {
380 let mut pairs = Vec::new();
381 for chunk in raw_body.split('&') {
382 if chunk.is_empty() {
383 continue;
384 }
385 let (name, value) = chunk
386 .find('=')
387 .map_or((chunk, ""), |idx| (&chunk[..idx], &chunk[idx + 1..]));
388 pairs.push(FormPair {
389 name: unquote_plus(name),
390 value: unquote_plus(value),
391 });
392 }
393 pairs
394}
395
396#[must_use]
402pub fn unquote_plus(s: &str) -> String {
403 if !s.contains('+') && !s.contains('%') {
404 return s.to_owned();
405 }
406 let mut out = Vec::with_capacity(s.len());
407 let bytes = s.as_bytes();
408 let mut i = 0;
409 while i < bytes.len() {
410 match bytes[i] {
411 b'+' => out.push(b' '),
412 b'%' if i + 2 < bytes.len() => {
413 if let (Some(h1), Some(h2)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
414 out.push(h1 << 4 | h2);
415 i += 3;
416 continue;
417 }
418 out.push(b'%');
419 }
420 b => out.push(b),
421 }
422 i += 1;
423 }
424 String::from_utf8_lossy(&out).into_owned()
425}
426
427const fn hex_val(c: u8) -> Option<u8> {
428 match c {
429 b'0'..=b'9' => Some(c - b'0'),
430 b'a'..=b'f' => Some(c - b'a' + 10),
431 b'A'..=b'F' => Some(c - b'A' + 10),
432 _ => None,
433 }
434}
435
436#[must_use]
439pub fn percent_decode_tolerant(s: &str) -> String {
440 if !s.contains('%') {
441 return s.to_owned();
442 }
443 let bytes = s.as_bytes();
444 let mut out = Vec::with_capacity(bytes.len());
445 let mut i = 0;
446 while i < bytes.len() {
447 if bytes[i] == b'%'
448 && i + 2 < bytes.len()
449 && let (Some(h1), Some(h2)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
450 {
451 out.push(h1 << 4 | h2);
452 i += 3;
453 continue;
454 }
455 out.push(bytes[i]);
456 i += 1;
457 }
458 String::from_utf8_lossy(&out).into_owned()
459}
460
461pub type HeaderParams = Vec<(String, String)>;
463
464#[must_use]
471pub fn parse_media_type_params(value: &str) -> (String, HeaderParams) {
472 let mut main_type = String::new();
473 let mut params = HeaderParams::new();
474 for (index, piece) in split_header_params(value).into_iter().enumerate() {
475 if index == 0 {
476 main_type = piece.trim().to_ascii_lowercase();
477 continue;
478 }
479 if let Some((name, param)) = split_param_piece(&piece) {
480 params.push((name, param));
481 }
482 }
483 (main_type, params)
484}
485
486#[must_use]
490pub fn parse_header_params(value: &str) -> (String, HeaderParams) {
491 let pieces = split_header_params(value);
492 let mut params = HeaderParams::new();
493 for piece in pieces.iter().skip(1) {
494 if let Some((name, param)) = split_param_piece(piece) {
495 params.push((name, param));
496 }
497 }
498 let main = pieces.first().map_or_else(String::new, String::clone);
499 (main.trim().to_owned(), params)
500}
501
502#[must_use]
504pub fn param_lookup<'a>(params: &'a HeaderParams, name: &str) -> Option<&'a str> {
505 params
506 .iter()
507 .rev()
508 .find(|(key, _)| key == name)
509 .map(|(_, value)| value.as_str())
510}
511
512fn split_param_piece(piece: &str) -> Option<(String, String)> {
513 let idx = piece.find('=')?;
514 let name = piece[..idx].trim().to_ascii_lowercase();
515 let value = unquote_header_param(piece[idx + 1..].trim());
516 Some((name, value))
517}
518
519fn unquote_header_param(value: &str) -> String {
523 let bytes = value.as_bytes();
524 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
525 return value.to_owned();
526 }
527 let inner = &value[1..value.len() - 1];
528 let mut out = Vec::with_capacity(inner.len());
529 let chars = inner.as_bytes();
530 let mut i = 0;
531 while i < chars.len() {
532 if chars[i] == b'\\'
533 && i + 1 < chars.len()
534 && (chars[i + 1] == b'"' || chars[i + 1] == b'\\')
535 {
536 out.push(chars[i + 1]);
537 i += 2;
538 continue;
539 }
540 out.push(chars[i]);
541 i += 1;
542 }
543 String::from_utf8_lossy(&out).into_owned()
544}
545
546#[must_use]
549pub fn split_header_params(value: &str) -> Vec<String> {
550 let mut pieces = Vec::new();
551 let mut start = 0_usize;
552 let mut in_quotes = false;
553 let bytes = value.as_bytes();
554 let mut i = 0;
555 while i < bytes.len() {
556 let c = bytes[i];
557 if in_quotes && c == b'\\' {
558 i += 2;
559 continue;
560 }
561 match c {
562 b'"' => in_quotes = !in_quotes,
563 b';' if !in_quotes => {
564 pieces.push(value[start..i].to_owned());
565 start = i + 1;
566 }
567 _ => {}
568 }
569 i += 1;
570 }
571 pieces.push(value[start..].to_owned());
572 pieces
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use crate::detect::DetectConfig;
579
580 fn corpus_config() -> DetectConfig {
581 DetectConfig {
582 max_content_length: 10_000,
583 max_full_scan_bytes: 262_144,
584 preserve_attack_patterns: true,
585 semantic_threshold: 0.7,
586 threat_score_threshold: 1.0,
587 binary_min_run_length: 16,
588 max_scan_values: 512,
589 max_scan_chars: 65_536,
590 max_json_depth: 32,
591 }
592 }
593
594 fn extract(raw_body: &str, content_type: &str) -> Vec<(String, String)> {
595 extract_body_scan_values_with_exclusions(
596 raw_body,
597 content_type,
598 &corpus_config(),
599 ExcludedBodyFields::default(),
600 )
601 .into_iter()
602 .map(|v| (v.context, v.content))
603 .collect()
604 }
605
606 #[test]
607 fn parse_form_pairs_mirrors_parse_qsl() {
608 let cases: &[(&str, Vec<(&str, &str)>)] = &[
609 ("a=b&c", vec![("a", "b"), ("c", "")]),
610 (
611 "a=b=c&d=&=v&+x=%2F%zz&a=b",
612 vec![
613 ("a", "b=c"),
614 ("d", ""),
615 ("", "v"),
616 (" x", "/%zz"),
617 ("a", "b"),
618 ],
619 ),
620 ("a=1&&b=2&", vec![("a", "1"), ("b", "2")]),
621 ("", vec![]),
622 ];
623 for (body, want) in cases {
624 let got = parse_form_pairs(body);
625 let got: Vec<(&str, &str)> = got
626 .iter()
627 .map(|p| (p.name.as_str(), p.value.as_str()))
628 .collect();
629 let want: Vec<(&str, &str)> = want.iter().map(|(a, b)| (*a, *b)).collect();
630 assert_eq!(got, want, "parse_form_pairs({body:?})");
631 }
632 }
633
634 #[test]
635 fn form_body_extraction_contexts_and_order() {
636 let values = extract(
637 "system=<script>alert(1)</script>¬e=hello",
638 "application/x-www-form-urlencoded",
639 );
640 assert_eq!(
641 values,
642 vec![
643 (REQUEST_BODY_CONTEXT.to_owned(), "system".to_owned()),
644 (
645 FORM_FIELD_CONTEXT.to_owned(),
646 "<script>alert(1)</script>".to_owned()
647 ),
648 (REQUEST_BODY_CONTEXT.to_owned(), "note".to_owned()),
649 (FORM_FIELD_CONTEXT.to_owned(), "hello".to_owned()),
650 ]
651 );
652 }
653
654 #[test]
655 fn form_field_embedded_json_walk_contexts() {
656 let values = extract(
657 "data={\"a\":\"<script>alert(1)</script>\"}",
658 "application/x-www-form-urlencoded",
659 );
660 assert_eq!(
661 values,
662 vec![
663 (REQUEST_BODY_CONTEXT.to_owned(), "data".to_owned()),
664 (REQUEST_BODY_CONTEXT.to_owned(), "a".to_owned()),
665 (
666 "request_body:form_field:embedded_json".to_owned(),
667 "<script>alert(1)</script>".to_owned()
668 ),
669 (
672 "request_body:form_field".to_owned(),
673 "{\"a\":\"<script>alert(1)</script>\"}".to_owned()
674 ),
675 ]
676 );
677 }
678
679 #[test]
680 fn multipart_text_part_entries() {
681 let body =
682 "--B0\r\nContent-Disposition: form-data; name=\"note\"\r\n\r\nhello\r\n--B0--\r\n";
683 let values = extract(body, "multipart/form-data; boundary=B0");
684 assert_eq!(
685 values,
686 vec![
687 (REQUEST_BODY_CONTEXT.to_owned(), "note".to_owned()),
688 (
689 MULTIPART_FIELD_CONTEXT.to_owned(),
690 "Content-Disposition: form-data; name=\"note\"".to_owned()
691 ),
692 (MULTIPART_FIELD_CONTEXT.to_owned(), "hello".to_owned()),
693 ]
694 );
695 }
696
697 #[test]
698 fn multipart_file_part_entries_include_filename_and_headers() {
699 let body = "--B0\r\nContent-Disposition: form-data; name=\"upload\"; filename=\"report.pdf\"\r\n\r\nbinary-file-payload\r\n--B0--\r\n";
700 let values = extract(body, "multipart/form-data; boundary=B0");
701 assert_eq!(
702 values,
703 vec![
704 (REQUEST_BODY_CONTEXT.to_owned(), "upload".to_owned()),
705 (
706 MULTIPART_FIELD_CONTEXT.to_owned(),
707 "filename=\"report.pdf\"".to_owned()
708 ),
709 (
710 MULTIPART_FIELD_CONTEXT.to_owned(),
711 "Content-Disposition: form-data; name=\"upload\"; filename=\"report.pdf\""
712 .to_owned()
713 ),
714 (
715 MULTIPART_FIELD_CONTEXT.to_owned(),
716 "binary-file-payload".to_owned()
717 ),
718 ]
719 );
720 }
721
722 #[test]
723 fn multipart_json_body_walk_leaf_contexts() {
724 let values = extract(
725 "{\"system\":\"1 OR 1=1\",\"meta\":{\"deep\":\"x\"}}",
726 "application/json",
727 );
728 assert_eq!(
729 values,
730 vec![
731 (REQUEST_BODY_CONTEXT.to_owned(), "system".to_owned()),
732 (REQUEST_BODY_CONTEXT.to_owned(), "1 OR 1=1".to_owned()),
733 (REQUEST_BODY_CONTEXT.to_owned(), "meta".to_owned()),
734 (REQUEST_BODY_CONTEXT.to_owned(), "deep".to_owned()),
735 (REQUEST_BODY_CONTEXT.to_owned(), "x".to_owned()),
736 ]
737 );
738 }
739
740 #[test]
741 fn mongo_operator_key_forces_a_nosql_hit() {
742 let values = extract_body_scan_values_with_exclusions(
743 "{\"$where\": \"1 OR 1=1\"}",
744 "application/json",
745 &corpus_config(),
746 ExcludedBodyFields::default(),
747 );
748 assert_eq!(values.len(), 1);
749 assert_eq!(values[0].content, "$where");
750 assert_eq!(values[0].forced_category, Some(MONGO_OPERATOR_CATEGORY));
751 }
752
753 #[test]
754 fn non_json_body_with_json_content_type_falls_back_to_blob() {
755 let values = extract("1 OR 1=1", "application/json");
756 assert_eq!(
757 values,
758 vec![(REQUEST_BODY_CONTEXT.to_owned(), "1 OR 1=1".to_owned())]
759 );
760 }
761
762 #[test]
763 fn unknown_content_type_falls_back_to_blob() {
764 let values = extract("anything at all", "application/octet-stream");
765 assert_eq!(
766 values,
767 vec![(
768 REQUEST_BODY_CONTEXT.to_owned(),
769 "anything at all".to_owned()
770 )]
771 );
772 }
773
774 #[test]
775 fn empty_file_part_content_yields_no_scan_for_that_part() {
776 let body = "--B0\r\nContent-Disposition: form-data; name=\"f\"; filename=\"empty.bin\"\r\n\r\n\r\n--B0--\r\n";
777 let values = extract(body, "multipart/form-data; boundary=B0");
778 assert_eq!(values.len(), 3);
780 assert_eq!(values[0].1, "f");
781 assert_eq!(values[1].1, "filename=\"empty.bin\"");
782 assert!(!values.iter().any(|(_, content)| content == "\u{0}"));
783 }
784
785 #[test]
786 fn part_without_content_disposition_still_yields_entries() {
787 let body =
788 "--B0\r\nContent-Type: text/plain\r\n\r\n<script>alert(1)</script>\r\n--B0--\r\n";
789 let values = extract(body, "multipart/form-data; boundary=B0");
790 assert_eq!(
791 values,
792 vec![
793 (
794 REQUEST_BODY_CONTEXT.to_owned(),
795 MULTIPART_FILE_LABEL.to_owned()
796 ),
797 (
798 MULTIPART_FIELD_CONTEXT.to_owned(),
799 "Content-Type: text/plain".to_owned()
800 ),
801 (
802 MULTIPART_FIELD_CONTEXT.to_owned(),
803 "<script>alert(1)</script>".to_owned()
804 ),
805 ]
806 );
807 }
808
809 #[test]
810 fn rfc2231_extended_filename_is_detected() {
811 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";
812 let values = extract(body, "multipart/form-data; boundary=B0");
813 assert_eq!(values[1].1, "filename=\"shell.php\n.jpg\"");
814 }
815
816 #[test]
817 fn escaped_quote_in_filename_is_unescaped_and_stripped() {
818 let body = "--B0\r\nContent-Disposition: form-data; name=\"file\"; filename=\"shell\\\".php%00.jpg\"\r\n\r\nharmless\r\n--B0--\r\n";
819 let values = extract(body, "multipart/form-data; boundary=B0");
820 assert_eq!(values[1].1, "filename=\"shell.php%00.jpg\"");
821 }
822
823 #[test]
824 fn binary_like_file_part_reduces_to_islands() {
825 let payload = "\u{FFFD}".repeat(8) + &"a".repeat(20) + &"\u{FFFD}".repeat(8);
826 let body = format!(
827 "--B0\r\nContent-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n\r\n{payload}\r\n--B0--\r\n"
828 );
829 let values = extract(&body, "multipart/form-data; boundary=B0");
830 let last = values.last().expect("island entry");
831 assert_eq!(last.0, MULTIPART_FIELD_CONTEXT);
832 assert_eq!(last.1, "a".repeat(20));
833 }
834
835 #[test]
836 fn mostly_text_file_part_keeps_full_payload_scan() {
837 let payload = "one null\u{0}byte in text".to_owned();
838 let body = format!(
839 "--B0\r\nContent-Disposition: form-data; name=\"file\"; filename=\"note.txt\"\r\n\r\n{payload}\r\n--B0--\r\n"
840 );
841 let values = extract(&body, "multipart/form-data; boundary=B0");
842 assert_eq!(values.last().expect("payload").1, payload);
843 }
844
845 #[test]
846 fn boundary_mismatch_falls_back_to_whole_body_blob() {
847 let raw =
848 "--B0\r\nContent-Disposition: form-data; name=\"file\"\r\n\r\npayload\r\n--B0--\r\n";
849 let values = extract(raw, "multipart/form-data; boundary=DOES-NOT-MATCH");
850 assert_eq!(values.len(), 1);
851 assert_eq!(values[0].0, REQUEST_BODY_CONTEXT);
852 assert!(values[0].1.starts_with("--B0"));
853 }
854
855 #[test]
856 fn header_param_splitting_respects_quotes() {
857 let (main, params) = parse_header_params("form-data; name=\"a;b\"; filename=\"x'.y\"");
858 assert_eq!(main, "form-data");
859 assert_eq!(param_lookup(¶ms, "name"), Some("a;b"));
860 assert_eq!(param_lookup(¶ms, "filename"), Some("x'.y"));
861 }
862
863 #[test]
864 fn media_type_params_lowercases_main_type_and_names() {
865 let (main, params) = parse_media_type_params("Multipart/Form-Data; Boundary=B0");
866 assert_eq!(main, "multipart/form-data");
867 assert_eq!(param_lookup(¶ms, "boundary"), Some("B0"));
868 }
869
870 #[test]
871 fn unquote_header_param_keeps_asymmetric_quotes() {
872 assert_eq!(unquote_header_param("\"a\""), "a");
873 assert_eq!(unquote_header_param("\"a"), "\"a");
874 assert_eq!(unquote_header_param("a"), "a");
875 }
876
877 #[test]
878 fn unquote_plus_decodes_lowercase_hex_and_stray_escapes() {
879 assert_eq!(unquote_plus("%2f%2F"), "//");
881 assert_eq!(unquote_plus("a+ b"), "a b");
882 assert_eq!(unquote_plus("100%"), "100%");
884 assert_eq!(unquote_plus("%zz"), "%zz");
885 assert_eq!(unquote_plus("%2"), "%2");
886 }
887
888 #[test]
889 fn media_type_params_skip_pieces_without_an_equals() {
890 let (main, params) = parse_media_type_params("multipart/form-data; boundary=x; junk");
891 assert_eq!(main, "multipart/form-data");
892 assert_eq!(
893 params,
894 vec![(String::from("boundary"), String::from("x"))],
895 "a param piece without '=' is dropped"
896 );
897 }
898
899 #[test]
900 fn unquote_header_param_only_unescapes_quotes_and_backslashes() {
901 assert_eq!(unquote_header_param("\"a\\\\b\\\"c\""), "a\\b\"c");
904 assert_eq!(unquote_header_param("\"a\\nb\""), "a\\nb");
905 }
906}
907
908#[cfg(test)]
909mod coverage_tests {
910 use super::*;
911 use crate::detect::DetectConfig;
912
913 fn config() -> DetectConfig {
914 DetectConfig {
915 max_content_length: 10_000,
916 max_full_scan_bytes: 262_144,
917 preserve_attack_patterns: true,
918 semantic_threshold: 0.7,
919 threat_score_threshold: 1.0,
920 binary_min_run_length: 8,
921 max_scan_values: 512,
922 max_scan_chars: 65_536,
923 max_json_depth: 32,
924 }
925 }
926
927 #[test]
928 fn the_json_depth_knob_caps_the_walk() {
929 let body = r#"{"a":{"b":{"c":"1 UNION SELECT password"}}}"#;
934 let mut config = config();
935 config.max_json_depth = 32;
936 let values = extract_body_scan_values_with_exclusions(
937 body,
938 "application/json",
939 &config,
940 ExcludedBodyFields::default(),
941 );
942 assert!(
943 values
944 .iter()
945 .any(|value| value.content == "1 UNION SELECT password"),
946 "the default cap walks to the leaf"
947 );
948
949 config.max_json_depth = 1;
950 let values = extract_body_scan_values_with_exclusions(
951 body,
952 "application/json",
953 &config,
954 ExcludedBodyFields::default(),
955 );
956 assert!(
957 values
958 .iter()
959 .any(|value| value.content.contains(r#"{"b":{"c""#)),
960 "the knob-capped walk serializes the depth-2 object as text"
961 );
962 assert!(
963 !values
964 .iter()
965 .any(|value| value.content == "1 UNION SELECT password"),
966 "the leaf is never reached at the capped depth"
967 );
968 }
969 #[test]
970 fn excluded_multipart_parts_and_rfc2231_filenames_scan() {
971 let boundary = "XBOUND";
972 let b = boundary;
973 let body = format!(
974 "--{b}\r\nContent-Disposition: form-data; name=\"safe\"\r\n\r\nok\r\n\
975 --{b}\r\nContent-Disposition: form-data; name=\"secret\"; filename=\"a.php\"\r\n\r\n<?php\r\n\
976 --{b}\r\nContent-Disposition: form-data; name=\"f2\"; filename*0*=utf-8''up%2E;\r\n\
977 filename*1*=\"php\"\r\n\r\npayload\r\n--{b}--\r\n"
978 );
979 let content_type = format!("multipart/form-data; boundary={boundary}");
980
981 let all = extract_body_scan_values(&body, &content_type, &config());
983 assert!(!all.is_empty());
984
985 let mut excluded = std::collections::HashSet::new();
987 excluded.insert("secret".to_owned());
988 let filtered = extract_body_scan_values_with_exclusions(
989 &body,
990 &content_type,
991 &config(),
992 ExcludedBodyFields::new(&excluded),
993 );
994 assert!(filtered.len() < all.len());
995 }
996
997 #[test]
998 fn empty_and_headerless_parts_are_dropped() {
999 let boundary = "XBOUND";
1000 let b = boundary;
1002 let body = format!("--{b}\r\n\r\n\r\n--{b}--\r\n");
1003 let values = extract_body_scan_values(
1004 &body,
1005 &format!("multipart/form-data; boundary={boundary}"),
1006 &config(),
1007 );
1008 assert!(values.is_empty(), "unexpected values: {values:?}");
1011
1012 let b = boundary;
1015 let body = format!(
1016 "--{b}\r\nContent-Disposition: form-data; name=\"field\"\r\n\r\nhello\r\n--{b}--\r\n"
1017 );
1018 let named = extract_body_scan_values(
1019 &body,
1020 &format!("multipart/form-data; boundary={boundary}"),
1021 &config(),
1022 );
1023 assert!(!named.is_empty(), "the named part keeps its values");
1024 assert!(
1025 named
1026 .iter()
1027 .all(|value| value.content != MULTIPART_FILE_LABEL),
1028 "unexpected bare file label: {named:?}"
1029 );
1030
1031 let b = boundary;
1034 let body = format!("--{b}\r\nContent-Type: text/plain\r\n\r\n\r\n--{b}--\r\n");
1035 let values = extract_body_scan_values(
1036 &body,
1037 &format!("multipart/form-data; boundary={boundary}"),
1038 &config(),
1039 );
1040 assert!(
1041 values
1042 .iter()
1043 .any(|value| value.content.contains("Content-Type")),
1044 "the header entry should survive: {values:?}"
1045 );
1046 }
1047}
1048
1049#[cfg(test)]
1050mod gap_tests {
1051 use super::*;
1052
1053 #[test]
1054 fn excluded_body_fields_reports_emptiness() {
1055 assert!(ExcludedBodyFields::default().is_empty());
1056 let excluded = std::iter::once("password".to_owned()).collect();
1057 assert!(!ExcludedBodyFields::new(&excluded).is_empty());
1058 }
1059
1060 #[test]
1061 fn percent_decoding_without_escapes_is_the_identity() {
1062 assert_eq!(percent_decode_tolerant("plain value"), "plain value");
1063 }
1064}