Skip to main content

wafrift_encoding/encoding/
request_line.rs

1//! HTTP request-line differential encoders.
2//!
3//! Almost every byte of the request line — the first three tokens of
4//! an HTTP/1.x request — has some WAF parser that misreads it. This
5//! module produces request lines that one parser accepts as the
6//! benign request the WAF expects, while a different parser further
7//! down the chain reinterprets them.
8//!
9//! - **Method tricks.** Exotic methods (WebDAV: `PROPFIND`, `LOCK`,
10//!   `MERGE`; CalDAV: `REPORT`; private: `PURGE`, `CONNECT`). Some
11//!   WAFs hard-allow `GET`/`POST`/`PUT` only — others allow anything
12//!   but apply *no* rules to "weird" methods.
13//! - **Method case + whitespace.** `GeT /foo`, `GET\t/foo`, `GET
14//!   /foo` (multiple spaces), `GET<TAB>/foo<TAB>HTTP/1.1`. RFC says
15//!   ONE space; some parsers fold runs of whitespace.
16//! - **Version tricks.** `HTTP/0.9` (response has no headers — some
17//!   WAFs don't classify), `HTTP/1.99`, `HTTP/2.0` (mismatched
18//!   version vs transport), no version at all (HTTP/0.9-style).
19//! - **URI forms.** RFC 7230 §5.3 allows four request-target forms:
20//!   `origin-form` (`/path`), `absolute-form` (`http://host/path`),
21//!   `authority-form` (`host:port` — only for CONNECT), `asterisk-form`
22//!   (`*` — only for OPTIONS). Most WAFs assume origin-form; passing
23//!   absolute-form is a classic auth/path-bypass trick.
24
25/// Generate every method variant that has a known parser-discrepancy
26/// in some WAF, expressed as one possible first-token-of-request-line.
27///
28/// Useful as the seed set for a `--method` fuzzer or evolution loop.
29#[must_use]
30pub fn exotic_methods() -> Vec<&'static str> {
31    vec![
32        // WebDAV
33        "PROPFIND",
34        "PROPPATCH",
35        "MKCOL",
36        "COPY",
37        "MOVE",
38        "LOCK",
39        "UNLOCK",
40        // CalDAV / CardDAV
41        "REPORT",
42        "ACL",
43        "SEARCH",
44        // Cache control (Varnish / Squid private)
45        "PURGE",
46        "BAN",
47        "REFRESH",
48        // Versioning extensions (RFC 3253)
49        "VERSION-CONTROL",
50        "MKWORKSPACE",
51        "UPDATE",
52        "CHECKIN",
53        "CHECKOUT",
54        "MKACTIVITY",
55        "BASELINE-CONTROL",
56        "MERGE",
57        // Patch (RFC 5789) — older WAFs predate it
58        "PATCH",
59        // Tracing
60        "TRACE",
61        // Lowercase variants (some WAFs case-fold, some don't)
62        "get",
63        "post",
64        "put",
65        "delete",
66        // Mixed case
67        "GeT",
68        "PoSt",
69        "PuT",
70        "DeLeTe",
71        // Tab/space padded names (the leading whitespace gets stripped
72        // by most servers but inspected literally by some WAFs)
73        " GET",
74        "\tGET",
75        " GET ",
76    ]
77}
78
79/// Produce request-line bytes where the URI is rendered in
80/// absolute-form (RFC 7230 §5.3.2).
81///
82/// `host_in_uri` is the host the URI's authority component carries;
83/// `path` is the path-and-query.
84///
85/// Example: `GET http://evil.example/admin HTTP/1.1\r\nHost: target\r\n`
86/// Origin may route by URI; WAF may route by Host. Classic SSRF/
87/// auth-bypass shape.
88#[must_use]
89pub fn absolute_uri_request_line(method: &str, host_in_uri: &str, path: &str) -> String {
90    format!("{method} http://{host_in_uri}{path} HTTP/1.1")
91}
92
93/// Same as `absolute_uri_request_line` but with HTTPS scheme.
94#[must_use]
95pub fn absolute_uri_https_request_line(method: &str, host_in_uri: &str, path: &str) -> String {
96    format!("{method} https://{host_in_uri}{path} HTTP/1.1")
97}
98
99/// Build a request line using a specific HTTP version string. Some
100/// parsers honor `HTTP/0.9` (no headers, no status line on response).
101/// Some accept `HTTP/2.0` as a version on the wire even when the
102/// transport is HTTP/1.1.
103#[must_use]
104pub fn request_line_with_version(method: &str, path: &str, version: &str) -> String {
105    format!("{method} {path} {version}")
106}
107
108/// Render a request line with non-standard whitespace between the
109/// three tokens.
110///
111/// RFC 7230 allows exactly one SP. Real parsers accept a wide variety
112/// of separator strings — TAB, multiple SP, mixed — and the WAF may
113/// disagree with the origin on what counts as "the path".
114#[must_use]
115pub fn request_line_with_whitespace(
116    method: &str,
117    method_sep: &str,
118    path: &str,
119    path_sep: &str,
120    version: &str,
121) -> String {
122    format!("{method}{method_sep}{path}{path_sep}{version}")
123}
124
125/// Asterisk-form request target. RFC 7230 §5.3.4 — only valid for
126/// `OPTIONS *`. Some WAFs reject; some pass without rule application.
127#[must_use]
128pub fn asterisk_form_request_line(method: &str) -> String {
129    format!("{method} * HTTP/1.1")
130}
131
132/// Authority-form request target (`host:port`). RFC 7230 §5.3.3 —
133/// only valid for `CONNECT`. A WAF that sees `CONNECT internal:8080`
134/// and the upstream proxy that accepts it can be tricked into
135/// tunneling to private addresses.
136#[must_use]
137pub fn authority_form_request_line(method: &str, host: &str, port: u16) -> String {
138    format!("{method} {host}:{port} HTTP/1.1")
139}
140
141/// Returns the list of every request-line trick exposed by this
142/// module, used by the integration test as a registry to assert
143/// none was forgotten.
144pub const REQUEST_LINE_TRICKS: &[&str] = &[
145    "exotic_methods",
146    "absolute_uri_request_line",
147    "absolute_uri_https_request_line",
148    "request_line_with_version",
149    "request_line_with_whitespace",
150    "asterisk_form_request_line",
151    "authority_form_request_line",
152];
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn exotic_methods_includes_propfind() {
160        assert!(exotic_methods().contains(&"PROPFIND"));
161    }
162
163    #[test]
164    fn exotic_methods_includes_purge() {
165        assert!(exotic_methods().contains(&"PURGE"));
166    }
167
168    #[test]
169    fn exotic_methods_includes_lowercase() {
170        assert!(exotic_methods().contains(&"get"));
171        assert!(exotic_methods().contains(&"post"));
172    }
173
174    #[test]
175    fn exotic_methods_includes_mixed_case() {
176        assert!(exotic_methods().contains(&"GeT"));
177    }
178
179    #[test]
180    fn exotic_methods_includes_whitespace_pad() {
181        assert!(exotic_methods().iter().any(|m| m.starts_with(' ')));
182        assert!(exotic_methods().iter().any(|m| m.starts_with('\t')));
183    }
184
185    #[test]
186    fn exotic_methods_minimum_count() {
187        // Adding more is fine; removing a known parser-discrepancy
188        // method is not — every entry here has been observed to flip
189        // SOME WAF's rule set off.
190        assert!(
191            exotic_methods().len() >= 25,
192            "regression: lost coverage of exotic-method set"
193        );
194    }
195
196    #[test]
197    fn absolute_uri_basic() {
198        let rl = absolute_uri_request_line("GET", "evil.example", "/admin");
199        assert_eq!(rl, "GET http://evil.example/admin HTTP/1.1");
200    }
201
202    #[test]
203    fn absolute_uri_https() {
204        let rl = absolute_uri_https_request_line("POST", "evil.example", "/api");
205        assert_eq!(rl, "POST https://evil.example/api HTTP/1.1");
206    }
207
208    #[test]
209    fn absolute_uri_preserves_query() {
210        let rl = absolute_uri_request_line("GET", "h", "/a?b=c&d=e");
211        assert!(rl.contains("?b=c&d=e"));
212    }
213
214    #[test]
215    fn version_explicit_http_0_9() {
216        let rl = request_line_with_version("GET", "/", "HTTP/0.9");
217        assert_eq!(rl, "GET / HTTP/0.9");
218    }
219
220    #[test]
221    fn version_explicit_http_1_99() {
222        let rl = request_line_with_version("GET", "/", "HTTP/1.99");
223        assert_eq!(rl, "GET / HTTP/1.99");
224    }
225
226    #[test]
227    fn version_explicit_http_2_on_h1_wire() {
228        let rl = request_line_with_version("GET", "/", "HTTP/2.0");
229        assert_eq!(rl, "GET / HTTP/2.0");
230    }
231
232    #[test]
233    fn whitespace_tab_between_tokens() {
234        let rl = request_line_with_whitespace("GET", "\t", "/", "\t", "HTTP/1.1");
235        assert_eq!(rl, "GET\t/\tHTTP/1.1");
236        assert!(!rl.contains(' '), "no SP, only TAB");
237    }
238
239    #[test]
240    fn whitespace_multiple_spaces() {
241        let rl = request_line_with_whitespace("GET", "   ", "/", "   ", "HTTP/1.1");
242        assert_eq!(rl, "GET   /   HTTP/1.1");
243    }
244
245    #[test]
246    fn whitespace_mixed() {
247        let rl = request_line_with_whitespace("GET", " \t ", "/", "\t \t", "HTTP/1.1");
248        assert!(rl.contains('\t'));
249        assert!(rl.contains(' '));
250    }
251
252    #[test]
253    fn asterisk_form_options() {
254        let rl = asterisk_form_request_line("OPTIONS");
255        assert_eq!(rl, "OPTIONS * HTTP/1.1");
256    }
257
258    #[test]
259    fn asterisk_form_invalid_method_still_produces_string() {
260        // Asterisk form is only valid for OPTIONS per RFC, but we
261        // produce the wire bytes regardless so callers can test
262        // server tolerance.
263        let rl = asterisk_form_request_line("GET");
264        assert_eq!(rl, "GET * HTTP/1.1");
265    }
266
267    #[test]
268    fn authority_form_connect() {
269        let rl = authority_form_request_line("CONNECT", "internal", 8080);
270        assert_eq!(rl, "CONNECT internal:8080 HTTP/1.1");
271    }
272
273    #[test]
274    fn authority_form_high_port() {
275        let rl = authority_form_request_line("CONNECT", "h", u16::MAX);
276        assert!(rl.ends_with("65535 HTTP/1.1"));
277    }
278
279    #[test]
280    fn registry_lists_every_function_we_expose() {
281        // Smoke that REQUEST_LINE_TRICKS hasn't drifted from the
282        // public API.
283        assert_eq!(REQUEST_LINE_TRICKS.len(), 7);
284    }
285
286    #[test]
287    fn no_function_produces_crlf_in_output() {
288        // Every output of this module is meant to be ONE line of a
289        // request — embedding CRLF would let a caller smuggle a
290        // second request line, which is a different attack class
291        // (smuggling crate). Keep the boundary clean.
292        let candidates = vec![
293            absolute_uri_request_line("GET", "h", "/p"),
294            absolute_uri_https_request_line("GET", "h", "/p"),
295            request_line_with_version("GET", "/", "HTTP/0.9"),
296            request_line_with_whitespace("GET", " ", "/", " ", "HTTP/1.1"),
297            asterisk_form_request_line("OPTIONS"),
298            authority_form_request_line("CONNECT", "h", 443),
299        ];
300        for c in candidates {
301            assert!(!c.contains("\r\n"), "no CRLF in request line: {c:?}");
302            assert!(!c.contains('\n'), "no LF in request line: {c:?}");
303        }
304    }
305
306    #[test]
307    fn deterministic_across_calls() {
308        let a = exotic_methods();
309        let b = exotic_methods();
310        assert_eq!(a, b);
311    }
312
313    #[test]
314    fn adversarial_long_path_no_panic() {
315        let big = "/a".repeat(10_000);
316        let _ = absolute_uri_request_line("GET", "h", &big);
317        let _ = request_line_with_version("GET", &big, "HTTP/1.1");
318    }
319}