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)
0x200x21 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-Length and Transfer-Encoding
  • duplicate Content-Length
  • a chunked encoding that is not the last transfer-coding
  • a duplicate Host header
  • 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.