1use std::collections::HashSet;
29
30pub const PAD_KEY: &str = "_wafrift_pad";
33
34pub const MIN_USEFUL_PAD: usize = 4 * 1024;
37
38pub const MAX_USEFUL_PAD: usize = 8 * 1024 * 1024;
43
44fn fill(n: usize) -> Vec<u8> {
57 const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
58 let mut v = Vec::with_capacity(n);
59 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#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum PadOutcome {
76 Padded { bytes: Vec<u8>, added: usize },
79 SkippedOpaque,
83 SkippedTooSmall,
85}
86
87pub fn pad(body: &[u8], content_type: &str, requested_bytes: usize) -> PadOutcome {
98 if requested_bytes < MIN_USEFUL_PAD {
99 return PadOutcome::SkippedTooSmall;
100 }
101 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 if let Some(boundary) = extract_boundary(content_type) {
120 return pad_multipart(body, &boundary, requested_bytes);
121 }
122 return PadOutcome::SkippedOpaque;
125 }
126 if main_type.starts_with("text/") || main_type == "application/xml" {
127 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 if body.len() > MAX_USEFUL_PAD {
150 return PadOutcome::SkippedOpaque;
151 }
152 let pad = fill(requested_bytes);
153 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 if let Some(open) = s.find('{') {
187 let after = &s[open + 1..];
188 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 }
201 return PadOutcome::Padded {
202 bytes: new_body,
203 added,
204 };
205 }
206 }
207 let Ok(original) = std::str::from_utf8(body) else {
209 return PadOutcome::SkippedOpaque;
210 };
211 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 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 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 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 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#[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#[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#[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 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 assert_eq!(fill(8 * 1024), v);
374 }
375
376 #[test]
377 fn fill_no_long_runs() {
378 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 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 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 for ct in &[
417 "",
418 "////",
419 ";;;;",
420 "application/json;;;boundary=",
421 "\x00\x01\x02",
422 ] {
423 let _ = pad(b"id=42", ct, 8 * 1024);
425 }
426 }
427
428 #[test]
429 fn empty_input_with_huge_size() {
430 let out = pad(b"", "application/json", 1024 * 1024);
433 let PadOutcome::Padded { bytes, .. } = out else {
434 panic!()
435 };
436 let _: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
438 }
439
440 #[test]
441 fn fill_distinct_per_size() {
442 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 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 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 assert!(s.starts_with(&format!("--{boundary}\r\n")));
538 assert!(s.contains("name=\"_wafrift_pad\""));
539 assert!(s.contains("' OR 1=1--"));
541 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 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 assert!(
601 matches!(out, PadOutcome::SkippedOpaque | PadOutcome::SkippedTooSmall),
602 "oversized JSON body should be skipped, got {out:?}"
603 );
604 }
605}