Day 64 — HTTP Reverse Proxy
Learning Objectives
- Configure
httputil.ReverseProxywith all production options - Handle hop-by-hop headers correctly
- Inject
X-Forwarded-ForandX-Request-IDsecurely
Key Concepts
Hop-by-hop headers — must NOT be forwarded:
Connection, Keep-Alive, Transfer-Encoding, TE, Upgrade,
Proxy-Authorization, Proxy-Authenticate
X-Forwarded-For security:
Proxy APPENDS the client IP (never overwrites)
X-Forwarded-For: client-ip, proxy1-ip, proxy2-ip
Backend should use the FIRST entry in the chain
Never trust X-Forwarded-For from untrusted clients — it can be spoofed
Only trust the XFF chain from IPs you control
X-Real-IP:
Proxy sets to the direct client IP (before any other proxies)
Never trust X-Real-IP if set by the client
Exercise: Production Reverse Proxy
package main
import (
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
)
// hopByHop headers must not be forwarded upstream
var hopByHopHeaders = map[string]bool{
"connection": true,
"keep-alive": true,
"transfer-encoding": true,
"te": true,
"upgrade": true,
"proxy-authorization": true,
"proxy-authenticate": true,
"trailers": true,
}
// internalHeaders must not be set by clients (privilege escalation)
var sensitiveInternalHeaders = []string{
"X-Internal-Role",
"X-Admin",
"X-Authenticated-Subject",
"X-User-ID",
}
func NewSecureReverseProxy(upstream *url.URL) *httputil.ReverseProxy {
proxy := httputil.NewSingleHostReverseProxy(upstream)
// Director: modify request before sending to upstream
proxy.Director = func(r *http.Request) {
// Standard reverse proxy setup
r.URL.Scheme = upstream.Scheme
r.URL.Host = upstream.Host
r.Host = upstream.Host
// Strip hop-by-hop headers
for header := range hopByHopHeaders {
r.Header.Del(header)
}
// Strip client-supplied internal headers (security: prevent privilege escalation)
for _, h := range sensitiveInternalHeaders {
r.Header.Del(h)
}
// X-Forwarded-For: append real client IP, don't trust existing
clientIP := r.RemoteAddr
if host, _, err := net.SplitHostPort(clientIP); err == nil {
clientIP = host
}
if prior := r.Header.Get("X-Forwarded-For"); prior != "" {
r.Header.Set("X-Forwarded-For", prior+", "+clientIP)
} else {
r.Header.Set("X-Forwarded-For", clientIP)
}
// X-Forwarded-Proto
if r.TLS != nil {
r.Header.Set("X-Forwarded-Proto", "https")
} else {
r.Header.Set("X-Forwarded-Proto", "http")
}
// Inject request ID from context
if id := r.Context().Value(requestIDKey); id != nil {
r.Header.Set("X-Request-ID", id.(string))
}
}
// ModifyResponse: clean up upstream response before returning to client
proxy.ModifyResponse = func(resp *http.Response) error {
// Strip upstream's internal headers from response
resp.Header.Del("X-Powered-By")
resp.Header.Del("Server")
resp.Header.Del("X-AspNet-Version")
// Add correlation header
if id := resp.Request.Header.Get("X-Request-ID"); id != "" {
resp.Header.Set("X-Request-ID", id)
}
return nil
}
// ErrorHandler: what to return when upstream is unreachable
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
fmt.Printf("[PROXY] upstream error: %v\n", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
fmt.Fprintf(w, `{"error":"upstream unavailable","request_id":%q}`,
r.Context().Value(requestIDKey))
}
// Custom transport with timeouts
proxy.Transport = &http.Transport{
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
}
return proxy
}
func main() {
upstream, _ := url.Parse("http://127.0.0.1:9090")
proxy := NewSecureReverseProxy(upstream)
handler := Chain(proxy,
PanicRecoverer(slog.Default()),
RequestID,
SecurityHeaders,
RequestLogger(slog.Default()),
)
srv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second, // higher: includes upstream response time
IdleTimeout: 120 * time.Second,
}
fmt.Println("Reverse proxy :8080 → :9090")
srv.ListenAndServe()
}
Real-World Context
Stripping X-Internal-Role that clients might set prevents privilege escalation — OWASP A01 (Broken Access Control). Setting X-Forwarded-For correctly is required for IP-based rate limiting and WAF to see the real client IP. If your proxy overwrites (not appends) XFF, you lose the audit trail.