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 body fields (`excluded_detection_body_fields`) arrive as the
34//!   [`ExcludedBodyFields`] set: an excluded form pair, multipart part, or
35//!   JSON entry key is skipped outright, the reference's per-field routing.
36//! - scan budgets (`detection_max_scan_values`/`_max_scan_chars`) are not
37//!   modeled; the input is bounded by the adapter body cap instead.
38
39use crate::detect::DetectConfig;
40use crate::json_walk::{append_json_walk_entries, parse_ordered_json};
41use crate::multipart_scan::{MultipartPart, parse_multipart_parts};
42
43/// Context the reference scans form field values under.
44pub const FORM_FIELD_CONTEXT: &str = "request_body:form_field";
45/// Context the reference scans multipart part entries under.
46pub const MULTIPART_FIELD_CONTEXT: &str = "request_body:multipart_field";
47/// Context for the whole-body blob fallback and top-level JSON walks.
48pub const REQUEST_BODY_CONTEXT: &str = "request_body";
49/// Label for a multipart part without a `name` disposition parameter.
50pub const MULTIPART_FILE_LABEL: &str = "file";
51/// Direct JSON-walk registry hit category (`_mongo_operator_key_hit`).
52pub const MONGO_OPERATOR_CATEGORY: &str = "nosql";
53
54/// One value handed to the engine with the context label the reference scans
55/// it under.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct BodyScanValue {
58    pub content: String,
59    pub context: String,
60    /// Set when the value is a direct registry hit (`_mongo_operator_key_hit`
61    /// reports it unfiltered, without a pattern scan).
62    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/// The excluded detection body fields: the reference
84/// `excluded_detection_body_fields` set (config or route resolution),
85/// entries lowercased at resolution time.
86///
87/// Case-insensitive matching at the field-name boundary happens here
88/// (`contains` lowercases its input), the reference's
89/// `{k.lower() for k in ...}` coercion.
90#[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    /// Carry a resolved (lowercased) exclusion set.
103    #[must_use]
104    pub const fn new(set: &'a std::collections::HashSet<String>) -> Self {
105        Self(set)
106    }
107
108    /// `true` when no fields are excluded.
109    #[must_use]
110    pub fn is_empty(self) -> bool {
111        self.0.is_empty()
112    }
113
114    /// Case-insensitive membership: the field name lowercased before the
115    /// lookup, like the reference's lowercase comparison.
116    pub(crate) fn contains(self, name: &str) -> bool {
117        self.0.contains(name.to_ascii_lowercase().as_str())
118    }
119}
120
121/// `extractBodyScanValues`: the `_scan_request_body` routing on the lowered
122/// content type - urlencoded form fields, multipart parts, JSON walks, and the
123/// whole-body blob fallback.
124///
125/// Binary island reduction inside file parts uses
126/// `config.binary_min_run_length`.
127///
128/// The no-exclusions form: identical to
129/// [`extract_body_scan_values_with_exclusions`] with an empty set. The
130/// adapters' existing extraction call sites keep compiling unchanged.
131#[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/// [`extract_body_scan_values`] with the excluded detection body fields
146/// carried through: an excluded form pair, multipart part, or JSON entry
147/// key is skipped outright, the reference's per-field routing.
148#[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
170/// `appendFormBodyValues`: `parse_qsl` pairs with `keep_blank_values`, the
171/// name scanned as a `request_body` value, then the value as a `form_field`
172/// value
173/// (with the embedded JSON walk taking precedence over the raw string, exactly
174/// like `_check_value_enhanced`'s embedded-JSON-first order).
175fn 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        // An excluded field name skips the whole pair (name and value).
182        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
191/// `appendFieldBodyValue`: one form/multipart field value. A value that parses
192/// as a JSON object or array is walked first (leaf context gains the
193/// `:embedded_json` suffix) and the raw value is still scanned afterwards,
194/// exactly like `_check_embedded_json_if_applicable` running the walk and then
195/// `_check_value_enhanced` on the raw string: an attack can span the leaf
196/// boundaries and exist only in the raw text (guard-core #122's
197/// `body_raw_span_sqli_comment` vector).
198fn 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
212/// `appendMultipartBodyValues`: when the body does not parse into at least one
213/// leaf part, the whole raw body is scanned as one `request_body` blob value
214/// (Python's `_scan_blob_body` fallback, including the no-parts-with-final-
215/// boundary case the email parser reports as `is_multipart() == False`).
216fn 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(&params, "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
236/// `appendMultipartPartValues`: the per-part entry construction of
237/// `_multipart_part_entries`, scanned with the label name scan in front,
238/// exactly like the reference scanning the label once per entry (first-hit
239/// identical). A part that yields no entries is not scanned at all, like the
240/// reference.
241///
242/// The entries are the filename entry, every part header entry, and the
243/// payload entries (islands when the part is a binary-like file part).
244fn 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    // An excluded part label skips the whole part (filename entry, part
252    // headers, payload), exactly the reference's excluded-field routing.
253    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
300/// Extract a content-disposition parameter the way the Python email parser
301/// does: tolerant semicolon splitting that respects quoted strings, outer
302/// quotes stripped only when they pair, escaped quotes unescaped. Later
303/// duplicates win (the Go port's map semantics).
304fn 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(&params, param)
310                .map_or_else(|| (String::new(), false), |found| (found.to_owned(), true))
311        },
312    )
313}
314
315/// `partRFC2231Filename` (`get_filename`'s RFC 2231 fallback): when no plain
316/// filename parameter exists, the extended `filename*` piece, or the
317/// `filename*0*`..`filename*N*` segments, are joined, the charset prefix
318/// stripped, and the value percent-decoded.
319fn 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(&params, "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(&params, &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
337/// `decodeRFC2231Value`: strip the `charset'lang'` prefix, percent-decode
338/// tolerantly.
339fn decode_rfc2231_value(value: &str) -> String {
340    // A charset/lang prefix is stripped only when both quotes are present;
341    // otherwise the value stays untouched (the Go port's semantics).
342    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/// One `name=value` form pair, `unquote_plus`-decoded.
356#[derive(Debug, Clone, PartialEq, Eq)]
357pub struct FormPair {
358    pub name: String,
359    pub value: String,
360}
361
362/// `parseFormPairs` mirrors `urllib.parse.parse_qsl` with
363/// `keep_blank_values=True` and the `"&"` separator.
364///
365/// Empty chunks are dropped, pairs partition on the first `"="`, missing
366/// values are kept as empty strings, plus/space folding and tolerant
367/// percent-decoding apply.
368#[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/// `unquote_plus` with `errors="surrogateescape"`.
387///
388/// `"+"` becomes a space and every valid `%XX` escape contributes its byte;
389/// invalid escape sequences stay literal. Byte-oriented like the reference;
390/// invalid UTF-8 sequences surface as U+FFFD at the decode boundary.
391#[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/// `percentDecodeTolerant`: every valid `%XX` escape contributes its byte,
427/// invalid sequences stay literal.
428#[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
451/// Ordered content-type / disposition parameters (names lowercased).
452pub type HeaderParams = Vec<(String, String)>;
453
454/// `parseMediaTypeParams`: split a Content-Type style header into its
455/// lowercased main type and its parameters.
456///
457/// The main type keeps the part after `/` (the Go port lowercases the whole
458/// first piece); splitting uses the same tolerant quoted-string handling as
459/// the disposition parser.
460#[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/// `parseHeaderParams`: a Content-Disposition style header value split into
477/// its parameters (everything after the first `";"` piece) plus the main
478/// value.
479#[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/// Last-wins parameter lookup (the Go port's map semantics).
493#[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
509/// `unquoteHeaderParam` mirrors the email parser's param unquoting: matching
510/// outer quotes are stripped and escaped quotes and backslashes are
511/// unescaped; asymmetric quotes are kept verbatim.
512fn 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/// `splitHeaderParams`: split on semicolons that are not inside a quoted
537/// string, mirroring `email.utils._parseparam`'s quote-aware splitting.
538#[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>&note=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                // The raw value still scans after the clean walk (walk first,
657                // then raw: an attack can span the leaf boundaries).
658                (
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        // label scan, filename entry, disposition header; no payload entry.
766        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(&params, "name"), Some("a;b"));
847        assert_eq!(param_lookup(&params, "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(&params, "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}