HTTPPROTOCOL-SPECIFICGENERALFRAMEWORK-SPECIFIC

Parsing HTTP

Turning a byte stream with no message boundaries into a request — and why the parser is a security component, not a formality.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

How does a stream of bytes become a request object, and what decisions does the parser make on my behalf?

The requirement

Accept HTTP from arbitrary clients — browsers, mobile apps, curl, other services, and everyone else who can reach the port — and hand the application something well-defined.

The obvious build

HTTP is text. Split on newlines, take the first line as method and path, the rest as Key: Value headers, and everything after the blank line is the body.

Why it breaks

TCP delivers a stream, not messages. One read can return half a header line, or two entire requests, and a parser written against whole-message assumptions produces intermittent 400s under load and none in testing.

How it breaks in production
  • TCP delivers a stream, not messages. One read can return half a header line, or two entire requests, and a parser written against whole-message assumptions produces intermittent 400s under load and none in testing.
  • The body has no delimiter. Where it ends is decided by Content-Length or by chunked framing, and getting that wrong means your next "request" starts in the middle of the previous body.
  • Headers are case-insensitive and may repeat. Code that reads headers['Content-Type'] works with one client and returns undefined with another (Request and Response Objects).
  • Unbounded parsing is unbounded memory: a client that sends a very long request line or ten thousand headers costs you resources before you know who it is.
  • When two parsers in the chain disagree about framing — your proxy and your server — a request can be split so that one sees one request and the other sees two. That is request smuggling, and it is a parsing bug with an authorization consequence.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • An HTTP/1.1 message is a request line, then header lines, then CRLF CRLF, then an optional body. The parser is a state machine over a byte stream; it is fed whatever arrives and must be able to stop mid-token and resume.
  • Body framing is decided by rules, in order: certain statuses and methods have no body; Transfer-Encoding: chunked means the body is a sequence of length-prefixed chunks terminated by a zero-length chunk; otherwise Content-Length gives the exact byte count; otherwise there is no body.
  • If both Content-Length and Transfer-Encoding: chunked are present, the specification says chunked wins and the Content-Length must be ignored or the message rejected. Disagreement about this rule between two hops is the classic smuggling primitive.
  • Header names are case-insensitive, so servers normalise them — typically to lower case. Values are byte strings; a header may legitimately appear multiple times, and how those are combined is a per-header rule rather than a global one.
  • The URL is parsed separately: path, query string, percent-decoding, and normalisation of . and .. segments. Whether decoding happens before or after routing and authorization checks decides whether %2e%2e%2f is a traversal (Transport Validation).
  • The parser enforces the limits that exist before identity does: maximum request-line length, maximum header size, maximum header count, and a timeout on how long the headers may take to arrive.
  • HTTP/2 replaces all of the above with binary frames and HPACK-compressed headers, so "parsing" becomes framing plus decompression — and introduces its own resource concerns, since a small compressed header block can expand into a large one.

Where does the body end?

PROTOCOL-SPECIFICHTTP/1.1 only: HTTP/2 frames carry explicit lengths in the binary framing layer, so this specific ambiguity does not arise — which is why smuggling research concentrates on hops that downgrade HTTP/2 to HTTP/1.1 between the edge and the origin.

Everything difficult about HTTP/1.1 parsing reduces to this question. Headers end at a blank line, which is easy. The body has no terminator of its own — its length is described by headers that the client controls.

Read the two framings side by side. Chunked exists precisely because the sender does not always know the length in advance, which is what makes streaming responses possible at all (Request Bodies and Streaming).

Two ways to frame the same body
1POST /orders HTTP/1.1
2Host: api.example.com
3Content-Type: application/json
4Content-Length: 27
5
6{"sku":"ABC","quantity":2}
7
8--- or, when the length is not known up front ---
9
10POST /orders HTTP/1.1
11Host: api.example.com
12Content-Type: application/json
13Transfer-Encoding: chunked
14
151a
16{"sku":"ABC","quantity":2}
170
18
19
20--- and the one that must be REJECTED, not interpreted ---
21
22POST /orders HTTP/1.1
23Host: api.example.com
24Content-Length: 6
25Transfer-Encoding: chunked
26
270
28
29GET /admin HTTP/1.1
30X-Ignore: x

In the third message, a hop that honours Content-Length sees one request and stops after six bytes; a hop that honours Transfer-Encoding sees a complete zero-length chunked body and treats the rest as a second, unauthenticated request on the same connection. Neither implementation crashed. That is request smuggling.

