Internet Protocols · HTTP
Intermediaries, Hop-by-Hop Fields, and the Via Chain
How proxies, gateways, and tunnels differ, which fields an intermediary must consume rather than forward, and what forwarding does to message semantics.
An HTTP message is written for its next recipient, not for its final one. That single sentence explains most of the behaviour that distinguishes an intermediary from an endpoint. A proxy that simply relays bytes is not conformant, because the fields in the message describe a hop that has already been traversed, and forwarding them unchanged tells the next recipient something false about its own connection.
Three kinds of intermediary
A proxy forwards requests on behalf of a client, usually because the client cannot reach the origin directly. A gateway is an intermediary that appears to be the origin server to the client, translating to some other protocol behind it. A tunnel is a blind relay: after a CONNECT request succeeds, the intermediary copies bytes in both directions without parsing them as HTTP.
The distinction matters because only the first two are HTTP intermediaries in the semantic sense. A tunnel has no fields to rewrite and no messages to interpret, and an intermediary that tries to apply HTTP rules inside a tunnel will corrupt the payload. That is why CONNECT responses carry no body and why the bytes after the 200 are opaque.
An HTTP intermediary is further classified by whether it is shared. A shared cache serves multiple users and must honour Cache-Control: private by not storing. A private cache, such as a browser’s, may store the same response. A reverse proxy sitting in front of an origin is shared from the client’s perspective even though it serves one site; the distinction is about users, not about topology.
Hop-by-hop versus end-to-end
HTTP fields divide into two categories by scope. End-to-end fields are addressed to the ultimate recipient and survive forwarding unchanged. Hop-by-hop fields describe the connection between two adjacent parties, and an intermediary must consume them rather than pass them on.
The hop-by-hop set is defined in two ways that must be combined. First, a fixed list of fields is connection-specific by definition: Connection, Keep-Alive, TE, Transfer-Encoding, and Upgrade, plus the non-standard Proxy-Connection that some clients still send. Second, and this is the part implementations miss, Connection may name additional fields that are hop-by-hop for this message only. A message with Connection: X-Foo declares that X-Foo applies to this hop and must not be forwarded. A proxy that forwards X-Foo has violated the sender’s declaration, and a proxy that forwards the Connection field itself has told the next hop about a connection that no longer exists.
Proxy-Authorization sits in a third category: it is end-to-end in form but addressed to the next inbound proxy, so a proxy consumes it and does not pass it to the origin. Proxy-Authenticate travels the other way, from a proxy to the client that must authenticate to it. Neither belongs in a message to an origin server.
Transfer-Encoding is the clearest case. It describes how the body is chunked on this connection. A proxy that receives a chunked request and forwards the raw bytes with Transfer-Encoding: chunked is fine; one that decodes the chunks into a buffer and then forwards the same field with the decoded body has produced a message that lies about its own framing. Either re-encode, or set Content-Length, or remove the field and let the body be delimited some other way, but the combination of a decoded body and a chunked declaration is precisely the primitive behind request smuggling, described in message delimitation and request smuggling.
Connection: close deserves separate mention. A proxy that receives it must not forward it, because it describes the client-to-proxy connection, not the proxy-to-origin one. Forwarding it would tell the origin to close a connection the proxy intended to keep. The reverse also holds: a proxy that wants to close its upstream connection sends the field on that hop only.
Via is the field that records the path. Each intermediary appends an entry containing the protocol version and a pseudonym, so Via: 1.1 edge-3, 1.1 cache-7 records two hops. A proxy must send it in each message it forwards, whether or not it changed anything, and it exists so that a later recipient can detect a loop and so that debugging can reconstruct the path. A Via chain that grows without bound is itself a symptom: a request bouncing between two proxies that each think the other is the origin will accumulate entries until something gives.
Rewriting what must be rewritten
An intermediary that forwards a request must adjust several things that are only true for the incoming connection.
Host is not rewritten by a proxy that forwards in origin-form; it forwards the field as received, because it identifies the origin the client intended. A proxy that forwards in absolute-form sends the full URI and must ensure the authority in the target matches the Host field. A gateway that maps a public hostname to an internal one faces a real tension: rewriting Host breaks origin applications that generate absolute URLs from it, while not rewriting it breaks virtual hosting on the internal side. The usual resolution is to forward the original value in a separate field and rewrite Host, which means the origin application has to know to read the other field.
Content-Length must be recomputed if the body changed length, which happens whenever the intermediary re-encodes, compresses, or transforms. A proxy that compresses a response on the fly and leaves the original Content-Length produces a message whose declared length is wrong, and the recipient will either truncate or hang.
Connection must be reconstructed for the outgoing hop, listing whatever hop-by-hop fields that hop needs. And any field the intermediary adds must not collide with a field the client sent; a proxy that appends its own X-Forwarded-For entry to a client-supplied value is trusting data it did not generate, which is the origin of a long history of spoofing.
Forwarded addresses and the trust boundary
The original client address is lost when a request passes through a proxy, because the TCP connection now terminates at the proxy. The conventional remedy is a field carrying the address chain: Forwarded in its standardised form, or the older X-Forwarded-For. Both are lists, and both are trivially forgeable by any client that can send a request with the field already populated.
The correct handling is to treat the field as untrusted until a hop that the origin actually controls has appended to it. An origin behind exactly one trusted proxy can take the last entry as the client address. An origin behind two takes the second-to-last. The count of trusted hops is configuration, and getting it wrong means either logging the proxy’s address as the client or accepting an attacker’s claimed address as truth. A request that arrives with a Forwarded chain longer than the trusted hop count is either a misconfiguration or an attack; there is no way to distinguish them from the message alone.
X-Forwarded-Proto and X-Forwarded-Host carry the same caveat. An application that decides whether to redirect to HTTPS based on a client-supplied X-Forwarded-Proto is allowing the client to choose. Only a value appended by a trusted hop means anything.
Tunnels and the CONNECT method
CONNECT asks an intermediary to establish a bidirectional byte stream to a named authority. The request target is authority-form, host:port, with no path. On success the intermediary sends a 2xx response with no body and then stops parsing HTTP on that connection.
The tunnel has properties that a proxy must respect. It is not a cache, because there are no messages to cache. It does not rewrite fields, because there are no fields after the handshake. It must not assume the tunneled protocol is HTTP; CONNECT is used for TLS and for arbitrary TCP. And it must enforce its own policy, because the tunnel target is chosen by the client: an open proxy that allows CONNECT to any authority is an abuse vector, and the standard mitigation is an allowlist of ports or destinations.
A tunnel also changes what the intermediary can observe. End-to-end fields inside a TLS tunnel are encrypted and invisible, which is why a proxy that wants to cache or filter must terminate TLS rather than tunnel it. That termination turns the proxy into an endpoint for the TLS session, and the client must trust the proxy’s certificate, a design decision with its own security consequences, not a protocol mechanism.
Failure modes worth naming
Loop detection depends on Via, and an intermediary that strips Via removes the only signal. A pair of misconfigured proxies that each forward to the other will consume the request indefinitely, and the client sees a timeout with no diagnostic.
Hop-by-hop leakage is the security-relevant failure. A proxy that forwards Connection: keep-alive to an origin using HTTP/1.0 causes the origin to keep a connection open that the proxy will close, leaving the origin with a half-used socket. A proxy that forwards Transfer-Encoding after decoding enables smuggling. A proxy that forwards Proxy-Authorization leaks credentials intended for the proxy to the origin.
Message transformation without revalidation is the third. An intermediary that strips a header it considers noisy, or normalises whitespace in a field value, changes the message in a way the origin’s signature or cache key may depend on. The specification allows an intermediary to transform a message only in ways the message’s semantics permit, and the safe default is to transform nothing that was not explicitly hop-by-hop. A proxy’s job is to be as close to transparent as the connection semantics allow, and every deviation should be a deliberate, documented decision.
References
- RFC 9110: HTTP Semantics — IETF
- RFC 9112: HTTP/1.1 — IETF
- RFC 9293: Transmission Control Protocol (TCP) — IETF