Traffic capture
Architecture live
Before Heimdall can reason about a request it has to see it — exactly as it came off the wire. This is the boundary between HAProxy (transport) and the engine (security), and it is the reason Heimdall can catch attacks that a header-level proxy cannot.
HAProxy does transport only
HAProxy sits at the edge and handles TLS termination, routing, connection pooling, health checks, HA and coarse L4/L7 rate limiting. It carries no security logic. Its one job toward Heimdall is to hand over the connection with two things attached: the real client identity, and the raw bytes.
TCP-mode inline backend
Per ADR-0001, HAProxy forwards to the engine as a TCP-mode inline backend: it terminates TLS and splices the raw, undecoded byte stream to the engine, which then owns all HTTP parsing itself. HTTP mode was considered and kept only as a documented fallback.
The reason is fidelity. If HAProxy parsed and re-serialized the HTTP, Heimdall would only ever see HAProxy's interpretation of the request — the smuggling and framing ambiguities would already have been normalized away. In TCP mode Heimdall sees the true bytes, which is what makes request smuggling detectable. It also benchmarked faster than HTTP mode.
PROXY protocol v2 — identity and TLS fingerprints
TCP mode loses the client IP (HAProxy is now the peer), so HAProxy prepends a PROXY protocol v2 header. Heimdall parses it to recover the real client address, and — when a plugin enricher is present — walks the TLVs to extract:
| TLV | Value |
|---|---|
0x02 |
SNI (authority) |
0xE0 |
JA3 (TLS ClientHello fingerprint) |
0xE1 |
JA4 (modern TLS fingerprint) |
0x20 → 0x21 |
TLS version (nested SSL sub-TLV) |
JA3/JA4/SNI/TLS-version are handed to connection-level plugins so Browser Identity can fingerprint the client's TLS stack. See Plugins.
The HTTP engine
Heimdall parses the request head with its own HTTP engine — not Go's
net/http. The parser fills the canonical request (request line, headers,
framing) and, critically, flags framing anomalies while it does so:
- conflicting
Content-LengthandTransfer-Encoding - duplicate
Content-Length - a chunked encoding that is not the last transfer-coding
- a duplicate
Hostheader - obsolete line folding, bare CR, spaces before the colon, invalid header names, non-ASCII in the request line
These anomaly flags live on the canonical model and are weighted directly by the risk engine — they are how request smuggling and desync attacks are caught, independent of any payload signature.
HTTP/1.1, HTTP/2, and WebSocket
The engine handles a keep-alive HTTP/1.1 request loop, detects HTTP/2 (both h2c
prior-knowledge and ALPN) and serves it through its own HPACK implementation, and
relays WebSocket frames after a 101 upgrade so post-handshake frames can still
be inspected. If the head cannot be framed within the head timeout, the connection
degrades to a raw splice rather than failing — availability first.
Forwarding to origin
Once analysis allows the request, the engine dials the origin, writes the head
(optionally injecting Forwarded / X-Forwarded-Proto), streams the body, and
proxies the response back — with optional response-header hardening and a branded
block/challenge page when a decision calls for it. The client's real IP can also
be resolved from a trusted header (rightmost trusted entry) when Heimdall runs
behind another trusted hop.
Where this lives
internal/proxy (connection loop and forwarding), internal/httpengine
(head parser and framing), internal/proxyproto (PROXY v2 + TLVs). The exact
entry functions are listed in Internals.