waf_detection/request_smuggling.rs
1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! HTTP request-smuggling defence (Fase 6 / Pillar 4).
5//!
6//! This is NOT content-inspection: it validates HTTP **framing** — how the
7//! request body boundary is declared (`Content-Length` vs `Transfer-Encoding`).
8//! Smuggling is a disagreement between how two parsers (the WAF and the upstream)
9//! interpret those boundaries; forwarding an *ambiguous* framing is the attack
10//! vector. So this runs in `Phase::Connection`, BEFORE normalization/detection,
11//! and on confirmed-ambiguous framing it **rejects with 400** (binary, never a
12//! score).
13//!
14//! Boundary (shared with `header_injection`, see ARCHITECTURE §8): hyper parses
15//! and re-serializes the request, rejecting obs-fold / whitespace-before-colon and
16//! trimming OWS *before* this module sees the headers, and regenerating CL/TE
17//! toward the backend. This module is defence-in-depth over the semantic framing
18//! ambiguities that still reach it (CL+TE, duplicate/invalid CL, obfuscated/
19//! duplicate TE). If the HTTP parser is ever changed, or a path without
20//! re-serialization is introduced, the raw-byte checks (whitespace-before-colon,
21//! obs-fold) must be re-implemented here — see the explicit assumption in §8.
22
23use waf_core::{Config, Decision, Phase, RequestContext, WafModule};
24
25#[derive(Default)]
26pub struct RequestSmugglingModule {
27 enabled: bool,
28}
29
30impl RequestSmugglingModule {
31 pub fn new() -> Self {
32 Self::default()
33 }
34}
35
36fn reject(reason: &'static str) -> Decision {
37 Decision::Reject {
38 rule_id: "request-smuggling".to_string(),
39 reason: reason.to_string(),
40 status: 400,
41 retry_after: None,
42 }
43}
44
45/// A valid request `Content-Length` is a single run of ASCII digits (no sign, no
46/// list, no spaces). hyper has already trimmed OWS, so anything else is illegal.
47fn is_valid_content_length(v: &str) -> bool {
48 !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit())
49}
50
51impl WafModule for RequestSmugglingModule {
52 fn id(&self) -> &str {
53 "request_smuggling"
54 }
55
56 fn phase(&self) -> Phase {
57 // Connection phase → runs in `run_connection`, before normalization, so
58 // illegal framing is refused without paying for parsing/detection.
59 Phase::Connection
60 }
61
62 fn init(&mut self, cfg: &Config) {
63 self.enabled = cfg.modules.request_smuggling.enabled;
64 }
65
66 fn inspect(&self, ctx: &RequestContext) -> Decision {
67 if !self.enabled {
68 return Decision::Allow;
69 }
70
71 // Header names are the lowercase canonical form; duplicates are preserved
72 // as separate entries by the proxy's context builder.
73 let cl: Vec<&str> = ctx
74 .headers
75 .iter()
76 .filter(|(n, _)| n == "content-length")
77 .map(|(_, v)| v.as_str())
78 .collect();
79 let te: Vec<&str> = ctx
80 .headers
81 .iter()
82 .filter(|(n, _)| n == "transfer-encoding")
83 .map(|(_, v)| v.as_str())
84 .collect();
85
86 // Rule 1: Content-Length AND Transfer-Encoding together. RFC tells ONE
87 // upstream to prefer TE, but an in-path WAF that forwards the ambiguity has
88 // no guarantee the backend resolves it identically → reject, don't propagate.
89 if !cl.is_empty() && !te.is_empty() {
90 return reject("content-length and transfer-encoding both present");
91 }
92
93 // Rule 2: Content-Length integrity.
94 if !cl.is_empty() {
95 if cl.len() > 1 {
96 return reject("duplicate content-length headers");
97 }
98 if !is_valid_content_length(cl[0]) {
99 return reject("malformed content-length value");
100 }
101 }
102
103 // Rule 3: Transfer-Encoding integrity. Strict posture: the only accepted
104 // request TE is the single token "chunked" (case-insensitive). Lists like
105 // "gzip, chunked" and obfuscations like "xchunked"/"chunked, chunked" are
106 // the preferred smuggling ground; we re-serialize toward the backend, so we
107 // never need to honour exotic client transfer-codings.
108 if !te.is_empty() {
109 if te.len() > 1 {
110 return reject("duplicate transfer-encoding headers");
111 }
112 if !te[0].eq_ignore_ascii_case("chunked") {
113 return reject("obfuscated or non-chunked transfer-encoding");
114 }
115 }
116
117 Decision::Allow
118 }
119}