Home › Networking › HTTP & RPC
algorithm low

HTTP/1.1 message parsing

Problem A program reads HTTP/1.1 from a TCP connection (server, proxy or crawler); bytes arrive in arbitrary pieces, and it must find where each message ends in exactly the way every other hop does.

Parse HTTP/1.1 requests and responses incrementally from a byte stream: RFC 9112 body-length rules, chunked decoding and the strictness that stops request smuggling.

O(n) in bytes, each byte examined about once; O(1) extra state between reads
httpparsingchunked-encodingrequest-smugglingkeep-alivepipelining
Overview

HTTP/1.1 is text with three ways to say where the body ends, and every hop must pick the same one. The parser keeps the buffer, finds the blank line, validates the headers strictly, then decides the body length in the fixed order of RFC 9112 §6.3. If the answer is ambiguous it refuses: two parsers disagreeing is how requests get smuggled.

Key facts

Use when

  • Servers and API gateways, proxies and load balancers, crawlers and feed harvesters reading responses, embedded devices with a small HTTP endpoint, scanners, WAFs and test harnesses that must see what a peer sees.

Avoid when

  • HTTP/2 or HTTP/3 runs end to end (HTTP/2): binary framing has none of these ambiguities.
  • A maintained parser fits: hand-rolling is for learning or tiny targets.

Deep dive

How it works

In: the bytes read so far from a connection, and whether they are a request or a response (to a HEAD request or not) · Out: need more bytes, or the head length, the body framing and the header fields; then the decoded body · State: offset already scanned; for chunked bodies the consumed position, bytes left in the chunk and a trailer flag · Cost: O(n), about 0.8 µs for a 520-byte request in the code below (measured)

  1. Search for CRLF CRLF from 3 bytes before the last scan's end. Not found: need more bytes, or TooLarge past the head limit.
  2. Split into CRLF lines (a bare LF is an error). Start line: method SP target SP version, or version SP status [SP reason].
  3. Each field line: no leading SP/HT (obs-fold, RFC 9112 §5.2), token name, no space before the colon (§5.1), no control bytes, OWS trimmed; count against the limit.
  4. Body length, §6.3 order: response to HEAD or 1xx/204/304 → none. Transfer-Encoding with Content-Length → reject. Last coding chunked → chunked. Content-Length: digits only, all copies equal (4, 4 is allowed), no overflow. Neither: request → none, response → until close.
  5. Length n: take n bytes. Until close: read to EOF and treat as possibly truncated.
  6. Chunked: hex size (≤ 16 digits), optional ;extension (ignored, syntax-checked), data, CRLF; size 0, trailer fields, blank line.
  7. Advance by head plus body; the rest of the buffer is the next message (keep-alive, pipelining; responses return in request order, §9.3.2).
  8. Any error: 400 and close (server) or drop the connection (client). Never resynchronise.

Not in the code: CONNECT tunnels, skipping 1xx responses, Host rules.

Code

