praxis-proxy-protocol 0.7.3

HTTP, TCP, and protocol adapters for Praxis
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2024 Praxis Contributors

//! Request header normalization per [RFC 9110] and [RFC 9112].
//!
//! Provides a single entry point, `normalize_request_headers`, that
//! runs before the filter pipeline to enforce consistent header semantics:
//!
//! - Rejects requests with conflicting single-value headers (`Content-Length`, `Content-Type`)
//! - Unfolds obsolete line folding (obs-fold) per [RFC 9112 Section 5.2]
//! - Rejects obs-fold on security-sensitive headers (`Host`, `Content-Length`)
//!
//! Case normalization is handled by Pingora's underlying [`HeaderMap`],
//! which uses case-insensitive keys.
//!
//! [RFC 9110]: https://datatracker.ietf.org/doc/html/rfc9110
//! [RFC 9112]: https://datatracker.ietf.org/doc/html/rfc9112
//! [RFC 9112 Section 5.2]: https://datatracker.ietf.org/doc/html/rfc9112#section-5.2
//! [`HeaderMap`]: http::HeaderMap

use pingora_proxy::Session;
use praxis_filter::Rejection;
use tracing::debug;

// -----------------------------------------------------------------------------
// Constants
// -----------------------------------------------------------------------------

/// Headers that MUST NOT appear with conflicting duplicate values.
/// Host is already validated in `validation.rs`; Content-Length and
/// Content-Type are checked here.
const SINGLE_VALUE_HEADERS: &[http::header::HeaderName] = &[http::header::CONTENT_LENGTH, http::header::CONTENT_TYPE];

/// Headers where obs-fold is a security risk and must be rejected.
const OBS_FOLD_REJECT_HEADERS: &[http::header::HeaderName] = &[http::header::HOST, http::header::CONTENT_LENGTH];

// -----------------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------------

/// Normalize request headers, rejecting malformed requests.
///
/// Returns `Some(rejection)` if the request is invalid:
/// - Conflicting duplicate `Content-Length` or `Content-Type` values
/// - Both `Content-Length` and `Transfer-Encoding` present ([RFC 9112 Section 6.2])
/// - Obs-fold on `Host` or `Content-Length` headers
///
/// On success, obs-fold sequences in non-sensitive headers are replaced
/// with a single space per [RFC 9112 Section 5.2].
///
/// ```ignore
/// // Requires a live `pingora_proxy::Session`.
/// if let Some(rejection) = normalize_request_headers(session) {
///     send_rejection(session, rejection).await;
///     return Ok(true);
/// }
/// ```
///
/// [RFC 9112 Section 5.2]: https://datatracker.ietf.org/doc/html/rfc9112#section-5.2
/// [RFC 9112 Section 6.2]: https://datatracker.ietf.org/doc/html/rfc9112#section-6.2
pub(in crate::http) fn normalize_request_headers(session: &mut Session) -> Option<Rejection> {
    if let Some(r) = reject_conflicting_single_value_headers(session) {
        return Some(r);
    }
    if let Some(r) = reject_dual_content_length_transfer_encoding(session) {
        return Some(r);
    }
    if let Some(r) = handle_obs_fold(session) {
        return Some(r);
    }
    None
}

// -----------------------------------------------------------------------------
// Duplicate Single-Value Headers
// -----------------------------------------------------------------------------

/// Reject requests where a single-value header appears multiple
/// times with differing values. Identical duplicates are collapsed.
fn reject_conflicting_single_value_headers(session: &mut Session) -> Option<Rejection> {
    for header_name in SINGLE_VALUE_HEADERS {
        // Walk the multimap in place: the dominant zero-or-one-value case
        // must not allocate a Vec per header per request.
        let mut values = session.req_header().headers.get_all(header_name).iter();
        let Some(first) = values.next() else {
            continue;
        };
        let first_bytes = first.as_bytes();
        let mut saw_duplicate = false;
        for value in values {
            saw_duplicate = true;
            if value.as_bytes() != first_bytes {
                debug!(header = %header_name, "rejecting request with conflicting duplicate header");
                return Some(Rejection::status(400));
            }
        }
        if !saw_duplicate {
            continue;
        }

        debug!(header = %header_name, "canonicalizing duplicate identical header");
        let canonical = first.clone();
        let _remove = session.req_header_mut().remove_header(header_name.as_str());
        let _insert = session.req_header_mut().insert_header(header_name.clone(), canonical);
    }

    None
}

