diff --git a/middleware/proxy.go b/middleware/proxy.go index eb4d9e23e..b01cca680 100644 --- a/middleware/proxy.go +++ b/middleware/proxy.go @@ -290,7 +290,8 @@ func (config ProxyConfig) ToMiddleware() (echo.MiddlewareFunc, error) { // Always overwrite X-Real-IP. Without Echo#IPExtractor, c.RealIP() is the address of the direct peer, so a // client cannot spoof the IP seen by the upstream. Configure Echo#IPExtractor to trust headers set by proxies. - req.Header.Set(echo.HeaderXRealIP, c.RealIP()) + clientIP := c.RealIP() + req.Header.Set(echo.HeaderXRealIP, clientIP) // Always overwrite X-Forwarded-Proto. c.Scheme() uses an incoming X-Forwarded-Proto header only when it comes // from a trusted address (see Echo#SchemeExtractor), so clients cannot spoof the scheme seen by the upstream. // Remove the other scheme headers: X-Forwarded-Proto carries the scheme, and some upstreams (e.g. Rack) would @@ -326,7 +327,7 @@ func (config ProxyConfig) ToMiddleware() (echo.MiddlewareFunc, error) { req = c.Request() // ReverseProxy validates upgrades and closes both sides when a tunnel ends. - proxyHTTP(c, tgt, config).ServeHTTP(res, req) + proxyHTTP(c, tgt, config, clientIP).ServeHTTP(res, req) err, hasError := c.Get("_error").(error) if !hasError { @@ -351,7 +352,7 @@ func (config ProxyConfig) ToMiddleware() (echo.MiddlewareFunc, error) { // 499 too instead of the more problematic 5xx, which does not allow to detect this situation const StatusCodeContextCanceled = 499 -func proxyHTTP(c *echo.Context, tgt *ProxyTarget, config ProxyConfig) http.Handler { +func proxyHTTP(c *echo.Context, tgt *ProxyTarget, config ProxyConfig, clientIP string) http.Handler { // The HTTP transport also handles WebSocket upgrades. Normalize WebSocket // target schemes on a copy so shared balancer targets remain unchanged. targetURL := *tgt.URL @@ -385,7 +386,14 @@ func proxyHTTP(c *echo.Context, tgt *ProxyTarget, config ProxyConfig) http.Handl c.Set("_error", httpError) } } - proxy.Transport = config.Transport + transport := config.Transport + if transport == nil { + transport = http.DefaultTransport + } + // ReverseProxy removes headers named by Connection after its Director runs. + // Restore the derived identity at the transport boundary so a client cannot + // suppress it with Connection: X-Real-IP, including on WebSocket upgrades. + proxy.Transport = proxyClientIPTransport{transport: transport, clientIP: clientIP} proxy.ModifyResponse = func(response *http.Response) error { if config.ModifyResponse != nil { if err := config.ModifyResponse(response); err != nil { @@ -409,6 +417,17 @@ func proxyHTTP(c *echo.Context, tgt *ProxyTarget, config ProxyConfig) http.Handl }) } +type proxyClientIPTransport struct { + transport http.RoundTripper + clientIP string +} + +func (t proxyClientIPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set(echo.HeaderXRealIP, t.clientIP) + return t.transport.RoundTrip(req) +} + type proxyUpgradeResponseWriter struct { http.ResponseWriter } diff --git a/middleware/proxy_identity_test.go b/middleware/proxy_identity_test.go new file mode 100644 index 000000000..4e0734948 --- /dev/null +++ b/middleware/proxy_identity_test.go @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2026 LabStack LLC and Echo contributors + +package middleware + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" + "golang.org/x/net/websocket" +) + +func TestProxyIdentitySurvivesConnectionHeader(t *testing.T) { + for _, configuredTransport := range []bool{false, true} { + for _, configuredExtractor := range []bool{false, true} { + for _, connection := range []string{"", "keep-alive, x-ReAl-Ip"} { + t.Run(fmt.Sprintf("transport=%t/extractor=%t/connection=%s", configuredTransport, configuredExtractor, connection), func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(r.Header.Values(echo.HeaderXRealIP)) + })) + defer upstream.Close() + target, err := url.Parse(upstream.URL) + require.NoError(t, err) + e := echo.New() + wantIP := "198.51.100.10" + if configuredExtractor { + wantIP = "203.0.113.10" + e.IPExtractor = func(*http.Request) string { return wantIP } + } + config := ProxyConfig{Balancer: NewRoundRobinBalancer([]*ProxyTarget{{URL: target}})} + if configuredTransport { + transport := &http.Transport{} + defer transport.CloseIdleConnections() + config.Transport = transport + } + e.Use(ProxyWithConfig(config)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "198.51.100.10:1234" + req.Header.Set(echo.HeaderXRealIP, "203.0.113.1") + req.Header.Add(echo.HeaderXRealIP, "203.0.113.2") + req.Header.Set(echo.HeaderXForwardedFor, "203.0.113.3") + if connection != "" { + req.Header.Add(echo.HeaderConnection, connection) + req.Header.Add(echo.HeaderConnection, "X-Real-IP") + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + var identity []string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &identity)) + require.Equal(t, []string{wantIP}, identity) + }) + } + } + } +} + +type proxyIdentityTransport func(*http.Request) (*http.Response, error) + +func (f proxyIdentityTransport) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +type proxyIdentitySwappingBalancer struct{ ProxyBalancer } + +func (b proxyIdentitySwappingBalancer) Next(c *echo.Context) (*ProxyTarget, error) { + req := c.Request().Clone(c.Request().Context()) + req.Header.Set(echo.HeaderXRealIP, "spoofed-by-replacement") + c.SetRequest(req) + return b.ProxyBalancer.Next(c) +} + +func TestProxyIdentityCapturedAcrossRetries(t *testing.T) { + e := echo.New() + extractions := 0 + e.IPExtractor = func(*http.Request) string { + extractions++ + return fmt.Sprintf("derived-%d", extractions) + } + target, err := url.Parse("http://upstream.invalid") + require.NoError(t, err) + attempts := 0 + e.Use(ProxyWithConfig(ProxyConfig{ + Balancer: proxyIdentitySwappingBalancer{NewRoundRobinBalancer([]*ProxyTarget{{URL: target}})}, + RetryCount: 2, + Transport: proxyIdentityTransport(func(r *http.Request) (*http.Response, error) { + attempts++ + require.Equal(t, []string{"derived-1"}, r.Header.Values(echo.HeaderXRealIP)) + if attempts < 3 { + return nil, errors.New("retry this attempt") + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("ok")), ContentLength: 2}, nil + }), + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(echo.HeaderConnection, "X-Real-IP") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, 3, attempts) + require.Equal(t, 1, extractions) +} + +func TestProxyIdentityWebSocketConnectionHeader(t *testing.T) { + received := make(chan []string, 1) + upstream := httptest.NewServer(websocket.Handler(func(conn *websocket.Conn) { + defer conn.Close() + received <- conn.Request().Header.Values(echo.HeaderXRealIP) + _ = websocket.Message.Send(conn, "ok") + })) + defer upstream.Close() + target, err := url.Parse(upstream.URL) + require.NoError(t, err) + target.Scheme = "ws" + e := echo.New() + e.Use(Proxy(NewRoundRobinBalancer([]*ProxyTarget{{URL: target}}))) + server := httptest.NewServer(e) + defer server.Close() + conn, err := net.Dial("tcp", server.Listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + require.NoError(t, conn.SetDeadline(time.Now().Add(3*time.Second))) + _, err = fmt.Fprint(conn, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade, X-Real-IP\r\nUpgrade: websocket\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nOrigin: http://localhost/\r\nX-Real-IP: 203.0.113.1\r\nX-Forwarded-For: 203.0.113.2\r\n\r\n") + require.NoError(t, err) + response, err := http.ReadResponse(bufio.NewReader(conn), nil) + require.NoError(t, err) + defer response.Body.Close() + require.Equal(t, http.StatusSwitchingProtocols, response.StatusCode) + select { + case identity := <-received: + require.Equal(t, []string{"127.0.0.1"}, identity) + case <-time.After(3 * time.Second): + t.Fatal("upstream did not receive the upgrade") + } +}