const MAX_HEAD: usize = 8192; // also caps chunk-size and trailer lines
const MAX_FIELDS: usize = 100;
const MAX_BODY: usize = 1 << 20;
#[derive(Debug, PartialEq, Clone, Copy)] pub enum Body { None, Length(u64), Chunked, UntilClose }
#[derive(Debug, PartialEq)] pub enum Bad { Syntax, Fold, TooLarge, TooMany, Smuggling, Length, Chunk }
#[derive(Clone, Copy)] pub enum Kind { Request, Response { to_head: bool } }
pub type Fields<'a> = Vec<(&'a [u8], &'a [u8])>;
fn ctl(b: &u8) -> bool { (*b < 0x20 && *b != b'\t') || *b == 0x7f }
fn tchar(b: &u8) -> bool { b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(b) }
/// token ":" OWS value OWS; rejects obs-fold, space before ':', control bytes.
fn field(l: &[u8]) -> Result<(&[u8], &[u8]), Bad> {
    if matches!(l.first(), Some(b' ' | b'\t')) { return Err(Bad::Fold); }
    let c = l.iter().position(|&b| b == b':').ok_or(Bad::Syntax)?;
    if c == 0 || !l[..c].iter().all(tchar) || l[c..].iter().any(ctl) { return Err(Bad::Syntax); }
    Ok((&l[..c], l[c + 1..].trim_ascii())) // only SP/HT are left to trim: controls were rejected
}
/// Body length in RFC 9112 §6.3 order; Transfer-Encoding plus Content-Length is rejected.
fn framing(resp: Option<(bool, u16)>, f: &Fields) -> Result<Body, Bad> {
    if let Some((to_head, st)) = resp { if to_head || st / 100 == 1 || st == 204 || st == 304 { return Ok(Body::None); } }
    let get = |n: &str| f.iter().filter(|(k, _)| k.eq_ignore_ascii_case(n.as_bytes())).map(|f| f.1).collect::<Vec<_>>();
    let (te, cl) = (get("transfer-encoding"), get("content-length"));
    if let Some(last) = te.last() {
        if !cl.is_empty() { return Err(Bad::Smuggling); }
        return match last.rsplit(|&b| b == b',').next().unwrap().trim_ascii() {
            c if c.eq_ignore_ascii_case(b"chunked") => Ok(Body::Chunked),
            _ if resp.is_some() => Ok(Body::UntilClose),
            _ => Err(Bad::Length), // request whose last coding is not chunked: unframeable
        };
    }
    let mut n: Option<u64> = None;
    for item in cl.iter().flat_map(|v| v.split(|&b| b == b',')).map(|i| i.trim_ascii()) {
        let x = item.iter().try_fold(0u64, |a, &d| a.checked_mul(10)?.checked_add((d - b'0') as u64));
        let x = x.filter(|_| !item.is_empty() && item.iter().all(u8::is_ascii_digit)).ok_or(Bad::Length)?;
        if n.replace(x).is_some_and(|p| p != x) { return Err(Bad::Length); } // differing duplicates
    }
    Ok(match n { Some(x) => Body::Length(x), None if resp.is_none() => Body::None, None => Body::UntilClose })
}
/// Ok(None) = need more bytes; call again with the grown buffer. Ok(Some((body offset, body kind, fields))).
pub fn parse_head<'a>(kind: Kind, buf: &'a [u8], scanned: &mut usize) -> Result<Option<(usize, Body, Fields<'a>)>, Bad> {
    let from = scanned.saturating_sub(3);
    let Some(i) = buf[from..].windows(4).position(|w| w == b"\r\n\r\n") else {
        *scanned = buf.len();
        return if buf.len() > MAX_HEAD { Err(Bad::TooLarge) } else { Ok(None) };
    };
    let end = from + i + 4;
    if end > MAX_HEAD { return Err(Bad::TooLarge); }
    let mut lines = buf[..end - 2].split_inclusive(|&b| b == b'\n').map(|l| l.strip_suffix(b"\r\n").ok_or(Bad::Syntax));
    let start = lines.next().unwrap()?;
    let mut p = start.splitn(3, |&b| b == b' ');
    let (a, b, c) = (p.next().unwrap(), p.next().unwrap_or(b""), p.next().unwrap_or(b""));
    let ver = |v: &[u8]| v == b"HTTP/1.1" || v == b"HTTP/1.0";
    let resp = match kind {
        _ if start.iter().any(ctl) => return Err(Bad::Syntax),
        Kind::Request if ver(c) && !b.is_empty() && !a.is_empty() && a.iter().all(tchar) => None,
        Kind::Response { to_head } if ver(a) && b.len() == 3 && b.iter().all(u8::is_ascii_digit) => // reason phrase may hold spaces
            Some((to_head, b.iter().fold(0, |s, &d| s * 10 + (d - b'0') as u16))),
        _ => return Err(Bad::Syntax),
    };
    let mut fields = Vec::new();
    for l in lines { fields.push(field(l?)?); if fields.len() > MAX_FIELDS { return Err(Bad::TooMany); } }
    Ok(Some((end, framing(resp, &fields)?, fields)))
}
/// Resumable chunked decoder: feed the same growing buffer; `pos` = bytes consumed. Trailers are validated, then dropped.
#[derive(Default)] pub struct Chunked { pub pos: usize, left: usize, trailer: bool, pub body: Vec<u8> }
impl Chunked {
    /// Ok(true) = complete. `left` counts chunk data plus its CRLF.
    pub fn feed(&mut self, buf: &[u8]) -> Result<bool, Bad> {
        loop {
            let rest = &buf[self.pos..];
            if self.left > 0 {
                let k = self.left.min(rest.len());
                self.body.extend_from_slice(&rest[..k]);
                (self.pos, self.left) = (self.pos + k, self.left - k);
                if self.left > 0 { return Ok(false); }
                if !self.body.ends_with(b"\r\n") { return Err(Bad::Chunk); }
                self.body.truncate(self.body.len() - 2);
                continue;
            }
            let Some(i) = rest.windows(2).position(|w| w == b"\r\n") else { return if rest.len() > MAX_HEAD { Err(Bad::TooLarge) } else { Ok(false) } };
            let line = &rest[..i];
            self.pos += i + 2;
            if self.trailer { if line.is_empty() { return Ok(true); } field(line)?; continue; }
            let d = line.iter().take_while(|b| b.is_ascii_hexdigit()).count();
            let ext = line[d..].trim_ascii(); // ";name=value" parts are ignored, but must start with ';'
            if d == 0 || d > 16 || !(ext.is_empty() || ext[0] == b';') || ext.iter().any(ctl) { return Err(Bad::Chunk); }
            let n = usize::from_str_radix(std::str::from_utf8(&line[..d]).unwrap(), 16).unwrap();
            if n > MAX_BODY - self.body.len() { return Err(Bad::TooLarge); }
            if n == 0 { self.trailer = true } else { self.left = n + 2 }
        }
    }
}
#[cfg(test)]
mod tests { use super::*;
    fn head(k: Kind, raw: &[u8]) -> Result<Option<(usize, Body, usize)>, Bad> { // growing buffer, one byte at a time
        let (mut s, mut got) = (0, None);
        for i in 0..=raw.len() { if let Some(h) = parse_head(k, &raw[..i], &mut s)? { got = Some((h.0, h.1, h.2.len())); break; } }
        assert_eq!(got, parse_head(k, raw, &mut 0)?.map(|h| (h.0, h.1, h.2.len()))); // same as whole
        Ok(got)
    }
    #[test] fn heads() {
        let (q, r) = (Kind::Request, Kind::Response { to_head: true });
        let cases: [(Kind, &[u8], _); 4] = [
            (q, b"POST /a HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhelloGET /", Ok(Some((48, Body::Length(5), 2)))),
            (q, b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\nContent-Length: 4\r\n\r\n", Err(Bad::Smuggling)),
            (q, b"POST / HTTP/1.1\r\nContent-Length: 4\r\nContent-Length: 5\r\n\r\n", Err(Bad::Length)),
            (r, b"HTTP/1.1 200 OK\r\nContent-Length: 9\r\n\r\n", Ok(Some((38, Body::None, 1)))),
        ];
        for (k, raw, want) in cases { assert_eq!(head(k, raw), want); }
        let many = format!("GET / HTTP/1.1\r\n{}\r\n", "H: v\r\n".repeat(MAX_FIELDS + 1));
        assert_eq!(head(q, many.as_bytes()), Err(Bad::TooMany));
    }
    #[test] fn chunked() {
        let wire = b"4;sig=ab\r\nWiki\r\n5\r\npedia\r\n0\r\nX-Sum: 7\r\n\r\nNEXT";
        let (mut whole, mut bytewise) = (Chunked::default(), Chunked::default());
        assert!(whole.feed(wire).unwrap() && (&whole.body[..], whole.pos) == (&b"Wikipedia"[..], wire.len() - 4));
        assert!((1..=wire.len()).any(|i| bytewise.feed(&wire[..i]).unwrap()) && bytewise.body == whole.body);
        assert_eq!(Chunked::default().feed(b"zz\r\n"), Err(Bad::Chunk));
    }
}

