armature-h1 0.3.0

Zero-allocation thread-per-core HTTP/1.1 server for the Armature framework
Documentation
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
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
//! Message body framing: the decision of how long the request body is.
//!
//! This is the most security-critical code in the crate. Request smuggling is,
//! in essence, two HTTP implementations disagreeing about where one message ends
//! and the next begins — so this module resolves nothing ambiguously. Every
//! unclear case is an error, and every error closes the connection.
//!
//! The function is deliberately pure and synchronous so it can be exhaustively
//! unit-tested and differentially fuzzed against another implementation.

use crate::header::HeaderId;
use crate::{Head, Limits, Version};

/// How to read the request body.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BodyKind {
    /// No body. A request without `Content-Length` or `Transfer-Encoding` has no
    /// body — unlike a response, whose length may run to end-of-stream.
    None,
    /// A body of exactly this many bytes.
    Length(u64),
    /// A chunked body, terminated by a zero-length chunk.
    Chunked,
}

/// An unresolvable or unacceptable framing.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
pub enum FramingError {
    /// Both `Content-Length` and `Transfer-Encoding` were present.
    #[error("both Content-Length and Transfer-Encoding present")]
    LengthAndTransferEncoding,
    /// `Content-Length` appeared more than once with differing values.
    #[error("conflicting Content-Length values")]
    DuplicateContentLength,
    /// `Content-Length` was not a bare decimal integer.
    #[error("malformed Content-Length")]
    InvalidContentLength,
    /// `chunked` was present but was not the final transfer coding.
    #[error("chunked is not the final transfer coding")]
    ChunkedNotFinal,
    /// A transfer coding other than `chunked` was requested.
    #[error("unsupported transfer coding")]
    UnsupportedTransferEncoding,
    /// `Transfer-Encoding` appeared on an HTTP/1.0 request.
    #[error("Transfer-Encoding on an HTTP/1.0 request")]
    TransferEncodingOnHttp10,
    /// HTTP/1.1 requires exactly one `Host`.
    #[error("missing Host")]
    MissingHost,
    /// More than one `Host` is ambiguous at any version.
    #[error("multiple Host fields")]
    MultipleHost,
    /// The declared body exceeded `Limits::max_body_bytes`.
    #[error("declared body too large")]
    BodyTooLarge,
}

impl FramingError {
    /// The status code to answer with before closing the connection.
    #[inline]
    pub fn status(&self) -> u16 {
        match self {
            FramingError::BodyTooLarge => 413,
            FramingError::UnsupportedTransferEncoding => 501,
            _ => 400,
        }
    }
}