// -----------------------------------------------------------------------------
// Dual Content-Length / Transfer-Encoding (RFC 9112 Section 6.2)
// -----------------------------------------------------------------------------

/// Reject requests that carry both `Content-Length` and
/// `Transfer-Encoding`.
///
/// [RFC 9112 Section 6.2] requires a proxy to reject such messages
/// or strip `Content-Length` before forwarding. The combination is
/// the canonical request-smuggling ambiguity: the proxy reads the
/// body per `Transfer-Encoding` while a backend may read per
/// `Content-Length`, enabling CL/TE desync attacks.
///
/// [RFC 9112 Section 6.2]: https://datatracker.ietf.org/doc/html/rfc9112#section-6.2
fn reject_dual_content_length_transfer_encoding(session: &Session) -> Option<Rejection> {
    let headers = &session.req_header().headers;
    if headers.contains_key(http::header::CONTENT_LENGTH) && headers.contains_key(http::header::TRANSFER_ENCODING) {
        debug!("rejecting request with both Content-Length and Transfer-Encoding");
        return Some(Rejection::status(400));
    }
    None
}

// -----------------------------------------------------------------------------
// Obs-Fold (RFC 9112 Section 5.2)
// -----------------------------------------------------------------------------

/// Returns `true` if the byte sequence contains obs-fold (`\r\n` followed by SP/HTAB).
fn contains_obs_fold(value: &[u8]) -> bool {
    value.windows(3).any(|w| matches!(w, [b'\r', b'\n', b' ' | b'\t']))
}

/// Replace obs-fold sequences with a single SP.
///
/// Each `\r\n[ \t]+` sequence becomes one space character.
fn unfold_obs_fold(value: &[u8]) -> Vec<u8> {
    let mut result = Vec::with_capacity(value.len());
    let mut i = 0;
    while i < value.len() {
        let is_obs_fold = value.get(i) == Some(&b'\r')
            && value.get(i + 1) == Some(&b'\n')
            && matches!(value.get(i + 2), Some(b' ' | b'\t'));

        if is_obs_fold {
            result.push(b' ');
            i += 3;
            while matches!(value.get(i), Some(b' ' | b'\t')) {
                i += 1;
            }
        } else {
            if let Some(&b) = value.get(i) {
                result.push(b);
            }
            i += 1;
        }
    }
    result
}

/// Handle obs-fold in all request headers.
///
/// Rejects the request if obs-fold is found in security-sensitive
/// headers. For other headers, replaces obs-fold with a single SP.
fn handle_obs_fold(session: &mut Session) -> Option<Rejection> {
    // Obs-fold (a CRLF followed by SP/HTAB inside a header value) is an
    // HTTP/1.x wire artifact. The HTTP/2 and HTTP/3 codecs reject CR/LF in
    // header values at frame decode, so no obs-fold can reach here on those
    // protocols — skip the full per-request header scan for them.
    if !matches!(
        session.req_header().version,
        http::Version::HTTP_09 | http::Version::HTTP_10 | http::Version::HTTP_11
    ) {
        return None;
    }

    for name in OBS_FOLD_REJECT_HEADERS {
        if let Some(value) = session.req_header().headers.get(name)
            && contains_obs_fold(value.as_bytes())
        {
            debug!(header = %name, "rejecting request with obs-fold in security-sensitive header");
            return Some(Rejection::status(400));
        }
    }

    let headers_snapshot: Vec<(http::header::HeaderName, http::header::HeaderValue)> = session
        .req_header()
        .headers
        .iter()
        .filter(|(name, value)| !OBS_FOLD_REJECT_HEADERS.contains(name) && contains_obs_fold(value.as_bytes()))
        .map(|(name, value)| (name.clone(), value.clone()))
        .collect();

    for (name, value) in headers_snapshot {
        let unfolded = unfold_obs_fold(value.as_bytes());
        if let Ok(new_value) = http::header::HeaderValue::from_bytes(&unfolded) {
            debug!(header = %name, "replacing obs-fold with single SP");
            let _insert = session.req_header_mut().insert_header(name, new_value);
        }
    }

    None
}

// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------

#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, reason = "tests")]
mod tests {
    use super::*;

    #[test]
    fn contains_obs_fold_detects_crlf_sp() {
        assert!(
            contains_obs_fold(b"value\r\n continuation"),
            "CRLF followed by SP is obs-fold"
        );
    }

    #[test]
    fn contains_obs_fold_detects_crlf_htab() {
        assert!(
            contains_obs_fold(b"value\r\n\tcontinuation"),
            "CRLF followed by HTAB is obs-fold"
        );
    }

    #[test]
    fn contains_obs_fold_ignores_bare_crlf() {
        assert!(
            !contains_obs_fold(b"value\r\nno-fold"),
            "CRLF without following whitespace is not obs-fold"
        );
    }

    #[test]
    fn contains_obs_fold_false_for_normal_value() {
        assert!(
            !contains_obs_fold(b"plain header value"),
            "normal value has no obs-fold"
        );
    }

    #[test]
    fn contains_obs_fold_false_for_empty() {
        assert!(!contains_obs_fold(b""), "empty value has no obs-fold");
    }

    #[test]
    fn contains_obs_fold_false_for_trailing_crlf() {
        assert!(
            !contains_obs_fold(b"value\r\n"),
            "trailing CRLF without whitespace is not obs-fold"
        );
    }

    #[test]
    fn unfold_replaces_crlf_sp_with_single_sp() {
        let input = b"value\r\n continuation";
        let result = unfold_obs_fold(input);
        assert_eq!(result, b"value continuation", "obs-fold should become single SP");
    }

    #[test]
    fn unfold_replaces_crlf_htab_with_single_sp() {
        let input = b"value\r\n\tcontinuation";
        let result = unfold_obs_fold(input);
        assert_eq!(result, b"value continuation", "CRLF+HTAB should become single SP");
    }

    #[test]
    fn unfold_collapses_multiple_whitespace_after_fold() {
        let input = b"value\r\n   continuation";
        let result = unfold_obs_fold(input);
        assert_eq!(
            result, b"value continuation",
            "obs-fold with extra whitespace should collapse to single SP"
        );
    }

    #[test]
    fn unfold_handles_multiple_folds() {
        let input = b"a\r\n b\r\n c";
        let result = unfold_obs_fold(input);
        assert_eq!(result, b"a b c", "multiple obs-folds should each become single SP");
    }

    #[test]
    fn unfold_preserves_normal_value() {
        let input = b"plain value";
        let result = unfold_obs_fold(input);
        assert_eq!(result, b"plain value", "value without obs-fold should be unchanged");
    }

    #[test]
    fn unfold_preserves_empty() {
        let result = unfold_obs_fold(b"");
        assert!(result.is_empty(), "empty input should produce empty output");
    }

    #[test]
    fn contains_obs_fold_single_crlf_sp() {
        assert!(
            contains_obs_fold(b"\r\n value"),
            "CRLF+SP at the very start of the value is obs-fold"
        );
    }

    #[test]
    fn contains_obs_fold_multiple_folds() {
        assert!(
            contains_obs_fold(b"a\r\n b\r\n c"),
            "value with multiple obs-fold sequences should be detected"
        );
    }

    #[test]
    fn contains_obs_fold_only_cr_no_lf() {
        assert!(
            !contains_obs_fold(b"value\r continuation"),
            "bare CR followed by space is not obs-fold"
        );
    }

    #[test]
    fn contains_obs_fold_only_lf_sp() {
        assert!(
            !contains_obs_fold(b"value\n continuation"),
            "bare LF followed by space is not obs-fold"
        );
    }

    #[test]
    fn unfold_at_start_of_value() {
        let result = unfold_obs_fold(b"\r\n continuation");
        assert_eq!(
            result, b" continuation",
            "obs-fold at the very start should become single SP"
        );
    }