Complexity and constants

  • Time O(n); the resume offset keeps repeated calls linear: a 520-byte request fed byte by byte costs 1.9 µs in total, whole 0.8 µs (measured).
  • Memory: 32 bytes per field (two slices); the body is copied once into Chunked::body.
  • Limits are policy: RFC 9112 §3 recommends supporting request lines of at least 8000 octets; the code uses 8 KiB for the head and 100 fields.
  • Cost drivers: per-byte delimiter and token checks, allocation per request, body copies.

Efficiency

  • Cost: Apple M4 Pro, rustc 1.99.1 release, measured by me: 789 ns for a 520-byte request with 9 headers (0.66 GB/s); httparse 1.10.1 on the same bytes 139 ns (3.7 GB/s). Chunked decoding of a 64 KiB body in 4 KiB chunks: 2.9 µs (memcpy-bound). In a real server syscalls and TLS usually dominate.
  • Levers:
    • SIMD/SWAR delimiter and token scan — httparse has AVX2, SSE4.2, NEON and SWAR paths; 5.7× faster than the code above here, the gap also holds its zero allocation (not separated) — every request.
    • Resume offset — O(n) instead of O(n²) on trickled input — slow clients, small TLS records.
    • Caller-provided field array, zero-copy slices — no allocation per request (the code above makes three small vectors) — high request rates.
    • Large reads holding several requests — fewer syscalls (epoll) — keep-alive traffic.
    • Bodies read into place or spliced (zero-copy I/O) — no per-byte copy — large uploads, proxies.
  • Naive → standard → state of the art:
    • Lines into strings and a map, re-parse each read · allocation per field, quadratic on trickled input (not measured).
    • Scalar parser with resume offset (above) · 789 ns per 520 B, linear.
    • SIMD scan, caller-provided fields (httparse, picohttpparser) · 139 ns per 520 B; llhttp's README reports 1777 MB/s on its own benchmark.

