Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,27 @@ Echo v4.16.1 and later require Go 1.26 or later to use patched `golang.org/x/` d

Echo v4 receives bug fixes until 2026-12-31 and free security fixes until 2027-12-31, on its latest release. New projects should use [v5](https://github.com/labstack/echo). See [SECURITY.md](https://github.com/labstack/echo/blob/master/SECURITY.md) for the full policy.

### Client IP security migration (next release)

The next v4 security release also changes client IP defaults: without `Echo#IPExtractor`, `Context.RealIP()` uses the connection peer and ignores `X-Forwarded-For` and `X-Real-IP`. Proxy middleware always replaces upstream `X-Real-IP` with this result. This fixes CVE-2026-118651 and CVE-2026-118653.

**Migration for applications behind proxies:** configure `Echo#IPExtractor` before starting the server. Otherwise, clients behind the same proxy share its IP address for rate limiting, access controls, and logging. Use `echo.ExtractIPDirect()` when there is no proxy. For XFF-based proxies, disable the broad default trust ranges and trust only your proxy addresses:

```go
_, proxyRange, err := net.ParseCIDR("10.0.0.10/32") // replace with your proxy's address or CIDR
if err != nil {
log.Fatal(err)
}
e.IPExtractor = echo.ExtractIPFromXFFHeader(
echo.TrustLoopback(false),
echo.TrustLinkLocal(false),
echo.TrustPrivateNet(false),
echo.TrustIPRange(proxyRange),
)
```

If your proxy uses `X-Real-IP`, use `echo.ExtractIPFromRealIPHeader` with the same trust options. Configure the outermost proxy to replace client-supplied identity headers. Explicitly configured extractors and custom rate-limit identifiers retain their behavior.

### Security defaults since v4.16.1

`BodyDump` captures at most 5 MiB of the request and 5 MiB of the response by default. The application and client still receive the complete bodies. Configure `BodyDumpConfig.MaxRequestBytes` and `MaxResponseBytes` to change the capture limits; `-1` disables a limit.
Expand Down
52 changes: 52 additions & 0 deletions client_ip_security_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors

package echo

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
)

func TestContextRealIPIgnoresUntrustedHeaders(t *testing.T) {
for _, remote := range []struct {
address string
want string
}{
{"198.51.100.10:1234", "198.51.100.10"},
{"[2001:db8::10]:1234", "2001:db8::10"},
{"198.51.100.10", "198.51.100.10"},
{"2001:db8::10", "2001:db8::10"},
{"invalid", ""},
} {
t.Run(remote.address, func(t *testing.T) {
for _, headers := range []http.Header{
{},
{HeaderXForwardedFor: {"203.0.113.1"}},
{HeaderXForwardedFor: {"203.0.113.1, 203.0.113.2", "203.0.113.3"}},
{HeaderXRealIP: {"203.0.113.1"}},
{HeaderXRealIP: {"[2001:db8::1]"}},
{HeaderXForwardedFor: {"invalid"}, HeaderXRealIP: {"203.0.113.1"}},
} {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remote.address
req.Header = headers
assert.Equal(t, remote.want, New().NewContext(req, httptest.NewRecorder()).RealIP())
assert.Equal(t, remote.want, (&context{request: req}).RealIP())
}
})
}
}

