Skip to main content

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}