Skip to main content

guard_core_engine/
body_scan.rs

1//! Request body value extraction: the engine-side port of the reference
2//! body-scanning split, mirroring the Go engine's `bodyscan.go`.
3//!
4//! Sources: `guard_core/_utils/body_form_scan.py`, `body_json_scan.py`, and
5//! `embedded_json_scan.py`.
6//!
7//! Instead of scanning the whole request body as one blob, the body is
8//! extracted into the values the reference engine scans individually:
9//!
10//! ```text
11//! form field name / value     request_body / request_body:form_field
12//! multipart label / entry     request_body / request_body:multipart_field
13//! JSON walk keys              request_body
14//! JSON walk leaves            request_body (+ ":embedded_json" per embedded
15//!                             walk level)
16//! blob fallback               request_body (whole body)
17//! ```
18//!
19//! The caller scans every extracted value through the normal
20//! `guard_core_engine::detect::detect` path (first threat wins). A value with
21//! `forced_category` set is a direct registry hit the reference reports from
22//! the JSON walk without a pattern scan (`_mongo_operator_key_hit`): the
23//! caller treats it as a threat of that category immediately.
24//!
25//! Deviations from the reference (mirroring the documented Go deviations, all
26//! detection-neutral or noted):
27//! - raw bytes become the engine-wide lossy `&str` representation at the
28//!   adapter boundary: one U+FFFD per maximal invalid run instead of Python's
29//!   one surrogateescape rune per byte. Ratios stay on the same side of the
30//!   binary-like threshold for real payloads.
31//! - JSON number leaves scan the literal JSON text instead of Python's float
32//!   `repr`; JSON `NaN`/`Infinity` extensions are accepted like `json.loads`.
33//! - excluded/sensitive body-field redaction is not modeled: the Rust config
34//!   layer has no such knob, so the exclusion sets are empty.
35//! - scan budgets (`detection_max_scan_values`/`_max_scan_chars`) are not
36//!   modeled; the input is bounded by the adapter body cap instead.
37
38use crate::detect::DetectConfig;
39use crate::json_walk::{append_json_walk_entries, parse_ordered_json};
40use crate::multipart_scan::{MultipartPart, parse_multipart_parts};
41
42/// Context the reference scans form field values under.
43pub const FORM_FIELD_CONTEXT: &str = "request_body:form_field";
44/// Context the reference scans multipart part entries under.
45pub const MULTIPART_FIELD_CONTEXT: &str = "request_body:multipart_field";
46/// Context for the whole-body blob fallback and top-level JSON walks.
47pub const REQUEST_BODY_CONTEXT: &str = "request_body";
48/// Label for a multipart part without a `name` disposition parameter.
49pub const MULTIPART_FILE_LABEL: &str = "file";
50/// Direct JSON-walk registry hit category (`_mongo_operator_key_hit`).
51pub const MONGO_OPERATOR_CATEGORY: &str = "nosql";
52
53/// One value handed to the engine with the context label the reference scans
54/// it under.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct BodyScanValue {
57    pub content: String,
58    pub context: String,
59    /// Set when the value is a direct registry hit (`_mongo_operator_key_hit`
60    /// reports it unfiltered, without a pattern scan).
61    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/// `extractBodyScanValues`: the `_scan_request_body` routing on the lowered
83/// content type - urlencoded form fields, multipart parts, JSON walks, and the
84/// whole-body blob fallback.
85///
86/// Binary island reduction inside file parts uses
87/// `config.binary_min_run_length`.
88#[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
109/// `appendFormBodyValues`: `parse_qsl` pairs with `keep_blank_values`, the
110/// name scanned as a `request_body` value, then the value as a `form_field`
111/// value
112/// (with the embedded JSON walk taking precedence over the raw string, exactly
113/// like `_check_value_enhanced`'s embedded-JSON-first order).
114fn 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
122/// `appendFieldBodyValue`: one form/multipart field value. A value that parses
123/// as a JSON object or array is walked first (leaf context gains the
124/// `:embedded_json` suffix) and the raw value is still scanned afterwards,
125/// exactly like `_check_embedded_json_if_applicable` running the walk and then
126/// `_check_value_enhanced` on the raw string: an attack can span the leaf
127/// boundaries and exist only in the raw text (guard-core #122's
128/// `body_raw_span_sqli_comment` vector).
129fn 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
142/// `appendMultipartBodyValues`: when the body does not parse into at least one
143/// leaf part, the whole raw body is scanned as one `request_body` blob value
144/// (Python's `_scan_blob_body` fallback, including the no-parts-with-final-
145/// boundary case the email parser reports as `is_multipart() == False`).
146fn 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(&params, "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
165/// `appendMultipartPartValues`: the per-part entry construction of
166/// `_multipart_part_entries`, scanned with the label name scan in front,
167/// exactly like the reference scanning the label once per entry (first-hit
168/// identical). A part that yields no entries is not scanned at all, like the
169/// reference.
170///
171/// The entries are the filename entry, every part header entry, and the
172/// payload entries (islands when the part is a binary-like file part).
173fn 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
218/// Extract a content-disposition parameter the way the Python email parser
219/// does: tolerant semicolon splitting that respects quoted strings, outer
220/// quotes stripped only when they pair, escaped quotes unescaped. Later
221/// duplicates win (the Go port's map semantics).
222fn 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(&params, param)
228                .map_or_else(|| (String::new(), false), |found| (found.to_owned(), true))
229        },
230    )
231}
232
233/// `partRFC2231Filename` (`get_filename`'s RFC 2231 fallback): when no plain
234/// filename parameter exists, the extended `filename*` piece, or the
235/// `filename*0*`..`filename*N*` segments, are joined, the charset prefix
236/// stripped, and the value percent-decoded.
237fn 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(&params, "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(&params, &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
255/// `decodeRFC2231Value`: strip the `charset'lang'` prefix, percent-decode
256/// tolerantly.
257fn decode_rfc2231_value(value: &str) -> String {
258    // A charset/lang prefix is stripped only when both quotes are present;
259    // otherwise the value stays untouched (the Go port's semantics).
260    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/// One `name=value` form pair, `unquote_plus`-decoded.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct FormPair {
276    pub name: String,
277    pub value: String,
278}
279
280/// `parseFormPairs` mirrors `urllib.parse.parse_qsl` with
281/// `keep_blank_values=True` and the `"&"` separator.
282///
283/// Empty chunks are dropped, pairs partition on the first `"="`, missing
284/// values are kept as empty strings, plus/space folding and tolerant
285/// percent-decoding apply.
286#[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/// `unquote_plus` with `errors="surrogateescape"`.
305///
306/// `"+"` becomes a space and every valid `%XX` escape contributes its byte;
307/// invalid escape sequences stay literal. Byte-oriented like the reference;
308/// invalid UTF-8 sequences surface as U+FFFD at the decode boundary.
309#[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/// `percentDecodeTolerant`: every valid `%XX` escape contributes its byte,
345/// invalid sequences stay literal.
346#[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
369/// Ordered content-type / disposition parameters (names lowercased).
370pub type HeaderParams = Vec<(String, String)>;
371
372/// `parseMediaTypeParams`: split a Content-Type style header into its
373/// lowercased main type and its parameters.
374///
375/// The main type keeps the part after `/` (the Go port lowercases the whole
376/// first piece); splitting uses the same tolerant quoted-string handling as
377/// the disposition parser.
378#[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/// `parseHeaderParams`: a Content-Disposition style header value split into
395/// its parameters (everything after the first `";"` piece) plus the main
396/// value.
397#[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/// Last-wins parameter lookup (the Go port's map semantics).
411#[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
427/// `unquoteHeaderParam` mirrors the email parser's param unquoting: matching
428/// outer quotes are stripped and escaped quotes and backslashes are
429/// unescaped; asymmetric quotes are kept verbatim.
430fn 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/// `splitHeaderParams`: split on semicolons that are not inside a quoted
455/// string, mirroring `email.utils._parseparam`'s quote-aware splitting.
456#[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>&note=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                // The raw value still scans after the clean walk (walk first,
570                // then raw: an attack can span the leaf boundaries).
571                (
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        // label scan, filename entry, disposition header; no payload entry.
678        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(&params, "name"), Some("a;b"));
759        assert_eq!(param_lookup(&params, "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(&params, "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}