func TestContextRealIPPreservesCustomExtractor(t *testing.T) {
e := New()
e.IPExtractor = func(r *http.Request) string {
return r.Header.Get("Custom-Client-IP")
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Custom-Client-IP", "203.0.113.10")
assert.Equal(t, "203.0.113.10", e.NewContext(req, httptest.NewRecorder()).RealIP())
}
28 changes: 6 additions & 22 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/url"
"strings"
Expand Down Expand Up @@ -40,9 +39,10 @@ type Context interface {
// Scheme returns the HTTP protocol scheme, `http` or `https`.
Scheme() string

// RealIP returns the client's network address based on `X-Forwarded-For`
// or `X-Real-IP` request header.
// The behavior can be configured using `Echo#IPExtractor`.
// RealIP returns the client IP from Echo#IPExtractor, or the connection peer
// when no extractor is configured. Forwarding headers are ignored by default.
// Applications behind proxies must configure an extractor that trusts only
// their own proxies to recover individual client addresses.
RealIP() string

// Path returns the registered path for the handler.
Expand Down Expand Up @@ -299,24 +299,8 @@ func (c *context) RealIP() string {
if c.echo != nil && c.echo.IPExtractor != nil {
return c.echo.IPExtractor(c.request)
}
// Fall back to legacy behavior
if ip := c.request.Header.Get(HeaderXForwardedFor); ip != "" {
i := strings.IndexAny(ip, ",")
if i > 0 {
xffip := strings.TrimSpace(ip[:i])
xffip = strings.TrimPrefix(xffip, "[")
xffip = strings.TrimSuffix(xffip, "]")
return xffip
}
return ip
}
if ip := c.request.Header.Get(HeaderXRealIP); ip != "" {
ip = strings.TrimPrefix(ip, "[")
ip = strings.TrimSuffix(ip, "]")
return ip
}
ra, _, _ := net.SplitHostPort(c.request.RemoteAddr)
return ra
// Forwarding headers are client-controlled unless a trusted proxy extractor is configured.
return extractIP(c.request)
}

func (c *context) Path() string {
Expand Down
96 changes: 11 additions & 85 deletions context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1293,90 +1293,16 @@ func TestContext_Logger(t *testing.T) {
}

func TestContext_RealIP(t *testing.T) {
tests := []struct {
c Context
s string
}{
{
&context{
request: &http.Request{
Header: http.Header{HeaderXForwardedFor: []string{"127.0.0.1, 127.0.1.1, "}},
},
},
"127.0.0.1",
},
{
&context{
request: &http.Request{
Header: http.Header{HeaderXForwardedFor: []string{"127.0.0.1,127.0.1.1"}},
},
},
"127.0.0.1",
},
{
&context{
request: &http.Request{
Header: http.Header{HeaderXForwardedFor: []string{"127.0.0.1"}},
},
},
"127.0.0.1",
},
{
&context{
request: &http.Request{
Header: http.Header{HeaderXForwardedFor: []string{"[2001:db8:85a3:8d3:1319:8a2e:370:7348], 2001:db8::1, "}},
},
},
"2001:db8:85a3:8d3:1319:8a2e:370:7348",
},
{
&context{
request: &http.Request{
Header: http.Header{HeaderXForwardedFor: []string{"[2001:db8:85a3:8d3:1319:8a2e:370:7348],[2001:db8::1]"}},
},
},
"2001:db8:85a3:8d3:1319:8a2e:370:7348",
},
{
&context{
request: &http.Request{
Header: http.Header{HeaderXForwardedFor: []string{"2001:db8:85a3:8d3:1319:8a2e:370:7348"}},
},
},
"2001:db8:85a3:8d3:1319:8a2e:370:7348",
},
{
&context{
request: &http.Request{
Header: http.Header{
"X-Real-Ip": []string{"192.168.0.1"},
},
},
},
"192.168.0.1",
},
{
&context{
request: &http.Request{
Header: http.Header{
"X-Real-Ip": []string{"[2001:db8::1]"},
},
},
},
"2001:db8::1",
},

{
&context{
request: &http.Request{
RemoteAddr: "89.89.89.89:1654",
},
},
"89.89.89.89",
},
}

for _, tt := range tests {
assert.Equal(t, tt.s, tt.c.RealIP())
for _, header := range []http.Header{
{HeaderXForwardedFor: {"127.0.0.1, 127.0.1.1, "}},
{HeaderXForwardedFor: {"127.0.0.1,127.0.1.1"}},
{HeaderXForwardedFor: {"127.0.0.1"}},
{HeaderXForwardedFor: {"[2001:db8::1], 2001:db8::2"}},
{HeaderXRealIP: {"192.168.0.1"}},
{HeaderXRealIP: {"[2001:db8::1]"}},
{},
} {
c := &context{request: &http.Request{Header: header, RemoteAddr: "89.89.89.89:1654"}}
assert.Equal(t, "89.89.89.89", c.RealIP())
}
}
5 changes: 4 additions & 1 deletion echo.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,10 @@ type Echo struct {
Validator Validator
Renderer Renderer
Logger Logger
IPExtractor IPExtractor
// IPExtractor defines how Context.RealIP determines the client IP address.
// If nil, only the connection peer is used. Applications behind a proxy must
// configure a header extractor that trusts only their own proxies.
IPExtractor IPExtractor
// SchemeExtractor defines the strategy for determining the request scheme (`http` or `https`),
// particularly important when behind proxies or load balancers that terminate TLS.
// Used by Context.Scheme and by the HTTPS redirect, Secure and Proxy middlewares.
Expand Down
11 changes: 6 additions & 5 deletions ip.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ To retrieve IP address reliably/securely, you must let your application be aware
In Echo, this can be done by configuring `Echo#IPExtractor` appropriately.
This guides show you why and how.

> Note: if you don't set `Echo#IPExtractor` explicitly, Echo fallback to legacy behavior, which is not a good choice.
> Note: if you don't set `Echo#IPExtractor` explicitly, Echo uses the connection peer and ignores forwarding headers.
> Applications behind proxies must configure a trusted proxy extractor to recover individual client addresses.

Let's start from two questions to know the right direction:

Expand Down Expand Up @@ -114,10 +115,10 @@ To control this behavior, use [`TrustOption`](https://godoc.org/github.com/labst

## About default behavior

In default behavior, Echo sees all of first XFF header, X-Real-IP header and IP from network layer.

As you might already notice, after reading this article, this is not good.
Sole reason this is default is just backward compatibility.
By default, Echo uses the connection peer from req.RemoteAddr and ignores X-Forwarded-For and X-Real-IP.
This prevents clients from choosing their own identity for rate limiting, access control, or proxy forwarding.
Behind a proxy, all clients share that proxy's address unless Echo#IPExtractor is configured.
Configure a header extractor with trust options limited to your own proxies before relying on individual client IPs.

## Private IP ranges

Expand Down
Loading
Loading