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)
- Search for CRLF CRLF from 3 bytes before the last scan's end. Not found: need more bytes, or
TooLargepast the head limit. - Split into CRLF lines (a bare LF is an error). Start line:
method SP target SP version, orversion SP status [SP reason]. - 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.
- 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, 4is allowed), no overflow. Neither: request → none, response → until close. - Length n: take n bytes. Until close: read to EOF and treat as possibly truncated.
- Chunked: hex size (≤ 16 digits), optional
;extension(ignored, syntax-checked), data, CRLF; size 0, trailer fields, blank line. - 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).
- 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);
httparse1.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 —
httparsehas 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.
- SIMD/SWAR delimiter and token scan —
- 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: zchunkedmake 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 taking100 Continuefor the final response. - Re-scanning from byte 0 on each read, or no head deadline (slowloris).
Variants and alternatives
101 the connection speaks another protocol (WebSockets); stop parsing HTTP.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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| httparse | Rust | seanmonstar/httparse | cargo add httparse | — | — | — | — | push parser, no allocation; SIMD; no_std |
| hyper | Rust | hyperium/hyper | cargo add hyper --features http1,server | — | — | — | — | full client/server; HTTP/1 via httparse |
| llhttp | C | nodejs/llhttp | brew install llhttp | — | — | — | — | generated state machine, callbacks; rejects TE with CL by default |
| picohttpparser | C | h2o/picohttpparser | copy 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