/// Decide how to frame the request body.
///
/// Rule order is fixed and load-bearing:
///
/// 1. `Host` validity
/// 2. `Content-Length`-with-`Transfer-Encoding` conflict
/// 3. `Transfer-Encoding` analysis
/// 4. `Content-Length` analysis
/// 5. Declared-size limit
///
/// Step 2 precedes 3 and 4 so that a request carrying a *valid* value of each is
/// rejected outright rather than silently resolved in favor of one. That silent
/// resolution — and disagreement between peers about which one wins — is the
/// smuggling vector.
pub fn decide(head: &Head, limits: &Limits) -> Result<BodyKind, FramingError> {
    // Everything the rules below need about *presence* comes from one walk of
    // the field list. Asking `count()` three times and then iterating again per
    // field walked a 96-entry list up to five times over, for information a
    // single pass already has.
    let mut host_count = 0usize;
    let mut has_len = false;
    let mut has_te = false;
    for (id, _) in head.headers.iter() {
        match id {
            HeaderId::Host => host_count += 1,
            HeaderId::ContentLength => has_len = true,
            HeaderId::TransferEncoding => has_te = true,
            _ => {}
        }
    }

    // 1. Host. More than one is ambiguous at any version; HTTP/1.1 additionally
    //    requires at least one (RFC 9112 section 3.2).
    match host_count {
        0 if head.version == Version::Http11 => return Err(FramingError::MissingHost),
        n if n > 1 => return Err(FramingError::MultipleHost),
        _ => {}
    }

    // 2. Conflict. Checked before either field is analyzed.
    if has_len && has_te {
        return Err(FramingError::LengthAndTransferEncoding);
    }

    // 3. Transfer-Encoding. Codings accumulate across repeated fields in wire
    //    order, exactly as if they had been sent as one comma list.
    if has_te {
        // RFC 9112 section 6.1: a server must not reuse a connection after
        // receiving Transfer-Encoding in an HTTP/1.0 request. An HTTP/1.0 sender
        // has no business emitting one at all, and a hop that ignores it and
        // reads the body as unframed while we read it as chunked is the
        // TE-downgrade smuggling vector. Rejecting closes the connection, which
        // covers the "must not reuse" requirement as a side effect.
        if head.version == Version::Http10 {
            return Err(FramingError::TransferEncodingOnHttp10);
        }

        let mut codings: smallvec::SmallVec<[&str; 4]> = smallvec::SmallVec::new();
        for value in head.all(&HeaderId::TransferEncoding) {
            let s = std::str::from_utf8(value).map_err(|_| FramingError::ChunkedNotFinal)?;
            for coding in s.split(',') {
                let coding = coding.trim();
                if !coding.is_empty() {
                    codings.push(coding);
                }
            }
        }

        let chunked_count = codings
            .iter()
            .filter(|c| c.eq_ignore_ascii_case("chunked"))
            .count();

        return match chunked_count {
            // A single chunked coding, and it must be last.
            1 if codings
                .last()
                .is_some_and(|c| c.eq_ignore_ascii_case("chunked")) =>
            {
                if codings.len() == 1 {
                    Ok(BodyKind::Chunked)
                } else {
                    // e.g. `gzip, chunked`: chunked frames the message, but an
                    // inner coding we cannot decode remains. 501 rather than
                    // silently handing the handler compressed bytes.
                    Err(FramingError::UnsupportedTransferEncoding)
                }
            }
            // Present but not final, or present more than once: the message
            // length is undetermined.
            n if n >= 1 => Err(FramingError::ChunkedNotFinal),
            // No chunked coding at all: some coding we do not implement.
            _ => Err(FramingError::UnsupportedTransferEncoding),
        };
    }

    // 4. Content-Length. Every value across every field, and every element of
    //    every comma list, must parse identically (RFC 9112 section 6.3).
    if has_len {
        let mut agreed: Option<u64> = None;
        for value in head.all(&HeaderId::ContentLength) {
            let s = std::str::from_utf8(value).map_err(|_| FramingError::InvalidContentLength)?;
            for element in s.split(',') {
                let n = parse_content_length(element.trim())?;
                match agreed {
                    None => agreed = Some(n),
                    Some(prev) if prev == n => {}
                    Some(_) => return Err(FramingError::DuplicateContentLength),
                }
            }
        }
        let len = agreed.ok_or(FramingError::InvalidContentLength)?;

        // 5. Declared-size limit, before a single body byte is read.
        if len > limits.max_body_bytes {
            return Err(FramingError::BodyTooLarge);
        }
        return Ok(BodyKind::Length(len));
    }

    // Neither field: a request has no body.
    Ok(BodyKind::None)
}

