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, 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
176/// `appendFormBodyValues`: `parse_qsl` pairs with `keep_blank_values`, the
177/// name scanned as a `request_body` value, then the value as a `form_field`
178/// value
179/// (with the embedded JSON walk taking precedence over the raw string, exactly
180/// like `_check_value_enhanced`'s embedded-JSON-first order).
181fn 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        // An excluded field name skips the whole pair (name and value).
189        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
199/// `appendFieldBodyValue`: one form/multipart field value. A value that parses
200/// as a JSON object or array is walked first (leaf context gains the
201/// `:embedded_json` suffix) and the raw value is still scanned afterwards,
202/// exactly like `_check_embedded_json_if_applicable` running the walk and then
203/// `_check_value_enhanced` on the raw string: an attack can span the leaf
204/// boundaries and exist only in the raw text (guard-core #122's
205/// `body_raw_span_sqli_comment` vector).
206fn 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
221/// `appendMultipartBodyValues`: when the body does not parse into at least one
222/// leaf part, the whole raw body is scanned as one `request_body` blob value
223/// (Python's `_scan_blob_body` fallback, including the no-parts-with-final-
224/// boundary case the email parser reports as `is_multipart() == False`).
225fn 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(&params, "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
245/// `appendMultipartPartValues`: the per-part entry construction of
246/// `_multipart_part_entries`, scanned with the label name scan in front,
247/// exactly like the reference scanning the label once per entry (first-hit
248/// identical). A part that yields no entries is not scanned at all, like the
249/// reference.
250///
251/// The entries are the filename entry, every part header entry, and the
252/// payload entries (islands when the part is a binary-like file part).
253fn 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    // An excluded part label skips the whole part (filename entry, part
261    // headers, payload), exactly the reference's excluded-field routing.
262    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
310/// Extract a content-disposition parameter the way the Python email parser
311/// does: tolerant semicolon splitting that respects quoted strings, outer
312/// quotes stripped only when they pair, escaped quotes unescaped. Later
313/// duplicates win (the Go port's map semantics).
314fn 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(&params, param)
320                .map_or_else(|| (String::new(), false), |found| (found.to_owned(), true))
321        },
322    )
323}
324
325/// `partRFC2231Filename` (`get_filename`'s RFC 2231 fallback): when no plain
326/// filename parameter exists, the extended `filename*` piece, or the
327/// `filename*0*`..`filename*N*` segments, are joined, the charset prefix
328/// stripped, and the value percent-decoded.
329fn 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(&params, "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(&params, &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
347/// `decodeRFC2231Value`: strip the `charset'lang'` prefix, percent-decode
348/// tolerantly.
349fn decode_rfc2231_value(value: &str) -> String {
350    // A charset/lang prefix is stripped only when both quotes are present;
351    // otherwise the value stays untouched (the Go port's semantics).
352    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/// One `name=value` form pair, `unquote_plus`-decoded.
366#[derive(Debug, Clone, PartialEq, Eq)]
367pub struct FormPair {
368    pub name: String,
369    pub value: String,
370}
371
372/// `parseFormPairs` mirrors `urllib.parse.parse_qsl` with
373/// `keep_blank_values=True` and the `"&"` separator.
374///
375/// Empty chunks are dropped, pairs partition on the first `"="`, missing
376/// values are kept as empty strings, plus/space folding and tolerant
377/// percent-decoding apply.
378#[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/// `unquote_plus` with `errors="surrogateescape"`.
397///
398/// `"+"` becomes a space and every valid `%XX` escape contributes its byte;
399/// invalid escape sequences stay literal. Byte-oriented like the reference;
400/// invalid UTF-8 sequences surface as U+FFFD at the decode boundary.
401#[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/// `percentDecodeTolerant`: every valid `%XX` escape contributes its byte,
437/// invalid sequences stay literal.
438#[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
461/// Ordered content-type / disposition parameters (names lowercased).
462pub type HeaderParams = Vec<(String, String)>;
463
464/// `parseMediaTypeParams`: split a Content-Type style header into its
465/// lowercased main type and its parameters.
466///
467/// The main type keeps the part after `/` (the Go port lowercases the whole
468/// first piece); splitting uses the same tolerant quoted-string handling as
469/// the disposition parser.
470#[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/// `parseHeaderParams`: a Content-Disposition style header value split into
487/// its parameters (everything after the first `";"` piece) plus the main
488/// value.
489#[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/// Last-wins parameter lookup (the Go port's map semantics).
503#[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
519/// `unquoteHeaderParam` mirrors the email parser's param unquoting: matching
520/// outer quotes are stripped and escaped quotes and backslashes are
521/// unescaped; asymmetric quotes are kept verbatim.
522fn 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/// `splitHeaderParams`: split on semicolons that are not inside a quoted
547/// string, mirroring `email.utils._parseparam`'s quote-aware splitting.
548#[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>&note=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                // The raw value still scans after the clean walk (walk first,
670                // then raw: an attack can span the leaf boundaries).
671                (
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        // label scan, filename entry, disposition header; no payload entry.
779        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(&params, "name"), Some("a;b"));
860        assert_eq!(param_lookup(&params, "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(&params, "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        // Lowercase hex digits ride the same nibble table.
880        assert_eq!(unquote_plus("%2f%2F"), "//");
881        assert_eq!(unquote_plus("a+ b"), "a  b");
882        // A truncated or non-hex escape keeps its percent verbatim.
883        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        // An escaped backslash and quote unescape; any other escaped char
902        // keeps its backslash verbatim.
903        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        // A depth-3 object with a threat in the deepest leaf: at the
930        // reference default the walk reaches the leaf, at a knob-capped
931        // depth of 1 the object serializes to compact JSON and scans as
932        // one text value.
933        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        // everything scanned without exclusions
982        let all = extract_body_scan_values(&body, &content_type, &config());
983        assert!(!all.is_empty());
984
985        // the excluded part label drops its values entirely
986        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        // a completely bare part: no disposition, no headers, empty payload
1001        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        // a part with no name, no filename, and no entries contributes
1009        // nothing at all
1010        assert!(values.is_empty(), "unexpected values: {values:?}");
1011
1012        // a part that DOES produce values: a named part's payload renders
1013        // under its own name, never a bare `file`
1014        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        // a named part with only headers keeps its entries (the header line
1032        // alone is a value)
1033        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}