Skip to content

Commit c192e50

Browse files
authored
Merge pull request #2933 from aldas/LegacyIPExtractor
Remove legacy IP extraction logic from context.RealIP method
2 parents 83e04d2 + 22e4b71 commit c192e50

File tree

5 files changed

+168
-102
lines changed

5 files changed

+168
-102
lines changed

context.go

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -180,29 +180,28 @@ func (c *Context) Scheme() string {
180180
return "http"
181181
}
182182

183-
// RealIP returns the client's network address based on `X-Forwarded-For`
184-
// or `X-Real-IP` request header.
185-
// The behavior can be configured using `Echo#IPExtractor`.
183+
// RealIP returns the client IP address using the configured extraction strategy.
184+
//
185+
// If Echo#IPExtractor is set, it is used to resolve the client IP from the incoming request (typically via proxy
186+
// headers such as X-Forwarded-For or X-Real-IP).
187+
// Look into the `ip.go` file for comments and examples.
188+
//
189+
// See:
190+
// - Echo#ExtractIPFromXFFHeader for `X-Forwarded-For` handling with trust checks
191+
// - Echo#ExtractIPFromRealIPHeader for `X-Real-IP` handling with trust checks
192+
// - Echo#LegacyIPExtractor for `v4` compatibility (spoofable, no trust checks built in)
193+
//
194+
// If no extractor is configured, RealIP falls back to the request RemoteAddr, returning only the host portion.
195+
//
196+
// Notes:
197+
// - No validation or trust enforcement is performed unless implemented by the configured IPExtractor.
198+
// - When relying on proxy headers, ensure the application is deployed behind trusted intermediaries to avoid spoofing.
186199
func (c *Context) RealIP() string {
187200
if c.echo != nil && c.echo.IPExtractor != nil {
188201
return c.echo.IPExtractor(c.request)
189202
}
190-
// Fall back to legacy behavior
191-
if ip := c.request.Header.Get(HeaderXForwardedFor); ip != "" {
192-
i := strings.IndexAny(ip, ",")
193-
if i > 0 {
194-
xffip := strings.TrimSpace(ip[:i])
195-
xffip = strings.TrimPrefix(xffip, "[")
196-
xffip = strings.TrimSuffix(xffip, "]")
197-
return xffip
198-
}
199-
return ip
200-
}
201-
if ip := c.request.Header.Get(HeaderXRealIP); ip != "" {
202-
ip = strings.TrimPrefix(ip, "[")
203-
ip = strings.TrimSuffix(ip, "]")
204-
return ip
205-
}
203+
// req.RemoteAddr is the IP address of the remote end of the connection, which may be a proxy. It is populated by the
204+
// http.conn.readRequest() method and uses net.Conn.RemoteAddr().String() which we trust.
206205
ra, _, _ := net.SplitHostPort(c.request.RemoteAddr)
207206
return ra
208207
}

context_test.go

Lines changed: 29 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"log/slog"
1515
"math"
1616
"mime/multipart"
17+
"net"
1718
"net/http"
1819
"net/http/httptest"
1920
"net/url"
@@ -1220,91 +1221,42 @@ func TestContext_Bind(t *testing.T) {
12201221
}
12211222

