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);
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(Vec::new(), &root, REQUEST_BODY_CONTEXT, excluded);
166 }
167 vec![BodyScanValue::plain(raw_body, REQUEST_BODY_CONTEXT)]
168}
169
170fn append_form_body_values(
176 mut values: Vec<BodyScanValue>,
177 raw_body: &str,
178 excluded: ExcludedBodyFields<'_>,
179) -> Vec<BodyScanValue> {
180 for pair in parse_form_pairs(raw_body) {
181 if excluded.contains(&pair.name) {
183 continue;
184 }
185 values.push(BodyScanValue::plain(pair.name, REQUEST_BODY_CONTEXT));
186 values = append_field_body_value(values, &pair.value, FORM_FIELD_CONTEXT, excluded);
187 }
188 values
189}
190
191fn append_field_body_value(
199 mut values: Vec<BodyScanValue>,
200 text: &str,
201 ctx: &str,
202 excluded: ExcludedBodyFields<'_>,
203) -> Vec<BodyScanValue> {
204 if let Some(root) = parse_ordered_json(text) {
205 let walk_context = format!("{ctx}{}", crate::detect::EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX);
206 values = append_json_walk_entries(values, &root, &walk_context, excluded);
207 }
208 values.push(BodyScanValue::plain(text, ctx));
209 values
210}
211
212fn append_multipart_body_values(
217 mut values: Vec<BodyScanValue>,
218 raw_body: &str,
219 content_type: &str,
220 config: &DetectConfig,
221 excluded: ExcludedBodyFields<'_>,
222) -> Vec<BodyScanValue> {
223 let (_, params) = parse_media_type_params(content_type);
224 let boundary = param_lookup(¶ms, "boundary").unwrap_or_default();
225 let parts = parse_multipart_parts(raw_body, boundary);
226 if parts.is_empty() {
227 values.push(BodyScanValue::plain(raw_body, REQUEST_BODY_CONTEXT));
228 return values;
229 }
230 for part in parts {
231 append_multipart_part_values(&mut values, &part, config, excluded);
232 }
233 values
234}
235
236fn append_multipart_part_values(
245 values: &mut Vec<BodyScanValue>,
246 part: &MultipartPart<'_>,
247 config: &DetectConfig,
248 excluded: ExcludedBodyFields<'_>,
249) {
250 let (name, has_name) = part_disposition_param(part, "name");
251 if has_name && excluded.contains(&name) {
254 return;
255 }
256 let (plain_filename, plain_found) = part_disposition_param(part, "filename");
257 let (filename, has_filename) = if plain_found {
258 (plain_filename, true)
259 } else {
260 part_rfc2231_filename(part).map_or((String::new(), false), |f| (f, true))
261 };
262 let label: std::borrow::Cow<'_, str> = if has_name {
263 std::borrow::Cow::Borrowed(name.as_str())
264 } else {
265 std::borrow::Cow::Borrowed(MULTIPART_FILE_LABEL)
266 };
267
268 let mut entries: Vec<String> = Vec::new();
269 if has_filename {
270 let sanitized = filename.replace(['"', '\''], "");
271 entries.push(format!("filename=\"{sanitized}\""));
272 }
273 for header in &part.headers {
274 entries.push(format!("{}: {}", header.name, header.value));
275 }
276 let payload = part.payload;
277 if has_filename && crate::binary_islands::value_is_binary_like(payload) {
278 entries.extend(
279 crate::binary_islands::extract_binary_islands(payload, config.binary_min_run_length)
280 .into_iter()
281 .map(String::from),
282 );
283 } else if !payload.is_empty() {
284 entries.push(payload.to_owned());
285 }
286 if entries.is_empty() {
287 return;
288 }
289 values.push(BodyScanValue::plain(label.as_ref(), REQUEST_BODY_CONTEXT));
290 for entry in &entries {
291 *values = append_field_body_value(
292 std::mem::take(values),
293 entry,
294 MULTIPART_FIELD_CONTEXT,
295 excluded,
296 );
297 }
298}
299
300fn part_disposition_param(part: &MultipartPart<'_>, param: &str) -> (String, bool) {
305 first_header_value(part, "content-disposition").map_or_else(
306 || (String::new(), false),
307 |value| {
308 let (_, params) = parse_header_params(&value);
309 param_lookup(¶ms, param)
310 .map_or_else(|| (String::new(), false), |found| (found.to_owned(), true))
311 },
312 )
313}
314
315fn part_rfc2231_filename(part: &MultipartPart<'_>) -> Option<String> {
320 let value = first_header_value(part, "content-disposition")?;
321 let (_, params) = parse_header_params(&value);
322 if let Some(extended) = param_lookup(¶ms, "filename*") {
323 return Some(decode_rfc2231_value(extended));
324 }
325 let mut pieces = String::new();
326 let mut index = 0_usize;
327 while let Some(piece) = param_lookup(¶ms, &format!("filename*{index}*")) {
328 pieces.push_str(piece);
329 index += 1;
330 }
331 if index == 0 {
332 return None;
333 }
334 Some(decode_rfc2231_value(&pieces))
335}
336
337fn decode_rfc2231_value(value: &str) -> String {
340 let rest = value.split_once('\'').map_or(value, |(_, after)| {
343 after.split_once('\'').map_or(value, |(_, tail)| tail)
344 });
345 percent_decode_tolerant(rest)
346}
347
348fn first_header_value(part: &MultipartPart<'_>, lower_name: &str) -> Option<String> {
349 part.headers
350 .iter()
351 .find(|h| h.name.eq_ignore_ascii_case(lower_name))
352 .map(|h| h.value.clone())
353}
354
355#[derive(Debug, Clone, PartialEq, Eq)]
357pub struct FormPair {
358 pub name: String,
359 pub value: String,
360}
361
362#[must_use]
369pub fn parse_form_pairs(raw_body: &str) -> Vec<FormPair> {
370 let mut pairs = Vec::new();
371 for chunk in raw_body.split('&') {
372 if chunk.is_empty() {
373 continue;
374 }
375 let (name, value) = chunk
376 .find('=')
377 .map_or((chunk, ""), |idx| (&chunk[..idx], &chunk[idx + 1..]));
378 pairs.push(FormPair {
379 name: unquote_plus(name),
380 value: unquote_plus(value),
381 });
382 }
383 pairs
384}
385
386#[must_use]
392pub fn unquote_plus(s: &str) -> String {
393 if !s.contains('+') && !s.contains('%') {
394 return s.to_owned();
395 }
396 let mut out = Vec::with_capacity(s.len());
397 let bytes = s.as_bytes();
398 let mut i = 0;
399 while i < bytes.len() {
400 match bytes[i] {
401 b'+' => out.push(b' '),
402 b'%' if i + 2 < bytes.len() => {
403 if let (Some(h1), Some(h2)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
404 out.push(h1 << 4 | h2);
405 i += 3;
406 continue;
407 }
408 out.push(b'%');
409 }
410 b => out.push(b),
411 }
412 i += 1;
413 }
414 String::from_utf8_lossy(&out).into_owned()
415}
416
417const fn hex_val(c: u8) -> Option<u8> {
418 match c {
419 b'0'..=b'9' => Some(c - b'0'),
420 b'a'..=b'f' => Some(c - b'a' + 10),
421 b'A'..=b'F' => Some(c - b'A' + 10),
422 _ => None,
423 }
424}
425
426#[must_use]
429pub fn percent_decode_tolerant(s: &str) -> String {
430 if !s.contains('%') {
431 return s.to_owned();
432 }
433 let bytes = s.as_bytes();
434 let mut out = Vec::with_capacity(bytes.len());
435 let mut i = 0;
436 while i < bytes.len() {
437 if bytes[i] == b'%'
438 && i + 2 < bytes.len()
439 && let (Some(h1), Some(h2)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
440 {
441 out.push(h1 << 4 | h2);
442 i += 3;
443 continue;
444 }
445 out.push(bytes[i]);
446 i += 1;
447 }
448 String::from_utf8_lossy(&out).into_owned()
449}
450
451pub type HeaderParams = Vec<(String, String)>;
453
454#[must_use]
461pub fn parse_media_type_params(value: &str) -> (String, HeaderParams) {
462 let mut main_type = String::new();
463 let mut params = HeaderParams::new();
464 for (index, piece) in split_header_params(value).into_iter().enumerate() {
465 if index == 0 {
466 main_type = piece.trim().to_ascii_lowercase();
467 continue;
468 }
469 if let Some((name, param)) = split_param_piece(&piece) {
470 params.push((name, param));
471 }
472 }
473 (main_type, params)
474}
475
476#[must_use]
480pub fn parse_header_params(value: &str) -> (String, HeaderParams) {
481 let pieces = split_header_params(value);
482 let mut params = HeaderParams::new();
483 for piece in pieces.iter().skip(1) {
484 if let Some((name, param)) = split_param_piece(piece) {
485 params.push((name, param));
486 }
487 }
488 let main = pieces.first().map_or_else(String::new, String::clone);
489 (main.trim().to_owned(), params)
490}
491
492#[must_use]
494pub fn param_lookup<'a>(params: &'a HeaderParams, name: &str) -> Option<&'a str> {
495 params
496 .iter()
497 .rev()
498 .find(|(key, _)| key == name)
499 .map(|(_, value)| value.as_str())
500}
501
502fn split_param_piece(piece: &str) -> Option<(String, String)> {
503 let idx = piece.find('=')?;
504 let name = piece[..idx].trim().to_ascii_lowercase();
505 let value = unquote_header_param(piece[idx + 1..].trim());
506 Some((name, value))
507}
508
509fn unquote_header_param(value: &str) -> String {
513 let bytes = value.as_bytes();
514 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
515 return value.to_owned();
516 }
517 let inner = &value[1..value.len() - 1];
518 let mut out = Vec::with_capacity(inner.len());
519 let chars = inner.as_bytes();
520 let mut i = 0;
521 while i < chars.len() {
522 if chars[i] == b'\\'
523 && i + 1 < chars.len()
524 && (chars[i + 1] == b'"' || chars[i + 1] == b'\\')
525 {
526 out.push(chars[i + 1]);
527 i += 2;
528 continue;
529 }
530 out.push(chars[i]);
531 i += 1;
532 }
533 String::from_utf8_lossy(&out).into_owned()
534}
535
536#[must_use]
539pub fn split_header_params(value: &str) -> Vec<String> {
540 let mut pieces = Vec::new();
541 let mut start = 0_usize;
542 let mut in_quotes = false;
543 let bytes = value.as_bytes();
544 let mut i = 0;
545 while i < bytes.len() {
546 let c = bytes[i];
547 if in_quotes && c == b'\\' {
548 i += 2;
549 continue;
550 }
551 match c {
552 b'"' => in_quotes = !in_quotes,
553 b';' if !in_quotes => {
554 pieces.push(value[start..i].to_owned());
555 start = i + 1;
556 }
557 _ => {}
558 }
559 i += 1;
560 }
561 pieces.push(value[start..].to_owned());
562 pieces
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568 use crate::detect::DetectConfig;
569
570 fn corpus_config() -> DetectConfig {
571 DetectConfig {
572 max_content_length: 10_000,
573 max_full_scan_bytes: 262_144,
574 preserve_attack_patterns: true,
575 semantic_threshold: 0.7,
576 threat_score_threshold: 1.0,
577 binary_min_run_length: 16,
578 }
579 }
580
581 fn extract(raw_body: &str, content_type: &str) -> Vec<(String, String)> {
582 extract_body_scan_values_with_exclusions(
583 raw_body,
584 content_type,
585 &corpus_config(),
586 ExcludedBodyFields::default(),
587 )
588 .into_iter()
589 .map(|v| (v.context, v.content))
590 .collect()
591 }
592
593 #[test]
594 fn parse_form_pairs_mirrors_parse_qsl() {
595 let cases: &[(&str, Vec<(&str, &str)>)] = &[
596 ("a=b&c", vec![("a", "b"), ("c", "")]),
597 (
598 "a=b=c&d=&=v&+x=%2F%zz&a=b",
599 vec![
600 ("a", "b=c"),
601 ("d", ""),
602 ("", "v"),
603 (" x", "/%zz"),
604 ("a", "b"),
605 ],
606 ),
607 ("a=1&&b=2&", vec![("a", "1"), ("b", "2")]),
608 ("", vec![]),
609 ];
610 for (body, want) in cases {
611 let got = parse_form_pairs(body);
612 let got: Vec<(&str, &str)> = got
613 .iter()
614 .map(|p| (p.name.as_str(), p.value.as_str()))
615 .collect();
616 let want: Vec<(&str, &str)> = want.iter().map(|(a, b)| (*a, *b)).collect();
617 assert_eq!(got, want, "parse_form_pairs({body:?})");
618 }
619 }
620
621 #[test]
622 fn form_body_extraction_contexts_and_order() {
623 let values = extract(
624 "system=<script>alert(1)</script>¬e=hello",
625 "application/x-www-form-urlencoded",
626 );
627 assert_eq!(
628 values,
629 vec![
630 (REQUEST_BODY_CONTEXT.to_owned(), "system".to_owned()),
631 (
632 FORM_FIELD_CONTEXT.to_owned(),
633 "<script>alert(1)</script>".to_owned()
634 ),
635 (REQUEST_BODY_CONTEXT.to_owned(), "note".to_owned()),
636 (FORM_FIELD_CONTEXT.to_owned(), "hello".to_owned()),
637 ]
638 );
639 }
640
641 #[test]
642 fn form_field_embedded_json_walk_contexts() {
643 let values = extract(
644 "data={\"a\":\"<script>alert(1)</script>\"}",
645 "application/x-www-form-urlencoded",
646 );
647 assert_eq!(
648 values,
649 vec![
650 (REQUEST_BODY_CONTEXT.to_owned(), "data".to_owned()),
651 (REQUEST_BODY_CONTEXT.to_owned(), "a".to_owned()),
652 (
653 "request_body:form_field:embedded_json".to_owned(),
654 "<script>alert(1)</script>".to_owned()
655 ),
656 (
659 "request_body:form_field".to_owned(),
660 "{\"a\":\"<script>alert(1)</script>\"}".to_owned()
661 ),
662 ]
663 );
664 }
665
666 #[test]
667 fn multipart_text_part_entries() {
668 let body =
669 "--B0\r\nContent-Disposition: form-data; name=\"note\"\r\n\r\nhello\r\n--B0--\r\n";
670 let values = extract(body, "multipart/form-data; boundary=B0");
671 assert_eq!(
672 values,
673 vec![
674 (REQUEST_BODY_CONTEXT.to_owned(), "note".to_owned()),
675 (
676 MULTIPART_FIELD_CONTEXT.to_owned(),
677 "Content-Disposition: form-data; name=\"note\"".to_owned()
678 ),
679 (MULTIPART_FIELD_CONTEXT.to_owned(), "hello".to_owned()),
680 ]
681 );
682 }
683
684 #[test]
685 fn multipart_file_part_entries_include_filename_and_headers() {
686 let body = "--B0\r\nContent-Disposition: form-data; name=\"upload\"; filename=\"report.pdf\"\r\n\r\nbinary-file-payload\r\n--B0--\r\n";
687 let values = extract(body, "multipart/form-data; boundary=B0");
688 assert_eq!(
689 values,
690 vec![
691 (REQUEST_BODY_CONTEXT.to_owned(), "upload".to_owned()),
692 (
693 MULTIPART_FIELD_CONTEXT.to_owned(),
694 "filename=\"report.pdf\"".to_owned()
695 ),
696 (
697 MULTIPART_FIELD_CONTEXT.to_owned(),
698 "Content-Disposition: form-data; name=\"upload\"; filename=\"report.pdf\""
699 .to_owned()
700 ),
701 (
702 MULTIPART_FIELD_CONTEXT.to_owned(),
703 "binary-file-payload".to_owned()
704 ),
705 ]
706 );
707 }
708
709 #[test]
710 fn multipart_json_body_walk_leaf_contexts() {
711 let values = extract(
712 "{\"system\":\"1 OR 1=1\",\"meta\":{\"deep\":\"x\"}}",
713 "application/json",
714 );
715 assert_eq!(
716 values,
717 vec![
718 (REQUEST_BODY_CONTEXT.to_owned(), "system".to_owned()),
719 (REQUEST_BODY_CONTEXT.to_owned(), "1 OR 1=1".to_owned()),
720 (REQUEST_BODY_CONTEXT.to_owned(), "meta".to_owned()),
721 (REQUEST_BODY_CONTEXT.to_owned(), "deep".to_owned()),
722 (REQUEST_BODY_CONTEXT.to_owned(), "x".to_owned()),
723 ]
724 );
725 }
726
727 #[test]
728 fn mongo_operator_key_forces_a_nosql_hit() {
729 let values = extract_body_scan_values_with_exclusions(
730 "{\"$where\": \"1 OR 1=1\"}",
731 "application/json",
732 &corpus_config(),
733 ExcludedBodyFields::default(),
734 );
735 assert_eq!(values.len(), 1);
736 assert_eq!(values[0].content, "$where");
737 assert_eq!(values[0].forced_category, Some(MONGO_OPERATOR_CATEGORY));
738 }
739
740 #[test]
741 fn non_json_body_with_json_content_type_falls_back_to_blob() {
742 let values = extract("1 OR 1=1", "application/json");
743 assert_eq!(
744 values,
745 vec![(REQUEST_BODY_CONTEXT.to_owned(), "1 OR 1=1".to_owned())]
746 );
747 }
748
749 #[test]
750 fn unknown_content_type_falls_back_to_blob() {
751 let values = extract("anything at all", "application/octet-stream");
752 assert_eq!(
753 values,
754 vec![(
755 REQUEST_BODY_CONTEXT.to_owned(),
756 "anything at all".to_owned()
757 )]
758 );
759 }
760
761 #[test]
762 fn empty_file_part_content_yields_no_scan_for_that_part() {
763 let body = "--B0\r\nContent-Disposition: form-data; name=\"f\"; filename=\"empty.bin\"\r\n\r\n\r\n--B0--\r\n";
764 let values = extract(body, "multipart/form-data; boundary=B0");
765 assert_eq!(values.len(), 3);
767 assert_eq!(values[0].1, "f");
768 assert_eq!(values[1].1, "filename=\"empty.bin\"");
769 assert!(!values.iter().any(|(_, content)| content == "\u{0}"));
770 }
771
772 #[test]
773 fn part_without_content_disposition_still_yields_entries() {
774 let body =
775 "--B0\r\nContent-Type: text/plain\r\n\r\n<script>alert(1)</script>\r\n--B0--\r\n";
776 let values = extract(body, "multipart/form-data; boundary=B0");
777 assert_eq!(
778 values,
779 vec![
780 (
781 REQUEST_BODY_CONTEXT.to_owned(),
782 MULTIPART_FILE_LABEL.to_owned()
783 ),
784 (
785 MULTIPART_FIELD_CONTEXT.to_owned(),
786 "Content-Type: text/plain".to_owned()
787 ),
788 (
789 MULTIPART_FIELD_CONTEXT.to_owned(),
790 "<script>alert(1)</script>".to_owned()
791 ),
792 ]
793 );
794 }
795
796 #[test]
797 fn rfc2231_extended_filename_is_detected() {
798 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";
799 let values = extract(body, "multipart/form-data; boundary=B0");
800 assert_eq!(values[1].1, "filename=\"shell.php\n.jpg\"");
801 }
802
803 #[test]
804 fn escaped_quote_in_filename_is_unescaped_and_stripped() {
805 let body = "--B0\r\nContent-Disposition: form-data; name=\"file\"; filename=\"shell\\\".php%00.jpg\"\r\n\r\nharmless\r\n--B0--\r\n";
806 let values = extract(body, "multipart/form-data; boundary=B0");
807 assert_eq!(values[1].1, "filename=\"shell.php%00.jpg\"");
808 }
809
810 #[test]
811 fn binary_like_file_part_reduces_to_islands() {
812 let payload = "\u{FFFD}".repeat(8) + &"a".repeat(20) + &"\u{FFFD}".repeat(8);
813 let body = format!(
814 "--B0\r\nContent-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n\r\n{payload}\r\n--B0--\r\n"
815 );
816 let values = extract(&body, "multipart/form-data; boundary=B0");
817 let last = values.last().expect("island entry");
818 assert_eq!(last.0, MULTIPART_FIELD_CONTEXT);
819 assert_eq!(last.1, "a".repeat(20));
820 }
821
822 #[test]
823 fn mostly_text_file_part_keeps_full_payload_scan() {
824 let payload = "one null\u{0}byte in text".to_owned();
825 let body = format!(
826 "--B0\r\nContent-Disposition: form-data; name=\"file\"; filename=\"note.txt\"\r\n\r\n{payload}\r\n--B0--\r\n"
827 );
828 let values = extract(&body, "multipart/form-data; boundary=B0");
829 assert_eq!(values.last().expect("payload").1, payload);
830 }
831
832 #[test]
833 fn boundary_mismatch_falls_back_to_whole_body_blob() {
834 let raw =
835 "--B0\r\nContent-Disposition: form-data; name=\"file\"\r\n\r\npayload\r\n--B0--\r\n";
836 let values = extract(raw, "multipart/form-data; boundary=DOES-NOT-MATCH");
837 assert_eq!(values.len(), 1);
838 assert_eq!(values[0].0, REQUEST_BODY_CONTEXT);
839 assert!(values[0].1.starts_with("--B0"));
840 }
841
842 #[test]
843 fn header_param_splitting_respects_quotes() {
844 let (main, params) = parse_header_params("form-data; name=\"a;b\"; filename=\"x'.y\"");
845 assert_eq!(main, "form-data");
846 assert_eq!(param_lookup(¶ms, "name"), Some("a;b"));
847 assert_eq!(param_lookup(¶ms, "filename"), Some("x'.y"));
848 }
849
850 #[test]
851 fn media_type_params_lowercases_main_type_and_names() {
852 let (main, params) = parse_media_type_params("Multipart/Form-Data; Boundary=B0");
853 assert_eq!(main, "multipart/form-data");
854 assert_eq!(param_lookup(¶ms, "boundary"), Some("B0"));
855 }
856
857 #[test]
858 fn unquote_header_param_keeps_asymmetric_quotes() {
859 assert_eq!(unquote_header_param("\"a\""), "a");
860 assert_eq!(unquote_header_param("\"a"), "\"a");
861 assert_eq!(unquote_header_param("a"), "a");
862 }
863
864 #[test]
865 fn unquote_plus_decodes_lowercase_hex_and_stray_escapes() {
866 assert_eq!(unquote_plus("%2f%2F"), "//");
868 assert_eq!(unquote_plus("a+ b"), "a b");
869 assert_eq!(unquote_plus("100%"), "100%");
871 assert_eq!(unquote_plus("%zz"), "%zz");
872 assert_eq!(unquote_plus("%2"), "%2");
873 }
874
875 #[test]
876 fn media_type_params_skip_pieces_without_an_equals() {
877 let (main, params) = parse_media_type_params("multipart/form-data; boundary=x; junk");
878 assert_eq!(main, "multipart/form-data");
879 assert_eq!(
880 params,
881 vec![(String::from("boundary"), String::from("x"))],
882 "a param piece without '=' is dropped"
883 );
884 }
885
886 #[test]
887 fn unquote_header_param_only_unescapes_quotes_and_backslashes() {
888 assert_eq!(unquote_header_param("\"a\\\\b\\\"c\""), "a\\b\"c");
891 assert_eq!(unquote_header_param("\"a\\nb\""), "a\\nb");
892 }
893}
894
895#[cfg(test)]
896mod coverage_tests {
897 use super::*;
898 use crate::detect::DetectConfig;
899
900 fn config() -> DetectConfig {
901 DetectConfig {
902 max_content_length: 10_000,
903 max_full_scan_bytes: 262_144,
904 preserve_attack_patterns: true,
905 semantic_threshold: 0.7,
906 threat_score_threshold: 1.0,
907 binary_min_run_length: 8,
908 }
909 }
910
911 #[test]
912 fn excluded_multipart_parts_and_rfc2231_filenames_scan() {
913 let boundary = "XBOUND";
914 let b = boundary;
915 let body = format!(
916 "--{b}\r\nContent-Disposition: form-data; name=\"safe\"\r\n\r\nok\r\n\
917 --{b}\r\nContent-Disposition: form-data; name=\"secret\"; filename=\"a.php\"\r\n\r\n<?php\r\n\
918 --{b}\r\nContent-Disposition: form-data; name=\"f2\"; filename*0*=utf-8''up%2E;\r\n\
919 filename*1*=\"php\"\r\n\r\npayload\r\n--{b}--\r\n"
920 );
921 let content_type = format!("multipart/form-data; boundary={boundary}");
922
923 let all = extract_body_scan_values(&body, &content_type, &config());
925 assert!(!all.is_empty());
926
927 let mut excluded = std::collections::HashSet::new();
929 excluded.insert("secret".to_owned());
930 let filtered = extract_body_scan_values_with_exclusions(
931 &body,
932 &content_type,
933 &config(),
934 ExcludedBodyFields::new(&excluded),
935 );
936 assert!(filtered.len() < all.len());
937 }
938
939 #[test]
940 fn empty_and_headerless_parts_are_dropped() {
941 let boundary = "XBOUND";
942 let b = boundary;
944 let body = format!("--{b}\r\n\r\n\r\n--{b}--\r\n");
945 let values = extract_body_scan_values(
946 &body,
947 &format!("multipart/form-data; boundary={boundary}"),
948 &config(),
949 );
950 assert!(values.is_empty(), "unexpected values: {values:?}");
953
954 let b = boundary;
957 let body = format!(
958 "--{b}\r\nContent-Disposition: form-data; name=\"field\"\r\n\r\nhello\r\n--{b}--\r\n"
959 );
960 let named = extract_body_scan_values(
961 &body,
962 &format!("multipart/form-data; boundary={boundary}"),
963 &config(),
964 );
965 assert!(!named.is_empty(), "the named part keeps its values");
966 assert!(
967 named
968 .iter()
969 .all(|value| value.content != MULTIPART_FILE_LABEL),
970 "unexpected bare file label: {named:?}"
971 );
972
973 let b = boundary;
976 let body = format!("--{b}\r\nContent-Type: text/plain\r\n\r\n\r\n--{b}--\r\n");
977 let values = extract_body_scan_values(
978 &body,
979 &format!("multipart/form-data; boundary={boundary}"),
980 &config(),
981 );
982 assert!(
983 values
984 .iter()
985 .any(|value| value.content.contains("Content-Type")),
986 "the header entry should survive: {values:?}"
987 );
988 }
989}
990
991#[cfg(test)]
992mod gap_tests {
993 use super::*;
994
995 #[test]
996 fn excluded_body_fields_reports_emptiness() {
997 assert!(ExcludedBodyFields::default().is_empty());
998 let excluded = std::iter::once("password".to_owned()).collect();
999 assert!(!ExcludedBodyFields::new(&excluded).is_empty());
1000 }
1001
1002 #[test]
1003 fn percent_decoding_without_escapes_is_the_identity() {
1004 assert_eq!(percent_decode_tolerant("plain value"), "plain value");
1005 }
1006}