HTTP/1.1 codec

http-codec is Cruft's HTTP/1.1 codec, the layer that turns bytes on a socket into a request or response and back. It rejects the framing ambiguities behind request smuggling (conflicting Content-Length and Transfer-Encoding, duplicate lengths) and CR/LF header injection, and enforces fixed size and count caps.

A request arrives as bytes on a socket, and something turns those bytes into a method, a path, headers, and a body. In Node that something is llhttp (the C parser behind http), the same layer hyper-shaped Rust stacks provide. Two decades of adversarial traffic have already been thrown at it. The interesting question is what happens when a load balancer, a CDN, and an origin all parse the same bytes: do they agree on where one request ends and the next begins? Disagreement there is request smuggling, and it does not need a memory bug to happen. It needs two parsers that read framing slightly differently. That is the lens for this crate.

Alpha. http-codec (Cruft 0.0.10) is an HTTP/1.1 codec standing in for httparse / hyper. It has not been externally audited and has near-zero interop testing against real proxies or spec vectors. The framing and injection defenses below are exercised by the crate's own tests; they are not yet shown to agree with a mainstream parser on inputs nobody has imagined. Do not put it in front of adversarial internet traffic in production.

Scope: the codec layer

hyper is a lot of things at once: a parser, a connection state machine, a pool, HTTP/2, TLS glue. http-codec is only the innermost of those. Its whole surface is bytes-to-message and message-to-bytes:

  • One-shot parsers: parse_request(bytes) and parse_response(bytes) return a ParsedRequest / ParsedResponse or a CodecError.
  • A streaming decoder: ResponseDecoder with push(bytes), close(), head(), framing(), is_complete(), read_body() for reading a response as it arrives off the wire.
  • Serializers: serialize_request / serialize_response (and try_* variants that return a Result instead of panicking).
  • Chunked transfer coding: chunked_encode, chunked_decode, and body_framing to decide between content-length, chunked, EOF-delimited, or bodiless framing.
  • message_consumed_len(bytes) to report how many bytes one message occupied, so a caller can find the next message in a pipeline.

The connection lifecycle, the pool, keep-alive policy, HTTP/2: not here. Those live in the host. So the right comparison is to hyper's codec layer.

Body framing is a defense against a specific attack

Every HTTP parser answers "how long is the body?" The smuggling question is what it does when the message answers that question twice, in conflicting ways. Three classic desyncs:

  1. Content-Length AND Transfer-Encoding: chunked both present. RFC says strip CL and use chunked; a smuggler picks the framing your two hops disagree on.
  2. Two Content-Length headers with different values.
  3. Transfer-Encoding matched by substring, so Transfer-Encoding: xchunked or chunked, gzip sneaks past a naive .contains("chunked").

An early version of http-codec had none of these defenses: it accepted CL+TE together (TE won), took first-wins on duplicate Content-Length, and matched Transfer-Encoding by substring.

That is closed now. The codec rejects a message carrying both Content-Length and Transfer-Encoding, rejects differing duplicate Content-Length, and tokenizes Transfer-Encoding requiring chunked to be the exact final token (unknown or non-final encodings are rejected). This is enforced in body_framing and covered by tests that drive each desync against both the one-shot parser and the streaming ResponseDecoder. It defends against the framing desync; it does not establish interop agreement with any specific proxy, since no differential harness against llhttp or nginx runs in-tree yet (see Limitations).

Header validation stops CRLF injection

If a header value can contain a bare \r\n, an attacker who controls part of a header (a redirect target, a reflected value) can inject new headers or a whole second response. Response splitting. The dual on the request side lets a \r\n in a value forge request lines. An early version validated neither CR, LF, nor NUL in header names or values, on decode or encode.

That is closed too. Header names are constrained to the token charset, and header values reject CR / LF / NUL, on both parse and serialize, so a terminator cannot be smuggled through a value in either direction.

The related parser-differential leniencies are also gone: whitespace before the colon (Foo : bar) is now rejected, the HTTP version token is validated rather than accepted by a starts_with("HTTP/") check, over-Content-Length bodies are rejected rather than silently truncated, and chunk sizes are parsed strictly (no leading +, no surrounding whitespace). The codec also returns a consumed-byte count (message_consumed_len) so the consumer can frame pipelined messages without running its own scan.

Size and count caps

A chunked body with no cap on accumulated size is a memory bomb. Headers with no count or length cap are the same. An early version had no size or count limits, an unenforced advertised maxHeaderSize, and an O(n^2) header-end scan.

The crate now ships explicit caps as public constants: MAX_HEADER_SECTION_BYTES (16 KiB), MAX_HEADER_LINE_BYTES (8 KiB), MAX_HEADER_COUNT (100), MAX_DECODED_BODY_BYTES (16 MiB), and MAX_CHUNK_COUNT (100,000), enforced in both the one-shot and streaming chunked paths and covered by tests that drive each limit. These are fixed ceilings, not yet a configurable policy surface, which matters if an app legitimately needs a 32 MiB upload.

The dual-parser problem in the consumer

The subtlest problem was not in the codec at all. The HTTP server consumer once ran a second request parser to decide when a request was complete: it re-scanned the buffer with .lines() (which treats a bare \n as a terminator) and its own first-content-length rescan, then handed the same bytes to the codec (which splits on CRLF). Two parsers disagreeing on line termination over one buffer is the dual-parser differential that produces smuggling regardless of how good either parser is.

That disagreeing second parser has been removed, so the codec is now the single authority on framing; the consumer uses the message_consumed_len seam instead of re-scanning. The property that matters is agreement among every parser touching the bytes, which one parser cannot supply on its own however correct it is.

Limitations

  • Tested against its own red corpus, not against the world. The verifier covers CL+TE, duplicate CL, TE substring and non-final token, header injection, strict chunk-size parsing, and streaming decoder smuggling rejection. But a fuzz target over parse_request / chunked_decode / ResponseDecoder, and a differential harness against llhttp, are still follow-on work. So "agrees with nginx/llhttp on ambiguous inputs" does not hold yet. The tests cover the cases imagined so far; they do not cover the cases nobody has imagined yet, and that gap is exactly where smuggling historically lives.
  • HTTP/1.1 only. No HTTP/2, no HTTP/3. hyper gives you those with multiplexing and its own framing hardening. If you need them, this crate is not the comparison.
  • Fixed limits, not a policy engine. The caps above are compile-time constants. hyper exposes configurable limits; here you get one opinion.
  • Request-side transport hardening lives elsewhere and is separately unproven. The codec sanitizes on serialize, but the fetch/client seam has its own open questions this crate does not close: WHATWG forbidden-header enforcement, Host override, JS-set Content-Length/Transfer-Encoding desync, redirect credential stripping, and SSRF guards are live surfaces that remain unverified. This page does not clear the transport.
  • Maturity baseline. httparse / hyper / llhttp have absorbed years of CVEs and fuzzing. http-codec has absorbed one hardening pass and its red tests. The specific smuggling and injection classes above are closed and enforced; the population of defects a decade of hostile traffic would find is not. The CL+TE / duplicate-CL / TE-substring smuggling, CR/LF/NUL header injection, the disagreeing dual parser, the parser leniencies, and the missing size/count caps are all closed; fuzzing, llhttp differential parity, HTTP/2, and the transport-side header surface remain open. Trust it exactly as far as its tests reach and no further.