    #[test]
    fn unfold_consecutive_folds() {
        let result = unfold_obs_fold(b"a\r\n \r\n b");
        assert_eq!(
            result, b"a  b",
            "two back-to-back obs-folds should each become single SP"
        );
    }

    #[test]
    fn unfold_mixed_whitespace_after_fold() {
        let result = unfold_obs_fold(b"val\r\n\t  rest");
        assert_eq!(
            result, b"val rest",
            "CRLF followed by tab then spaces should collapse to single SP"
        );
    }

    #[test]
    fn unfold_preserves_internal_crlf_without_continuation() {
        let result = unfold_obs_fold(b"before\r\nafter");
        assert_eq!(
            result, b"before\r\nafter",
            "bare CRLF without following whitespace should be kept as-is"
        );
    }

    #[test]
    fn unfold_single_byte_values() {
        assert_eq!(unfold_obs_fold(b"x"), b"x", "single byte input unchanged");
        assert_eq!(unfold_obs_fold(b"ab"), b"ab", "two byte input unchanged");
    }

    /// Build a proxy session that has read the given raw HTTP/1.1
    /// request. The client half must stay alive for response writes.
    async fn session_for(raw: &str) -> (Session, tokio::io::DuplexStream) {
        use tokio::io::AsyncWriteExt as _;

        let (mut client, server) = tokio::io::duplex(1_048_576);
        client.write_all(raw.as_bytes()).await.unwrap();
        let mut session = Session::new_h1(Box::new(server));
        let read = session.read_request().await.unwrap();
        assert!(read, "the session must parse the request header");
        (session, client)
    }

    #[tokio::test]
    async fn identical_duplicate_content_type_is_canonicalized() {
        let (mut session, _client) = session_for("GET / HTTP/1.1\r\nHost: x\r\n\r\n").await;
        session
            .req_header_mut()
            .append_header("content-type", "text/plain")
            .unwrap();
        session
            .req_header_mut()
            .append_header("content-type", "text/plain")
            .unwrap();

        let rejection = normalize_request_headers(&mut session);
        assert!(rejection.is_none(), "identical duplicates must not reject");
        let count = session
            .req_header()
            .headers
            .get_all(http::header::CONTENT_TYPE)
            .iter()
            .count();
        assert_eq!(count, 1, "identical duplicates must collapse to one value");
    }

    #[tokio::test]
    async fn conflicting_duplicate_content_type_is_rejected() {
        let (mut session, _client) = session_for("GET / HTTP/1.1\r\nHost: x\r\n\r\n").await;
        session
            .req_header_mut()
            .append_header("content-type", "text/plain")
            .unwrap();
        session
            .req_header_mut()
            .append_header("content-type", "application/json")
            .unwrap();

        let rejection = normalize_request_headers(&mut session);
        assert!(
            rejection.is_some_and(|r| r.status == 400),
            "conflicting duplicates must reject with 400"
        );
    }

    #[tokio::test]
    async fn dual_content_length_and_transfer_encoding_is_rejected() {
        let (mut session, _client) = session_for("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello").await;
        session
            .req_header_mut()
            .insert_header("transfer-encoding", "chunked")
            .unwrap();
        let rejection = normalize_request_headers(&mut session);
        assert!(
            rejection.is_some_and(|r| r.status == 400),
            "requests with both Content-Length and Transfer-Encoding must be rejected"
        );
    }

    #[tokio::test]
    async fn content_length_alone_passes_normalization() {
        let (mut session, _client) = session_for("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello").await;
        let rejection = normalize_request_headers(&mut session);
        assert!(rejection.is_none(), "Content-Length alone must pass");
    }

    #[tokio::test]
    async fn clean_requests_pass_normalization() {
        let (mut session, _client) = session_for("GET / HTTP/1.1\r\nHost: x\r\nContent-Type: text/plain\r\n\r\n").await;
        let rejection = normalize_request_headers(&mut session);
        assert!(rejection.is_none(), "well-formed requests must pass");
    }
}