Chapter 20 — Reverse proxies in Go: the client's identity, framing, and request smuggling
In one sentence
A reverse proxy is a full HTTP client and server in one process. Most proxy bugs come from losing the client's identity, trusting headers the client wrote, or disagreeing with another hop about where a request ends.
Why this matters
Every request in production passes through at least one proxy (CDN, load balancer, ingress, sidecar). Proxies decide what your application believes about the client (its IP address, its scheme, its host) and what your logs and rate limiters record. A proxy that parses HTTP differently from the server behind it can be tricked into letting through a request it never inspected.
How it actually works
The client's identity. Behind a proxy, r.RemoteAddr is the proxy. The
proxy passes the real client along in headers:
X-Forwarded-For: 203.0.113.7, 10.0.1.15 (client, then each proxy that appended)
X-Forwarded-Proto: https
X-Forwarded-Host: shop.example.com
Forwarded: for=203.0.113.7;proto=https;host=shop.example.com (the RFC 7239 standard form)
The client can send any of these headers itself. A proxy must therefore either replace them or append to them, and the application must trust only the entries added by proxies it controls: the right-most ones, counted from the edge.
Hop-by-hop headers (Connection, Keep-Alive, Transfer-Encoding, TE,
Upgrade, and anything named in Connection) describe one connection and
must not be forwarded. Go's ReverseProxy strips them.
Request smuggling. HTTP/1.1 has two ways to say where a body ends:
Content-Length and Transfer-Encoding: chunked. If a request carries both
and the front end and back end each believe a different one, they disagree
about where the request ends. The leftover bytes become the start of a
second request that the front end never saw. Defences: front ends that
reject ambiguous requests, proxies that fully parse and re-serialise
requests (as Go's ReverseProxy does) rather than passing bytes through, and
HTTP/2 between hops (its binary framing has no ambiguity).
Build it in Go (40 min) — a reverse proxy that gets the details right
// revproxy: a path-routing reverse proxy, built to show the details that
// matter in production -- who the client is (X-Forwarded-*), hop-by-hop
// headers, upstream timeouts, and 502 vs 504.
//
// go run ./revproxy -demo
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"strings"
"time"
)
type route struct {
prefix string
target *url.URL
}
// newProxy routes by path prefix, first match wins, so list longer prefixes
// first. Rewrite (Go 1.20+) is the safe API: the outgoing request starts
// WITHOUT the client's X-Forwarded-* headers, and SetXForwarded adds fresh
// ones from the real TCP peer.
func newProxy(routes []route) http.Handler {
transport := &http.Transport{
DialContext: (&net.Dialer{Timeout: 2 * time.Second}).DialContext,
ResponseHeaderTimeout: 2 * time.Second, // upstream must START answering in 2s
IdleConnTimeout: 60 * time.Second,
MaxIdleConnsPerHost: 64, // the default of 2 causes connection churn under load
}
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
for _, rt := range routes {
if strings.HasPrefix(pr.In.URL.Path, rt.prefix) {
pr.SetURL(rt.target)
break
}
}
pr.SetXForwarded() // X-Forwarded-For/Host/Proto from the TCP connection
},
Transport: transport,
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
status := http.StatusBadGateway // 502: couldn't get a valid response
if errors.Is(err, context.DeadlineExceeded) || isTimeout(err) {
status = http.StatusGatewayTimeout // 504: upstream too slow
}
log.Printf("proxy %s %s -> %d: %v", r.Method, r.URL.Path, status, err)
http.Error(w, http.StatusText(status), status)
},
}
}
func isTimeout(err error) bool {
var ne net.Error
return errors.As(err, &ne) && ne.Timeout()
}
// legacyProxy is the pre-Go-1.20 Director style, shown for contrast: it
// APPENDS to whatever X-Forwarded-For the client sent.
func legacyProxy(target *url.URL) http.Handler {
return httputil.NewSingleHostReverseProxy(target)
}
func main() {
demo := flag.Bool("demo", false, "run a scripted demo")
flag.Parse()
if !*demo {
fmt.Println("run with -demo")
return
}
log.SetFlags(0)
echo := func(name string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/slow" {
time.Sleep(3 * time.Second)
}
fmt.Fprintf(w, "%s saw path=%s XFF=%q Proto=%q Connection=%q",
name, r.URL.Path, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Forwarded-Proto"), r.Header.Get("Connection"))
}))
}
api, web := echo("api"), echo("web")
defer api.Close()
defer web.Close()
apiURL, _ := url.Parse(api.URL)
webURL, _ := url.Parse(web.URL)
dead, _ := url.Parse("http://127.0.0.1:1") // nothing listens on port 1
proxy := httptest.NewServer(newProxy([]route{{"/api/", apiURL}, {"/down/", dead}, {"/", webURL}}))
defer proxy.Close()
legacy := httptest.NewServer(legacyProxy(webURL))
defer legacy.Close()
get := func(label, base, path string, hdr map[string]string) {
req, _ := http.NewRequest("GET", base+path, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println(label, err)
return
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("%-22s %d %s\n", label, resp.StatusCode, strings.TrimSpace(string(b)))
}
spoof := map[string]string{"X-Forwarded-For": "6.6.6.6", "Connection": "X-Secret", "X-Secret": "hop"}
get("route /api/users", proxy.URL, "/api/users", nil)
get("route /index.html", proxy.URL, "/index.html", nil)
get("spoofed XFF (Rewrite)", proxy.URL, "/", spoof)
get("spoofed XFF (Director)", legacy.URL, "/", spoof)
get("dead upstream", proxy.URL, "/down/x", nil)
get("slow upstream", proxy.URL, "/api/slow", nil)
}
$ go run ./revproxy -demo
route /api/users 200 api saw path=/api/users XFF="127.0.0.1" Proto="http" Connection=""
route /index.html 200 web saw path=/index.html XFF="127.0.0.1" Proto="http" Connection=""
spoofed XFF (Rewrite) 200 web saw path=/ XFF="127.0.0.1" Proto="http" Connection=""
spoofed XFF (Director) 200 web saw path=/ XFF="6.6.6.6, 127.0.0.1" Proto="" Connection=""
proxy GET /down/x -> 502: dial tcp 127.0.0.1:1: connect: connection refused
dead upstream 502 Bad Gateway
proxy GET /api/slow -> 504: net/http: timeout awaiting response headers
slow upstream 504 Gateway Timeout
What to notice:
RewritevsDirector: the client sentX-Forwarded-For: 6.6.6.6. With Go 1.20'sRewriteAPI, the backend saw only the real peer address. With the olderDirectorAPI (NewSingleHostReverseProxy), the client's fake value reached the backend first in the list, and an application that reads the first entry would rate-limit, geo-locate, or audit-log6.6.6.6.Connection: X-Secretwas stripped by both: hop-by-hop handling is built in.- 502 vs 504 is a diagnosis. 502: the proxy couldn't get a valid response
(here, connection refused). 504: the upstream accepted the connection but
didn't answer within
ResponseHeaderTimeout. MaxIdleConnsPerHost: 64: Go's default is 2, so a busy proxy would open and close upstream connections constantly, wasting handshakes and filling TIME_WAIT (TCP/IP guide, Chapter 23).- Route order matters. An earlier draft of this lab stored routes in a Go
map, and map iteration order is random, so
/api/userssometimes went to the catch-all/backend. Routing tables must be ordered (longest prefix first) or use a router that guarantees it.
Build it in Go (15 min) — request smuggling's root cause
desync sends one message carrying both framing headers to a Go server it
starts itself, and shows how the server splits it:
// desync: see HTTP request smuggling's root cause -- two parsers disagreeing
// about where a request ends. Self-contained: it only talks to a test server
// it starts itself.
//
// go run ./desync
package main
import (
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"time"
)
func main() {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "backend handled: %s %s\n", r.Method, r.URL.Path)
}))
defer backend.Close()
// ONE message with BOTH framing headers. A front end that trusts
// Content-Length (10 bytes: "0\r\n\r\nGET /") sees one POST whose body
// ends mid-line. The backend follows RFC 9112 -- Transfer-Encoding wins --
// so the body ends at the "0" chunk and everything after it is parsed as a
// SECOND request the front end never inspected.
msg := "POST /search HTTP/1.1\r\nHost: shop\r\n" +
"Content-Length: 10\r\nTransfer-Encoding: chunked\r\n\r\n" +
"0\r\n\r\n" +
"GET /admin/delete-user HTTP/1.1\r\nHost: shop\r\n\r\n"
c, err := net.Dial("tcp", strings.TrimPrefix(backend.URL, "http://"))
if err != nil {
panic(err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(2 * time.Second))
io.WriteString(c, msg)
out, _ := io.ReadAll(c)
for _, line := range strings.Split(string(out), "\n") {
if strings.HasPrefix(line, "HTTP/") || strings.HasPrefix(line, "backend") {
fmt.Println(strings.TrimSpace(line))
}
}
fmt.Println("\nOne message in, TWO requests handled. If a front end had checked only")
fmt.Println("POST /search, then GET /admin/delete-user was smuggled past it.")
}
$ go run ./desync
HTTP/1.1 200 OK
backend handled: POST /search
HTTP/1.1 200 OK
backend handled: GET /admin/delete-user
One message in, TWO requests handled. If a front end had checked only
POST /search, then GET /admin/delete-user was smuggled past it.
What's happening: Go follows RFC 9112, where Transfer-Encoding overrides
Content-Length. The chunked body ends at 0\r\n\r\n, and the rest of the
bytes are parsed as a new request on the same connection. A front end that
had framed the message by Content-Length would have forwarded all of it as
one request after checking only POST /search.
Two more facts from testing this with Go 1.26:
- Go's handler never sees the conflict: the
Content-Lengthheader is removed before your code runs. You can't detect smuggling inside a Go handler. It has to be stopped by the parser in front. - RFC 9112 says a server must close the connection after responding to a request with both headers. In this test the Go server answered the second request on the same connection. Don't rely on any single component's parser. Reject ambiguous requests at the edge, and prefer HTTP/2 between proxies and backends.
Real-world scenario
An application rate-limits login attempts by X-Forwarded-For, taking the
first entry. Attackers send X-Forwarded-For: <random IP> on every request,
each attempt appears to come from a new IP, and the rate limiter never
triggers. The fix is to read the address the trusted edge proxy appended
(or to configure the edge to overwrite the header) and to rate-limit by
account as well as by IP (Chapter 21).
Common mistakes
- Trusting the left-most
X-Forwarded-Forentry. - Using
NewSingleHostReverseProxywithout understanding that it appends to client-supplied forwarding headers. - Leaving
MaxIdleConnsPerHostat its default of 2 on a busy proxy. - No
ResponseHeaderTimeout: one hung backend ties up proxy connections indefinitely. - Passing raw bytes between hops whose HTTP parsers differ.
Check yourself
- Which
X-Forwarded-Forentry should an application trust, and why? - What does
pr.SetXForwarded()do differently from theDirectorbehaviour? - Why can't a Go handler detect a smuggling attempt?
- What does a 504 from your proxy tell you that a 502 doesn't?
Key Takeaways
- Use
httputil.ReverseProxywithRewriteandSetXForwarded, and set transport timeouts. - Trust only forwarding headers added by your own proxies.
- Smuggling is a parser disagreement. Normalise framing at the edge and use HTTP/2 between hops.
Across the series: Chapter 8 (proxy types); TCP/IP guide, Chapter 57 (PROXY protocol for L4 proxies); Go plan Day 64 (production reverse proxy) and Day 63 (security middleware chain).