diff --git a/internal/proxy/forwarded_test.go b/internal/proxy/forwarded_test.go new file mode 100644 index 0000000..89f4fe3 --- /dev/null +++ b/internal/proxy/forwarded_test.go @@ -0,0 +1,196 @@ +package proxy + +import ( + "bufio" + "crypto/tls" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +func newTestProxy() *Proxy { + return New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil) +} + +func TestProxyToBackend_ForwardedHeaders(t *testing.T) { + received := make(chan http.Header, 1) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received <- r.Header.Clone() + })) + defer backend.Close() + + backendURL, err := url.Parse(backend.URL) + if err != nil { + t.Fatal(err) + } + + p := newTestProxy() + + r := httptest.NewRequest(http.MethodGet, "https://example.com/path", nil) + r.Header.Set("X-Forwarded-For", "1.2.3.4") + r.Header.Set("X-Forwarded-Host", "spoofed.example.com") + r.Header.Set("X-Forwarded-Proto", "spoofed") + r.Header.Set("X-Real-IP", "5.6.7.8") + r.Header.Set("Forwarded", "for=1.2.3.4") + + w := httptest.NewRecorder() + p.proxyToBackend(w, r, backendURL.Host, time.Now()) + + var headers http.Header + select { + case headers = <-received: + case <-time.After(5 * time.Second): + t.Fatal("backend did not receive request") + } + + // httptest.NewRequest sets RemoteAddr to 192.0.2.1:1234 + if got := headers.Get("X-Forwarded-For"); got != "192.0.2.1" { + t.Errorf("X-Forwarded-For = %q, want %q (spoofed value must be stripped)", got, "192.0.2.1") + } + if got := headers.Get("X-Forwarded-Host"); got != "example.com" { + t.Errorf("X-Forwarded-Host = %q, want %q", got, "example.com") + } + if got := headers.Get("X-Forwarded-Proto"); got != "https" { + t.Errorf("X-Forwarded-Proto = %q, want %q", got, "https") + } + if got := headers.Get("X-Real-IP"); got != "" { + t.Errorf("X-Real-IP = %q, want it stripped", got) + } + if got := headers.Get("Forwarded"); got != "" { + t.Errorf("Forwarded = %q, want it stripped", got) + } +} + +func TestSetForwardedHeaders(t *testing.T) { + tests := []struct { + name string + tls *tls.ConnectionState + wantProto string + }{ + {name: "https request", tls: &tls.ConnectionState{}, wantProto: "https"}, + {name: "http request", tls: nil, wantProto: "http"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + r.TLS = tt.tls + r.RemoteAddr = "203.0.113.7:54321" + r.Header.Set("X-Forwarded-For", "1.2.3.4") + r.Header.Set("X-Forwarded-Host", "spoofed.example.com") + r.Header.Set("X-Forwarded-Proto", "spoofed") + r.Header.Set("X-Real-IP", "5.6.7.8") + r.Header.Set("Forwarded", "for=1.2.3.4") + + setForwardedHeaders(r) + + if got := r.Header.Get("X-Forwarded-For"); got != "203.0.113.7" { + t.Errorf("X-Forwarded-For = %q, want %q", got, "203.0.113.7") + } + if got := r.Header.Get("X-Forwarded-Host"); got != "example.com" { + t.Errorf("X-Forwarded-Host = %q, want %q", got, "example.com") + } + if got := r.Header.Get("X-Forwarded-Proto"); got != tt.wantProto { + t.Errorf("X-Forwarded-Proto = %q, want %q", got, tt.wantProto) + } + if got := r.Header.Get("X-Real-IP"); got != "" { + t.Errorf("X-Real-IP = %q, want it stripped", got) + } + if got := r.Header.Get("Forwarded"); got != "" { + t.Errorf("Forwarded = %q, want it stripped", got) + } + }) + } +} + +func TestHandleWebSocket_ForwardedHeaders(t *testing.T) { + backendListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer backendListener.Close() + + received := make(chan http.Header, 1) + go func() { + conn, err := backendListener.Accept() + if err != nil { + return + } + defer conn.Close() + req, err := http.ReadRequest(bufio.NewReader(conn)) + if err != nil { + return + } + received <- req.Header.Clone() + conn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n")) + }() + + backendHost, backendPort, err := net.SplitHostPort(backendListener.Addr().String()) + if err != nil { + t.Fatal(err) + } + + p := newTestProxy() + route := &Route{ + Canonical: "example.com", + Backends: []Backend{{IP: backendHost, Port: backendPort}}, + } + + front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p.handleWebSocket(w, r, route, time.Now()) + })) + defer front.Close() + + frontURL, err := url.Parse(front.URL) + if err != nil { + t.Fatal(err) + } + + clientConn, err := net.Dial("tcp", frontURL.Host) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + handshake := "GET / HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "X-Forwarded-For: 1.2.3.4\r\n" + + "X-Forwarded-Host: spoofed.example.com\r\n" + + "X-Forwarded-Proto: spoofed\r\n" + + "X-Real-IP: 5.6.7.8\r\n" + + "Forwarded: for=1.2.3.4\r\n" + + "\r\n" + if _, err := clientConn.Write([]byte(handshake)); err != nil { + t.Fatal(err) + } + + var headers http.Header + select { + case headers = <-received: + case <-time.After(5 * time.Second): + t.Fatal("backend did not receive websocket handshake") + } + + if got := headers.Get("X-Forwarded-For"); got != "127.0.0.1" { + t.Errorf("X-Forwarded-For = %q, want %q (spoofed value must be stripped)", got, "127.0.0.1") + } + if got := headers.Get("X-Forwarded-Host"); got != "example.com" { + t.Errorf("X-Forwarded-Host = %q, want %q", got, "example.com") + } + if got := headers.Get("X-Forwarded-Proto"); got != "http" { + t.Errorf("X-Forwarded-Proto = %q, want %q", got, "http") + } + if got := headers.Get("X-Real-IP"); got != "" { + t.Errorf("X-Real-IP = %q, want it stripped", got) + } + if got := headers.Get("Forwarded"); got != "" { + t.Errorf("Forwarded = %q, want it stripped", got) + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 3333f91..deee3b6 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -368,6 +368,7 @@ func (p *Proxy) proxyToBackend(w http.ResponseWriter, r *http.Request, backendAd Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(targetURL) pr.SetXForwarded() + pr.Out.Header.Del("X-Real-IP") pr.Out.Host = r.Host }, Transport: p.transport, @@ -400,6 +401,7 @@ func (p *Proxy) handleACMEChallenge(w http.ResponseWriter, r *http.Request) { proxy := &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(targetURL) + pr.Out.Header.Del("X-Real-IP") pr.Out.Host = r.Host }, Transport: p.transport, diff --git a/internal/proxy/websocket.go b/internal/proxy/websocket.go index cec486b..d81b1df 100644 --- a/internal/proxy/websocket.go +++ b/internal/proxy/websocket.go @@ -9,6 +9,27 @@ import ( "time" ) +// setForwardedHeaders replaces any client-supplied forwarding headers with +// trusted values, mirroring httputil.ReverseProxy's Rewrite + SetXForwarded +// behavior for requests that bypass the reverse proxy. +func setForwardedHeaders(r *http.Request) { + r.Header.Del("Forwarded") + r.Header.Del("X-Forwarded-For") + r.Header.Del("X-Forwarded-Host") + r.Header.Del("X-Forwarded-Proto") + r.Header.Del("X-Real-IP") + + if clientIP, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + r.Header.Set("X-Forwarded-For", clientIP) + } + r.Header.Set("X-Forwarded-Host", r.Host) + if r.TLS != nil { + r.Header.Set("X-Forwarded-Proto", "https") + } else { + r.Header.Set("X-Forwarded-Proto", "http") + } +} + // isWebSocketUpgrade checks if the request is a WebSocket upgrade request. func isWebSocketUpgrade(r *http.Request) bool { return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") && @@ -55,6 +76,8 @@ func (p *Proxy) handleWebSocket(w http.ResponseWriter, r *http.Request, route *R } defer clientConn.Close() + setForwardedHeaders(r) + // Forward the original HTTP request to the backend to initiate the WebSocket handshake if err := r.Write(backendConn); err != nil { p.logger.Error("WebSocket: failed to forward request to backend", "error", err)