skip to content

An httputil.ReverseProxy moved from Director to Rewrite now sends the upstream only one X-Forwarded-For address. Why?

level: seniorimportance: should knowfreq 40%

answer

  1. the two hooks default in opposite directions
  2. Rewrite starts from a clean slate
  3. Director appended to the caller's claim
  4. the helper writes what the proxy observed
  5. copy the chain across only if trusted

basics

~20 s

The Director path appends the client's address to whatever chain arrived. The Rewrite path deletes the inbound forwarding headers first and adds nothing unless the hook calls ProxyRequest.SetXForwarded, which writes only the address the proxy itself observed.

solid answer

~40 s

The two hooks have opposite defaults. With a `Director`, `ReverseProxy` itself appends the client's address from `RemoteAddr` to whatever `X-Forwarded-For` the client sent — the chain grows, including anything the caller made up. With `Rewrite`, the proxy first deletes `Forwarded`, `X-Forwarded-For`, `X-Forwarded-Host` and `X-Forwarded-Proto` from the outbound request, then calls your hook; `r.SetXForwarded()` writes `X-Forwarded-For` from the address this connection actually came from, plus `X-Forwarded-Host` from the inbound Host and `X-Forwarded-Proto` from whether the inbound connection was TLS. One address is the correct outcome for an internet-facing edge. If a trusted proxy sits in front of you and you genuinely want its chain, copy `r.In`'s header onto `r.Out` before calling `SetXForwarded`, so the helper appends to it.

code

go · 9 lines
go
proxy := &httputil.ReverseProxy{
	Rewrite: func(r *httputil.ProxyRequest) {
		r.SetURL(upstream)
		// only when the hop in front of us is trusted infrastructure
		r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
		// appends this connection's client address; also sets Host and Proto
		r.SetXForwarded()
	},
}

go deeper

for a junior

Know that the upstream cannot see the client's address by itself, so the proxy has to put it in a forwarding header, and that a Go proxy has a helper that writes those headers for you.

for a middle

Explain the mechanics of both hooks: one appends the client address to whatever arrived, the other clears the inbound forwarding headers first and writes only what the helper is asked to write.

for a senior

Treat it as a trust decision under a migration. Say what changes for the upstream when the hook changes, when copying the inbound chain is legitimate, and how you verify with a forged header before shipping.

for a principal

Set the rule once for the estate: which hops may append, which strip, and what every service is allowed to believe about the address it reads, so no team invents its own trust boundary.

## What changed between the hooks This is not a bug and not a regression; it is a default that was reversed on purpose. **Director path.** After the Director runs, `ReverseProxy` takes the client address out of the inbound `RemoteAddr` and appends it to any `X-Forwarded-For` already on the outbound request. Because the outbound request is a clone of the inbound one, "already on the request" means "whatever the client sent". So the upstream sees `<whatever the caller wrote>, <the address we observed>`. **Rewrite path.** Before your hook is called, the proxy deletes `Forwarded`, `X-Forwarded-For`, `X-Forwarded-Host` and `X-Forwarded-Proto` from the outbound request, and it appends nothing afterwards. If your hook does not call `SetXForwarded`, the upstream receives no forwarding headers at all. If it does, the helper writes what the proxy itself observed: the client address for `X-Forwarded-For`, the inbound Host for `X-Forwarded-Host`, and `http` or `https` for `X-Forwarded-Proto` according to whether the inbound connection was TLS. Hence the symptom: one address, and it is the right one. ## Why the reversal is the safer default `X-Forwarded-For` is a claim, not an observation. Only the last entry — the one your own proxy added — is something your process actually saw. Everything before it was written by an earlier hop, and at an internet-facing edge the earliest "hop" is the caller. A caller who sends `X-Forwarded-For: 10.0.0.9` on a proxy that appends has just placed an address of their choosing at the front of the chain your services read. The Director path made that pass-through the default and it required you to notice. The Rewrite path makes trust the explicit line of code. ## Restoring a chain you actually trust When the hop in front of you is your own load balancer or CDN and it appends honestly, the chain is worth keeping. The documented shape is to copy the inbound values onto the outbound request and then let the helper append: ```go r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"] r.SetXForwarded() ``` `SetXForwarded` appends to whatever `X-Forwarded-For` is on `Out`, so the result is the trusted chain plus the address this proxy observed. The decision you are making with that one line is a trust decision, and it should be conditioned on the deployment, not written by habit: an edge proxy reachable from the internet should not run it; an internal proxy behind a load balancer you own should. ## The other two headers Do not forget `X-Forwarded-Host` and `X-Forwarded-Proto`, which `SetXForwarded` also writes. An application that builds absolute links needs both: `Host` because `SetURL` may have changed the outbound Host to the target's, and `Proto` because the proxy usually terminates TLS and speaks plain HTTP to the upstream. Hand-rolling only `X-Forwarded-For` is how a service ends up generating `http://` links for users who arrived over HTTPS. ## Verifying it This is cheap to check and worth checking on every gateway change. Put a temporary handler on the upstream side that echoes the request headers it received, send a request through the proxy with a forged `X-Forwarded-For`, and read the two logs side by side: what your hook produced and what the upstream saw. On an edge proxy the forged value must be absent from the upstream's view. Do it once per environment, because the answer depends on which hook the proxy is using and on what sits in front of it — the code alone does not tell you. ## Summary Director appends to the caller's claim; Rewrite starts from nothing and writes only what the proxy observed. One address in the upstream's view means the new default is working. Copy the inbound chain across only when you can name the trusted component that produced it.

  • Which headers does ProxyRequest.SetXForwarded set, and from what?
    Three. `X-Forwarded-For` from the address the inbound connection came from, `X-Forwarded-Host` from the Host the client asked for, and `X-Forwarded-Proto` set to `https` or `http` depending on whether the inbound connection used TLS. All three come from the proxy's own observation, not from the caller.
  • When should you copy the inbound X-Forwarded-For chain onto the outbound request?
    Only when you can name the component in front of you and trust it to append honestly — your own load balancer or CDN. At an internet-facing edge, drop the inbound chain and let the helper write the single address you actually observed, otherwise a caller can choose what your services read.
  • On the Director path, what happens when the client sends no X-Forwarded-For at all?
    The proxy sets the header to the client's address alone. It appends to a prior value only when there is one, so the first proxy in a chain always produces a single-entry header.

saying these in an interview costs you the question

  • Assumes both hooks manage the forwarding headers identically
  • Trusts a client-supplied X-Forwarded-For at an internet-facing edge
  • Thinks SetXForwarded pulls the inbound chain across by itself
  • Believes X-Forwarded-Proto comes from the upstream target's scheme
  • Writes X-Forwarded-For by hand and forgets Host and Proto