12221223
func TestContext_RealIP(t *testing.T) {
1223-
tests := []struct {
1224-
c *Context
1225-
s string
1224+
_, ipv6ForRemoteAddrExternalRange, _ := net.ParseCIDR("2001:db8::/64")
1225+
1226+
var testCases = []struct {
1227+
name string
1228+
givenIPExtrator IPExtractor
1229+
whenReq *http.Request
1230+
expect string
12261231
}{
12271232
{
1228-
&Context{
1229-
request: &http.Request{
1230-
Header: http.Header{HeaderXForwardedFor: []string{"127.0.0.1, 127.0.1.1, "}},
1231-
},
1232-
},
1233-
"127.0.0.1",
1233+
name: "ip from remote addr",
1234+
givenIPExtrator: nil,
1235+
whenReq: &http.Request{RemoteAddr: "89.89.89.89:1654"},
1236+
expect: "89.89.89.89",
12341237
},
12351238
{
1236-
&Context{
1237-
request: &http.Request{
1238-
Header: http.Header{HeaderXForwardedFor: []string{"127.0.0.1,127.0.1.1"}},
1239-
},
1240-
},
1241-
"127.0.0.1",
1242-
},
1243-
{
1244-
&Context{
1245-
request: &http.Request{
1246-
Header: http.Header{HeaderXForwardedFor: []string{"127.0.0.1"}},
1239+
name: "ip from ip extractor",
1240+
givenIPExtrator: ExtractIPFromRealIPHeader(TrustIPRange(ipv6ForRemoteAddrExternalRange)),
1241+
whenReq: &http.Request{
1242+
Header: http.Header{
1243+
HeaderXRealIP: []string{"[2001:db8::113:199]"},
1244+
HeaderXForwardedFor: []string{"[2001:db8::113:198], [2001:db8::113:197]"}, // <-- should not affect anything
12471245
},
1246+
RemoteAddr: "[2001:db8::113:1]:8080",
12481247
},
1249-
"127.0.0.1",
1250-
},
1251-
{
1252-
&Context{
1253-
request: &http.Request{
1254-
Header: http.Header{HeaderXForwardedFor: []string{"[2001:db8:85a3:8d3:1319:8a2e:370:7348], 2001:db8::1, "}},
1255-
},
1256-
},
1257-
"2001:db8:85a3:8d3:1319:8a2e:370:7348",
1258-
},
1259-
{
1260-
&Context{
1261-
request: &http.Request{
1262-
Header: http.Header{HeaderXForwardedFor: []string{"[2001:db8:85a3:8d3:1319:8a2e:370:7348],[2001:db8::1]"}},
1263-
},
1264-
},
1265-
"2001:db8:85a3:8d3:1319:8a2e:370:7348",
1266-
},
1267-
{
1268-
&Context{
1269-
request: &http.Request{
1270-
Header: http.Header{HeaderXForwardedFor: []string{"2001:db8:85a3:8d3:1319:8a2e:370:7348"}},
1271-
},
1272-
},
1273-
"2001:db8:85a3:8d3:1319:8a2e:370:7348",
1274-
},
1275-
{
1276-
&Context{
1277-
request: &http.Request{
1278-
Header: http.Header{
1279-
"X-Real-Ip": []string{"192.168.0.1"},
1280-
},
1281-
},
1282-
},
1283-
"192.168.0.1",
1284-
},
1285-
{
1286-
&Context{
1287-
request: &http.Request{
1288-
Header: http.Header{
1289-
"X-Real-Ip": []string{"[2001:db8::1]"},
1290-
},
1291-
},
1292-
},
1293-
"2001:db8::1",
1294-
},
1295-
1296-
{
1297-
&Context{
1298-
request: &http.Request{
1299-
RemoteAddr: "89.89.89.89:1654",
1300-
},
1301-
},
1302-
"89.89.89.89",
1248+
expect: "2001:db8::113:199",
13031249
},
13041250
}
1305-
1306-
for _, tt := range tests {
1307-
assert.Equal(t, tt.s, tt.c.RealIP())
1251+
for _, tc := range testCases {
1252+
t.Run(tc.name, func(t *testing.T) {
1253+
e := New()
1254+
c := e.NewContext(tc.whenReq, nil)
1255+
if tc.givenIPExtrator != nil {
1256+
e.IPExtractor = tc.givenIPExtrator
1257+
}
1258+
assert.Equal(t, tc.expect, c.RealIP())
1259+
})
13081260
}
13091261
}
13101262

ip.go

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,8 @@ func (c *ipChecker) trust(ip net.IP) bool {
202202
// See https://echo.labstack.com/guide/ip-address for more details.
203203
type IPExtractor func(*http.Request) string
204204

205-
// ExtractIPDirect extracts IP address using actual IP address.
206-
// Use this if your server faces to internet directory (i.e.: uses no proxy).
205+
// ExtractIPDirect extracts an IP address using an actual IP address.
206+
// Use this if your server faces to internet directly (i.e.: uses no proxy).
207207
func ExtractIPDirect() IPExtractor {
208208
return extractIP
209209
}
@@ -219,7 +219,7 @@ func extractIP(req *http.Request) string {
219219
return host
220220
}
221221

222-
// ExtractIPFromRealIPHeader extracts IP address using x-real-ip header.
222+
// ExtractIPFromRealIPHeader extracts IP address using `x-real-ip` header.
223223
// Use this if you put proxy which uses this header.
224224
func ExtractIPFromRealIPHeader(options ...TrustOption) IPExtractor {
225225
checker := newIPChecker(options)
@@ -236,7 +236,7 @@ func ExtractIPFromRealIPHeader(options ...TrustOption) IPExtractor {
236236
}
237237
}
238238

239-
// ExtractIPFromXFFHeader extracts IP address using x-forwarded-for header.
239+
// ExtractIPFromXFFHeader extracts IP address using `x-forwarded-for` header.
240240
// Use this if you put proxy which uses this header.
241241
// This returns nearest untrustable IP. If all IPs are trustable, returns furthest one (i.e.: XFF[0]).
242242
func ExtractIPFromXFFHeader(options ...TrustOption) IPExtractor {
@@ -265,3 +265,45 @@ func ExtractIPFromXFFHeader(options ...TrustOption) IPExtractor {
265265
return strings.TrimSpace(ips[0])
266266
}
267267
}
268+
269+
// LegacyIPExtractor returns an IPExtractor that derives the client IP address
270+
// from common proxy headers, falling back to the request's remote address.
271+
//
272+
// Resolution order:
273+
// 1. X-Forwarded-For: returns the first IP in the comma-separated list.
274+
// If multiple values are present, only the left-most (original client)
275+
// is used. Surrounding brackets (for IPv6) are stripped.
276+
// 2. X-Real-IP: used if X-Forwarded-For is absent. Surrounding brackets
277+
// (for IPv6) are stripped.
278+
// 3. req.RemoteAddr: used as a fallback; the host portion is extracted
279+
// via net.SplitHostPort.
280+
//
281+
// Notes:
282+
// - No validation is performed on header values.
283+
// - This function trusts headers as-is and is therefore not safe against
284+
// spoofing unless the application is behind a trusted proxy that is
285+
// configured to strip/replace/modify headers correctly.
286+
//
287+
// Use ExtractIPFromXFFHeader or ExtractIPFromRealIPHeader instead of LegacyIPExtractor.
288+
func LegacyIPExtractor() IPExtractor {
289+
return legacyIPExtractor
290+
}
291+
292+
func legacyIPExtractor(req *http.Request) string {
293+
if ip := req.Header.Get(HeaderXForwardedFor); ip != "" {
294+
i := strings.IndexAny(ip, ",")
295+
if i > 0 {
296+
ip = strings.TrimSpace(ip[:i])
297+
}
298+
ip = strings.TrimPrefix(ip, "[")
299+
ip = strings.TrimSuffix(ip, "]")
300+
return ip
301+
}
302+
if ip := req.Header.Get(HeaderXRealIP); ip != "" {
303+
ip = strings.TrimPrefix(ip, "[")
304+
ip = strings.TrimSuffix(ip, "]")
305+
return ip
306+
}
307+
ra, _, _ := net.SplitHostPort(req.RemoteAddr)
308+
return ra
309+
}

ip_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,3 +714,75 @@ func TestExtractIPFromXFFHeader(t *testing.T) {
714714
})
715715
}
716716
}
717+
718+
func TestLegacyIPExtractor(t *testing.T) {
719+
var testCases = []struct {
720+
name string
721+
whenReq *http.Request
722+
expect string
723+
expectedError string
724+
}{
725+
{
726+
name: "extract first ip from X-Forwarded-For",
727+
whenReq: &http.Request{Header: http.Header{"X-Forwarded-For": []string{"203.0.113.10, 198.51.100.7"}}},
728+
expect: "203.0.113.10",
729+
},
730+
{
731+
name: "extract single ip from X-Forwarded-For",
732+
whenReq: &http.Request{Header: http.Header{"X-Forwarded-For": []string{"203.0.113.10"}}},
733+
expect: "203.0.113.10",
734+
},
735+
{
736+
name: "trim brackets from ipv6 in X-Forwarded-For when multiple values",
737+
whenReq: &http.Request{Header: http.Header{"X-Forwarded-For": []string{"[2001:db8::1], 198.51.100.7"}}},
738+
expect: "2001:db8::1",
739+
},
740+
{
741+
name: "prefer X-Forwarded-For over X-Real-Ip",
742+
whenReq: &http.Request{
743+
Header: http.Header{
744+
"X-Forwarded-For": []string{"203.0.113.10"},
745+
"X-Real-Ip": []string{"198.51.100.7"},
746+
},
747+
},
748+
expect: "203.0.113.10",
749+
},
750+
{
751+
name: "extract from X-Real-Ip",
752+
whenReq: &http.Request{Header: http.Header{"X-Real-Ip": []string{"[2001:db8::1]"}}},
753+
expect: "2001:db8::1",
754+
},
755+
{
756+
name: "extract plain ipv4 from X-Real-Ip",
757+
whenReq: &http.Request{Header: http.Header{"X-Real-Ip": []string{"203.0.113.10"}}},
758+
expect: "203.0.113.10",
759+
},
760+
{
761+
name: "fallback to RemoteAddr host",
762+
whenReq: &http.Request{RemoteAddr: "203.0.113.10:12345"},
763+
expect: "203.0.113.10",
764+
},
765+
{
766+
name: "fallback to RemoteAddr ipv6 host",
767+
whenReq: &http.Request{RemoteAddr: "[2001:db8::1]:12345"},
768+
expect: "2001:db8::1",
769+
},
770+
{
771+
name: "returns empty string when RemoteAddr is invalid and no headers exist",
772+
whenReq: &http.Request{RemoteAddr: "not-a-host-port"},
773+
expect: "",
774+
},
775+
{
776+
name: "trim brackets from single ipv6 in X-Forwarded-For",
777+
whenReq: &http.Request{Header: http.Header{"X-Forwarded-For": []string{"[2001:db8::1]"}}},
778+
expect: "2001:db8::1",
779+
},
780+
}
781+
782+
for _, tc := range testCases {
783+
t.Run(tc.name, func(t *testing.T) {
784+
ip := LegacyIPExtractor()(tc.whenReq)
785+
assert.Equal(t, tc.expect, ip)
786+
})
787+
}
788+
}

middleware/request_logger_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func TestRequestLoggerOK(t *testing.T) {
2727
})
2828

2929
e := echo.New()
30+
e.IPExtractor = echo.LegacyIPExtractor()
3031
buf := new(bytes.Buffer)
3132
e.Logger = slog.New(slog.NewJSONHandler(buf, nil))
3233
e.Use(RequestLogger())
@@ -439,7 +440,7 @@ func TestRequestLogger_allFields(t *testing.T) {
439440
assert.Equal(t, time.Unix(1631045377, 0), expect.StartTime)
440441
assert.Equal(t, 10*time.Second, expect.Latency)
441442
assert.Equal(t, "HTTP/1.1", expect.Protocol)
442-
assert.Equal(t, "8.8.8.8", expect.RemoteIP)
443+
assert.Equal(t, "192.0.2.1", expect.RemoteIP)
443444
assert.Equal(t, "example.com", expect.Host)
444445
assert.Equal(t, http.MethodPost, expect.Method)
445446
assert.Equal(t, "/test?lang=en&checked=1&checked=2", expect.URI)
@@ -530,7 +531,7 @@ func TestTestRequestLogger(t *testing.T) {
530531
assert.Contains(t, string(rawlog), `"uri":"/test?lang=en&checked=1&checked=2"`)
531532
assert.Contains(t, string(rawlog), `"latency":`) // this value varies
532533
assert.Contains(t, string(rawlog), `"request_id":"MY_ID"`)
533-
assert.Contains(t, string(rawlog), `"remote_ip":"8.8.8.8"`)
534+
assert.Contains(t, string(rawlog), `"remote_ip":"192.0.2.1"`)
534535
assert.Contains(t, string(rawlog), `"host":"example.com"`)
535536
assert.Contains(t, string(rawlog), `"user_agent":"curl/7.68.0"`)
536537
assert.Contains(t, string(rawlog), `"bytes_in":"32"`)

0 commit comments

Comments
 (0)