/// Parse one `Content-Length` element as a bare decimal integer.
///
/// Rejects the empty string, signs, whitespace, non-digits, and overflow.
/// `str::parse::<u64>` would accept a leading `+`, which RFC 9112 does not, so
/// digits are checked explicitly.
#[inline]
fn parse_content_length(s: &str) -> Result<u64, FramingError> {
    let bytes = s.as_bytes();
    if bytes.is_empty() || !bytes.iter().all(|b| b.is_ascii_digit()) {
        return Err(FramingError::InvalidContentLength);
    }
    s.parse::<u64>()
        .map_err(|_| FramingError::InvalidContentLength)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{Limits, parse_head};
    use bytes::Bytes;

    /// Build a head from a raw request, asserting it parses.
    fn head(raw: &'static [u8]) -> crate::Head {
        parse_head(&Bytes::from_static(raw), &Limits::default())
            .expect("must parse")
            .expect("must be complete")
            .0
    }

    fn decide_raw(raw: &'static [u8]) -> Result<BodyKind, FramingError> {
        decide(&head(raw), &Limits::default())
    }

    // ---- positive cases ----

    #[test]
    fn no_framing_headers_means_no_body() {
        assert_eq!(
            decide_raw(b"GET / HTTP/1.1\r\nHost: a\r\n\r\n"),
            Ok(BodyKind::None)
        );
    }

    #[test]
    fn content_length_gives_a_fixed_body() {
        assert_eq!(
            decide_raw(b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\n\r\n"),
            Ok(BodyKind::Length(5))
        );
        assert_eq!(
            decide_raw(b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 0\r\n\r\n"),
            Ok(BodyKind::Length(0))
        );
    }

    #[test]
    fn transfer_encoding_chunked_gives_a_chunked_body() {
        assert_eq!(
            decide_raw(b"POST / HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\n\r\n"),
            Ok(BodyKind::Chunked)
        );
        assert_eq!(
            decide_raw(b"POST / HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: CHUNKED\r\n\r\n"),
            Ok(BodyKind::Chunked),
            "transfer codings are case-insensitive"
        );
    }

    /// RFC 9112 section 6.3: duplicate Content-Length fields with identical
    /// values may be treated as one.
    #[test]
    fn identical_duplicate_content_length_is_accepted() {
        assert_eq!(
            decide_raw(
                b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\nContent-Length: 5\r\n\r\n"
            ),
            Ok(BodyKind::Length(5))
        );
        assert_eq!(
            decide_raw(b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 5, 5\r\n\r\n"),
            Ok(BodyKind::Length(5)),
            "a comma list of identical values is the same case"
        );
    }

    #[test]
    fn http_10_needs_no_host() {
        assert_eq!(decide_raw(b"GET / HTTP/1.0\r\n\r\n"), Ok(BodyKind::None));
    }

    // ---- the rejection table from the spec ----

    /// RFC 9112 section 6.1: both present is unresolvable ambiguity.
    #[test]
    fn rejects_content_length_with_transfer_encoding() {
        assert_eq!(
            decide_raw(
                b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n"
            ),
            Err(FramingError::LengthAndTransferEncoding)
        );
        // Order on the wire must not change the outcome.
        assert_eq!(
            decide_raw(
                b"POST / HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\nContent-Length: 5\r\n\r\n"
            ),
            Err(FramingError::LengthAndTransferEncoding)
        );
    }

    #[test]
    fn rejects_conflicting_content_length() {
        assert_eq!(
            decide_raw(
                b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\nContent-Length: 6\r\n\r\n"
            ),
            Err(FramingError::DuplicateContentLength)
        );
        assert_eq!(
            decide_raw(b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 5, 6\r\n\r\n"),
            Err(FramingError::DuplicateContentLength)
        );
    }

    #[test]
    fn rejects_malformed_content_length() {
        for raw in [
            &b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: abc\r\n\r\n"[..],
            &b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: \r\n\r\n"[..],
            &b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: +5\r\n\r\n"[..],
            &b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: -5\r\n\r\n"[..],
            &b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 5x\r\n\r\n"[..],
            &b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 0x5\r\n\r\n"[..],
            &b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 5 5\r\n\r\n"[..],
            // u64 overflow must not wrap.
            &b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 99999999999999999999999\r\n\r\n"[..],
        ] {
            let h = parse_head(&Bytes::copy_from_slice(raw), &Limits::default())
                .unwrap()
                .unwrap()
                .0;
            assert_eq!(
                decide(&h, &Limits::default()),
                Err(FramingError::InvalidContentLength),
                "should have rejected: {}",
                String::from_utf8_lossy(raw)
            );
        }
    }

    /// RFC 9112 section 6.1: chunked must be the final coding, or the message
    /// length is undetermined.
    #[test]
    fn rejects_chunked_not_final() {
        assert_eq!(
            decide_raw(b"POST / HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked, gzip\r\n\r\n"),
            Err(FramingError::ChunkedNotFinal)
        );
        assert_eq!(
            decide_raw(
                b"POST / HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\nTransfer-Encoding: gzip\r\n\r\n"
            ),
            Err(FramingError::ChunkedNotFinal),
            "codings accumulate across repeated fields"
        );
    }

    /// Two chunked codings would mean two framing layers; reject rather than
    /// pick one.
    #[test]
    fn rejects_repeated_chunked() {
        assert_eq!(
            decide_raw(
                b"POST / HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked, chunked\r\n\r\n"
            ),
            Err(FramingError::ChunkedNotFinal)
        );
    }

    #[test]
    fn rejects_unsupported_transfer_coding() {
        assert_eq!(
            decide_raw(b"POST / HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: gzip\r\n\r\n"),
            Err(FramingError::UnsupportedTransferEncoding)
        );
        assert_eq!(
            decide_raw(b"POST / HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: identity\r\n\r\n"),
            Err(FramingError::UnsupportedTransferEncoding)
        );
    }

    #[test]
    fn rejects_missing_or_multiple_host_on_http_11() {
        assert_eq!(
            decide_raw(b"GET / HTTP/1.1\r\n\r\n"),
            Err(FramingError::MissingHost)
        );
        assert_eq!(
            decide_raw(b"GET / HTTP/1.1\r\nHost: a\r\nHost: b\r\n\r\n"),
            Err(FramingError::MultipleHost)
        );
    }

    /// Multiple Host fields are ambiguous regardless of version.
    #[test]
    fn rejects_multiple_host_on_http_10_too() {
        assert_eq!(
            decide_raw(b"GET / HTTP/1.0\r\nHost: a\r\nHost: b\r\n\r\n"),
            Err(FramingError::MultipleHost)
        );
    }

    /// RFC 9112 section 6.1. An HTTP/1.0 sender cannot legitimately use chunked,
    /// so a `Transfer-Encoding` here is a downgrade attempt: a hop that reads the
    /// body as unframed while we read it as chunked disagrees about where the
    /// message ends.
    #[test]
    fn rejects_transfer_encoding_on_http_10() {
        assert_eq!(
            decide_raw(b"POST / HTTP/1.0\r\nTransfer-Encoding: chunked\r\n\r\n"),
            Err(FramingError::TransferEncodingOnHttp10)
        );
        assert_eq!(
            decide_raw(b"POST / HTTP/1.0\r\nHost: a\r\nTransfer-Encoding: chunked\r\n\r\n"),
            Err(FramingError::TransferEncodingOnHttp10)
        );
        // An unsupported coding on HTTP/1.0 is rejected for the version, before
        // the coding is even looked at.
        assert_eq!(
            decide_raw(b"POST / HTTP/1.0\r\nTransfer-Encoding: gzip\r\n\r\n"),
            Err(FramingError::TransferEncodingOnHttp10)
        );
    }

    /// The conflict rule still wins: a version violation must not mask the
    /// ambiguity that both framing fields together create.
    #[test]
    fn conflict_precedes_the_http_10_transfer_encoding_rule() {
        assert_eq!(
            decide_raw(
                b"POST / HTTP/1.0\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n"
            ),
            Err(FramingError::LengthAndTransferEncoding)
        );
    }

    #[test]
    fn rejects_oversized_declared_body() {
        let limits = Limits {
            max_body_bytes: 4,
            ..Default::default()
        };
        let h = head(b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\n\r\n");
        assert_eq!(decide(&h, &limits), Err(FramingError::BodyTooLarge));
    }

    // ---- ordering: the conflict check must win ----

    /// A request carrying both a *valid* Content-Length and a *valid*
    /// Transfer-Encoding must be rejected as a conflict, never resolved in favor
    /// of one. This ordering is the smuggling defense.
    #[test]
    fn conflict_check_precedes_individual_analysis() {
        assert_eq!(
            decide_raw(
                b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n"
            ),
            Err(FramingError::LengthAndTransferEncoding),
            "must not report ChunkedNotFinal or a Length body"
        );
    }

    /// An invalid Content-Length alongside a Transfer-Encoding is still first
    /// and foremost a conflict.
    #[test]
    fn conflict_reported_even_when_content_length_is_garbage() {
        assert_eq!(
            decide_raw(
                b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: abc\r\nTransfer-Encoding: chunked\r\n\r\n"
            ),
            Err(FramingError::LengthAndTransferEncoding)
        );
    }

    #[test]
    fn status_codes_map_correctly() {
        assert_eq!(FramingError::LengthAndTransferEncoding.status(), 400);
        assert_eq!(FramingError::DuplicateContentLength.status(), 400);
        assert_eq!(FramingError::InvalidContentLength.status(), 400);
        assert_eq!(FramingError::ChunkedNotFinal.status(), 400);
        assert_eq!(FramingError::MissingHost.status(), 400);
        assert_eq!(FramingError::MultipleHost.status(), 400);
        assert_eq!(FramingError::UnsupportedTransferEncoding.status(), 501);
        assert_eq!(FramingError::TransferEncodingOnHttp10.status(), 400);
        assert_eq!(FramingError::BodyTooLarge.status(), 413);
    }
}