The limits that run before you know who is calling

Authentication runs in middleware. Middleware runs after parsing. So every limit in the parser is enforced against completely unidentified traffic, and every limit *not* in the parser is a resource an anonymous client can consume.

These are cheap to set, and they are the difference between a bad client costing you a 400 and a bad client costing you memory.

  • Maximum request line — caps absurd URLs and query strings before they are stored anywhere.
  • Maximum header block size — the common default is a few kilobytes; large bearer tokens and cookie sets push against it legitimately.
  • Maximum header count — stops thousands of tiny headers from costing memory and per-header work.
  • Header-read timeout — the answer to a client that opens a connection and dribbles bytes forever.
  • Maximum body size — enforced *while reading*, not after buffering (Request Bodies and Streaming).
  • Reject ambiguous framing — conflicting Content-Length, or Content-Length together with Transfer-Encoding.
Reading a header
Indexing a raw object
// Works with one client, silently undefined with another.
const type = req.headers['Content-Type']
if (type === 'application/json') { /* ... */ }

// And this trusts a header the client can set:
const ip = req.headers['x-forwarded-for']
Normalised, and sourced deliberately
// Node lower-cases header names; use the lower-case key,
// or the framework accessor that normalises for you.
const type = req.headers['content-type'] ?? ''
if (type.split(';')[0].trim() === 'application/json') { /* ... */ }

// X-Forwarded-For is a list the client can prepend to.
// Only the entry appended by YOUR trusted proxy is meaningful,
// and only if the framework is configured to know how many
// trusted hops are in front of it.
const ip = trustedClientIp(req)

Header names are case-insensitive on the wire and normalised by the parser, so the capitalised key is a coin flip. And a header that an intermediary is expected to set is still attacker-controllable unless something strips or overwrites it — which is a configuration fact about your proxy, not a property of the header name.

Parsing is a state machine over a stream

The reason you should not write this yourself is easier to feel than to argue. A parser must handle a read that ends in the middle of a header name, a body that arrives in forty pieces, and a client that stops sending halfway — while never allocating more than its configured limits.

The sketch below is not a usable parser. It is the shape of the problem: a resumable state machine with byte budgets, which is why the mature ones are C libraries with large test corpora.

state: START -> REQUEST_LINE -> HEADERS -> (BODY_LENGTH | BODY_CHUNKED | NO_BODY) -> DONE

feed(bytes):
  buffer += bytes
  if len(buffer) > limit_for(state): reject(431 or 400)   # budget per state

  while progress_possible:
    START/REQUEST_LINE: need a full CRLF-terminated line, else return and wait
    HEADERS:            each CRLF line is a header; blank line ends the block
                        -> decide framing here, ONCE, and reject if ambiguous
    BODY_LENGTH:        consume exactly Content-Length bytes
    BODY_CHUNKED:       read size line (hex), then that many bytes, then CRLF;
                        a 0-size chunk ends the body
    DONE:               hand the request up; anything LEFT in the buffer is
                        the start of the NEXT request on this connection

timeout in START/REQUEST_LINE/HEADERS -> close, do not wait forever
error in any state -> close the connection; do not try to resynchronise

How to build it

Most important first.

  • Use your runtime's HTTP parser. Correct, hostile-input-safe HTTP parsing is a large body of work, and a hand-rolled parser reachable from the internet is a liability rather than an optimisation.
  • Set explicit limits rather than inheriting defaults: header size, header count, request-line length and the header-read timeout. Know the numbers, because they are the only defence that runs before authentication.
  • Make the proxy and the origin agree. Same limits, same body-framing behaviour, same treatment of ambiguous requests — divergence is the smuggling surface (The Request Lifecycle).
  • Reject ambiguity rather than guessing. A request with both Content-Length and Transfer-Encoding, or with two conflicting Content-Length headers, should be a 400, not an interpretation.
  • Read headers case-insensitively through the framework's accessor, never by indexing a raw object with a capitalised key.
  • Treat the parsed result as untrusted structured data: parsing proves the shape, not the meaning (The Three Validations).

What can go wrong

