Frontend Labs
⋯

Day 64 — HTTP Reverse Proxy

Learning Objectives

  • Configure httputil.ReverseProxy with all production options
  • Handle hop-by-hop headers correctly
  • Inject X-Forwarded-For and X-Request-ID securely

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.


Generated from week9.md · 8 Oct 2026 16:36