Implementation notes

  • Parse bytes, never str (RFC 9112 §2.2: Unicode decoding creates vulnerabilities).
  • Compact the buffer after each message, not each read (length-prefix framing has the pattern); check body sizes before allocating and chunk digits before converting (§7.1).
  • A crawler also limits redirects, response size and total time; parser limits are the floor.

Pitfalls

  • Accepting Transfer-Encoding with Content-Length: front-end and back-end pick different ones and the extra bytes become the next user's request. Kettle 2019 names the orders CL.TE (front-end Content-Length, back-end Transfer-Encoding) and TE.CL; obfuscations like Transfer-Encoding: zchunked make one side ignore the header.
  • Lenient whitespace (space before the colon, obs-fold, bare CR or LF): parsers disagree there. §5.1 requires 400 for the space; §5.2 allows rejecting or replacing folds.
  • Treating a close-delimited response as complete: truncation looks like the end (§6.3).
  • Waiting for a body after HEAD, 204 or 304 (they may still say Content-Length: 9), or taking 100 Continue for the final response.
  • Re-scanning from byte 0 on each read, or no head deadline (slowloris).

Variants and alternatives

HTTP/2 (HTTP/2), HTTP/3length-prefixed binary frames; no ambiguity of this kind.
Lenient modesllhttp has flags for lenient headers and TE with CL, off by default, documented as a smuggling risk.
Callback vs push parsersllhttp calls back per field; httparse and picohttpparser fill a caller array and return "partial".
Upgradeafter 101 the connection speaks another protocol (WebSockets); stop parsing HTTP.
Streaming bodieslong polling rides chunked or close-delimited bodies; range requests sit on the same framing.

In production

  • llhttp is the parser in Node.js; hyper (Rust) uses httparse; H2O uses picohttpparser (its README says so). httparse works without std, which suits embedded devices.
  • Strictness matters most in proxies (reverse proxy), which re-frame every message; a forwarded message must lose its Content-Length when Transfer-Encoding is processed (§6.3).

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
httparseRustseanmonstar/httparsecargo add httparse————push parser, no allocation; SIMD; no_std
hyperRusthyperium/hypercargo add hyper --features http1,server————full client/server; HTTP/1 via httparse
llhttpCnodejs/llhttpbrew install llhttp————generated state machine, callbacks; rejects TE with CL by default
picohttpparserCh2o/picohttpparsercopy picohttpparser.c and .h into your build————stateless; phr_decode_chunked; SSE4.2 path

Sources

  • Fielding, Nottingham, Reschke (eds.), RFC 9112: HTTP/1.1, 2022. rfc (§2.2 parsing, §5 fields, §6.3 body length, §7.1 chunked, §9.3.2 pipelining, §11.2 smuggling)
  • Fielding, Nottingham, Reschke (eds.), RFC 9110: HTTP Semantics, 2022. rfc (§5 fields, §5.6.2 tokens)
  • Kettle, HTTP Desync Attacks: Request Smuggling Reborn, PortSwigger Research, Black Hat USA 2019. article · whitepaper
  • Linhart, Klein, Heled, Orrin, HTTP Request Smuggling, 2005 (cited by RFC 9112). pdf

Used by

Systems (10):

Alternatives and related