Failure modes
  • A 400 with no application log line, because the request died before any middleware ran. The client sees a failure you cannot find.
  • Header limit exceeded after a cookie or a bearer token grew — often after an unrelated change, and often only for the subset of users with the largest tokens.
  • A proxy that normalises or rewrites headers differently from your server, so a header your code trusts was actually set by the client.
  • Percent-decoding applied after a routing or authorization check, so the check saw a different string from the one later used to open a file or build a query — the exploit technique belongs to Security Engineering, the ordering mistake belongs here.
  • A limit set so low it rejects legitimate traffic — large Authorization headers and long query strings from analytics tools are common casualties.
What can race
  • On a reused connection, a parse error mid-stream leaves the connection in an unknown state: the safe response is to close it, because resuming means guessing where the next request starts (Keep-Alive and Connection Reuse).
  • Two hops parsing concurrently on the same connection is the smuggling case: the front end has already forwarded what it thinks is one request while the back end is still reading what it thinks is two.
Security
  • The parser is the outermost trust boundary in the process. Everything it accepts is handed to code that assumes it is well-formed.
  • Request smuggling: when two hops disagree about where a request ends, an attacker can prefix a request onto another user's connection, bypassing front-end authorization and poisoning responses. The fix is agreement and strictness, not a WAF rule.
  • Header injection: if any value you place into a response header can contain CRLF, an attacker can split the response and inject headers or a body. Never build response headers with raw client input.
  • Resource limits before identity: without a header-read timeout and size caps, an unauthenticated client can occupy connection slots and memory at almost no cost to itself.
  • Duplicate headers are a smuggled-decision surface: if your code reads the first Host and a proxy reads the last, they are routing on different values.
Misreads
  • "HTTP is text, so parsing is easy." The text is the easy part. The framing rules, the ambiguity handling and the resource limits are the parser.
  • "The framework validated my request." The framework parsed it. Well-formed is not valid, and valid is not authorized (The Three Validations).
  • "Smuggling is a proxy bug." It is a disagreement between two conforming-enough parsers. Both ends must be strict for it not to exist.
  • "Headers are a dictionary." They are an ordered list of name/value pairs where names repeat and are case-insensitive. Whether your framework gives you the first, the last or a joined value is a detail worth knowing exactly.

Operating it

How you see it in production
  • Count 400s produced by the parser separately from application-generated 400s. A rise in the first is a client or intermediary change; a rise in the second is a contract problem (An Error Taxonomy That Maps Cause to Response).
  • Log request-line length and total header size as histogram values when parse errors climb. It converts "some clients get 400" into "tokens crossed 8 KB".
  • Compare status codes at the proxy with status codes at the origin. Requests that appear at one and not the other are being resolved by parsing, not by your application.
  • Alert on any request rejected for ambiguous framing. In normal traffic that number is zero, which makes it an unusually clean signal.
What changes at 10x and 100x
  • Parsing cost is per request and roughly fixed, so it grows linearly and only becomes visible as a share of CPU at high request rates — where it is one of the arguments for connection reuse and for HTTP/2 header compression.
  • At 100x, header size is bandwidth: a 2 KB cookie sent on every request is a real number multiplied by every request, and it is paid on the request side where compression is least likely to be in play.
  • Nothing about the framing rules changes with scale. The limits become more important, because the cost of an unbounded parse is now multiplied by your traffic.
What this costs
  • Strict parsing rejects some real traffic. Interoperability with old or sloppy clients pushes toward leniency, and every leniency is a place where two hops can disagree.
  • Low limits are safer and produce support tickets. High limits are friendlier and are an exhaustion surface. There is no setting that avoids both.
  • Using the runtime's parser means inheriting its bugs and its release cadence — HTTP parsers do receive security advisories, which is an argument for keeping the runtime patched rather than for writing your own (Dependency Security).

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • PROTOCOL-SPECIFICThis is HTTP/1.1. HTTP/2 replaces line-based text with binary frames and HPACK header compression — smuggling via framing disagreement largely disappears, replaced by concerns about stream limits and header-block expansion; HTTP/3 carries the same semantics over QUIC, so TCP-level framing questions do not arise at all.
  • GENERALThe principle transfers to any protocol on a stream transport: something must define message boundaries, and two implementations disagreeing about that boundary is always a security problem.
  • FRAMEWORK-SPECIFICWhere limits are set differs completely: Node exposes maxHeaderSize and per-server header timeouts; a Python WSGI app inherits them from Gunicorn or uWSGI; behind Nginx a large_client_header_buffers setting may reject the request before your runtime is involved at all. Same failure, three different configuration files.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.