diff --git a/context.go b/context.go index f5dd5a69d..fa0fc94a9 100644 --- a/context.go +++ b/context.go @@ -45,6 +45,10 @@ type Context interface { // The behavior can be configured using `Echo#IPExtractor`. RealIP() string + // AnonymizedIP returns the client's network address with anonymization applied. + // It uses RealIP() to get the original IP address and then applies anonymization. + AnonymizedIP() string + // Path returns the registered path for the handler. Path() string @@ -317,6 +321,32 @@ func (c *context) RealIP() string { return ra } +func (c *context) AnonymizedIP() string { + ip := c.RealIP() + + if ip == "" { + return "" // safeguard + } + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return ip // safeguard, not an ip + } + + // IPv4 + if parsedIP.To4() != nil { + ipParts := strings.Split(parsedIP.String(), ".") + if len(ipParts) == 4 { + ipParts[3] = "0" + return strings.Join(ipParts, ".") + } + } + + // IPv6 + ipParts := strings.Split(parsedIP.String(), ":") + ipParts[len(ipParts)-1] = "0" + return strings.Join(ipParts, ":") +} + func (c *context) Path() string { return c.path } diff --git a/context_test.go b/context_test.go index 1fd89edb4..93bd9a184 100644 --- a/context_test.go +++ b/context_test.go @@ -1121,3 +1121,92 @@ func TestContext_RealIP(t *testing.T) { assert.Equal(t, tt.s, tt.c.RealIP()) } } + +func TestContext_AnonymizedIP(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.0", + }, + { + &context{ + request: &http.Request{ + Header: http.Header{HeaderXForwardedFor: []string{"127.0.0.1,127.0.1.1"}}, + }, + }, + "127.0.0.0", + }, + { + &context{ + request: &http.Request{ + Header: http.Header{HeaderXForwardedFor: []string{"127.0.0.1"}}, + }, + }, + "127.0.0.0", + }, + { + &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:0", + }, + { + &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:0", + }, + { + &context{ + request: &http.Request{ + Header: http.Header{HeaderXForwardedFor: []string{"2001:db8:85a3:8d3:1319:8a2e:370:7348"}}, + }, + }, + "2001:db8:85a3:8d3:1319:8a2e:370:0", + }, + { + &context{ + request: &http.Request{ + Header: http.Header{ + "X-Real-Ip": []string{"192.168.0.1"}, + }, + }, + }, + "192.168.0.0", + }, + { + &context{ + request: &http.Request{ + Header: http.Header{ + "X-Real-Ip": []string{"[2001:db8::1]"}, + }, + }, + }, + "2001:db8::0", + }, + + { + &context{ + request: &http.Request{ + RemoteAddr: "89.89.89.89:1654", + }, + }, + "89.89.89.0", + }, + } + + for _, tt := range tests { + assert.Equal(t, tt.s, tt.c.AnonymizedIP()) + } +} diff --git a/middleware/logger.go b/middleware/logger.go index 910fce8cf..5ace7f5c9 100644 --- a/middleware/logger.go +++ b/middleware/logger.go @@ -33,6 +33,7 @@ type LoggerConfig struct { // - time_custom // - id (Request ID) // - remote_ip + // - remote_ip_anon (Anonymized remote IP address, e.g. "1.2.3.0") // - uri // - host // - method @@ -161,6 +162,8 @@ func LoggerWithConfig(config LoggerConfig) echo.MiddlewareFunc { return buf.WriteString(id) case "remote_ip": return buf.WriteString(c.RealIP()) + case "remote_ip_anon": + return buf.WriteString(c.AnonymizedIP()) case "host": return buf.WriteString(req.Host) case "uri": diff --git a/middleware/logger_test.go b/middleware/logger_test.go index d5236e1ac..319c91fb3 100644 --- a/middleware/logger_test.go +++ b/middleware/logger_test.go @@ -93,7 +93,7 @@ func TestLoggerTemplate(t *testing.T) { e := echo.New() e.Use(LoggerWithConfig(LoggerConfig{ - Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","user_agent":"${user_agent}",` + + Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","remote_ip_anon":"${remote_ip_anon}","host":"${host}","user_agent":"${user_agent}",` + `"method":"${method}","uri":"${uri}","status":${status}, "latency":${latency},` + `"latency_human":"${latency_human}","bytes_in":${bytes_in}, "path":"${path}", "route":"${route}", "referer":"${referer}",` + `"bytes_out":${bytes_out},"ch":"${header:X-Custom-Header}", "protocol":"${protocol}"` + @@ -151,7 +151,7 @@ func TestLoggerCustomTimestamp(t *testing.T) { customTimeFormat := "2006-01-02 15:04:05.00000" e := echo.New() e.Use(LoggerWithConfig(LoggerConfig{ - Format: `{"time":"${time_custom}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","user_agent":"${user_agent}",` + + Format: `{"time":"${time_custom}","id":"${id}","remote_ip":"${remote_ip}","remote_ip_anon":"${remote_ip_anon}","host":"${host}","user_agent":"${user_agent}",` + `"method":"${method}","uri":"${uri}","status":${status}, "latency":${latency},` + `"latency_human":"${latency_human}","bytes_in":${bytes_in}, "path":"${path}", "referer":"${referer}",` + `"bytes_out":${bytes_out},"ch":"${header:X-Custom-Header}",` + @@ -204,7 +204,7 @@ func BenchmarkLoggerWithConfig_withoutMapFields(b *testing.B) { buf := new(bytes.Buffer) mw := LoggerWithConfig(LoggerConfig{ - Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","user_agent":"${user_agent}",` + + Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","remote_ip_anon":"${remote_ip_anon}","host":"${host}","user_agent":"${user_agent}",` + `"method":"${method}","uri":"${uri}","status":${status}, "latency":${latency},` + `"latency_human":"${latency_human}","bytes_in":${bytes_in}, "path":"${path}", "referer":"${referer}",` + `"bytes_out":${bytes_out}, "protocol":"${protocol}"}` + "\n", @@ -240,7 +240,7 @@ func BenchmarkLoggerWithConfig_withMapFields(b *testing.B) { buf := new(bytes.Buffer) mw := LoggerWithConfig(LoggerConfig{ - Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","user_agent":"${user_agent}",` + + Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","remote_ip_anon":"${remote_ip_anon}","host":"${host}","user_agent":"${user_agent}",` + `"method":"${method}","uri":"${uri}","status":${status}, "latency":${latency},` + `"latency_human":"${latency_human}","bytes_in":${bytes_in}, "path":"${path}", "referer":"${referer}",` + `"bytes_out":${bytes_out},"ch":"${header:X-Custom-Header}", "protocol":"${protocol}"` + diff --git a/middleware/request_logger.go b/middleware/request_logger.go index 7c18200b0..7b6b14041 100644 --- a/middleware/request_logger.go +++ b/middleware/request_logger.go @@ -147,6 +147,8 @@ type RequestLoggerConfig struct { LogProtocol bool // LogRemoteIP instructs logger to extract request remote IP. See `echo.Context.RealIP()` for implementation details. LogRemoteIP bool + // AnonymizeRemoteIP instructs logger to anonymize remote IP. See `echo.Context.AnonymizedIP()` for implementation details. + AnonymizeRemoteIP bool // LogHost instructs logger to extract request host value (i.e. `example.com`) LogHost bool // LogMethod instructs logger to extract request method value (i.e. `GET` etc) @@ -298,7 +300,11 @@ func (config RequestLoggerConfig) ToMiddleware() (echo.MiddlewareFunc, error) { v.Protocol = req.Proto } if config.LogRemoteIP { - v.RemoteIP = c.RealIP() + if config.AnonymizeRemoteIP { + v.RemoteIP = c.AnonymizedIP() + } else { + v.RemoteIP = c.RealIP() + } } if config.LogHost { v.Host = req.Host diff --git a/middleware/request_logger_test.go b/middleware/request_logger_test.go index c612f5c22..94edaf741 100644 --- a/middleware/request_logger_test.go +++ b/middleware/request_logger_test.go @@ -4,8 +4,6 @@ package middleware import ( - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" "net/http" "net/http/httptest" "net/url" @@ -13,6 +11,9 @@ import ( "strings" "testing" "time" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" ) func TestRequestLoggerWithConfig(t *testing.T) { @@ -292,24 +293,25 @@ func TestRequestLogger_allFields(t *testing.T) { expect = values return nil }, - LogLatency: true, - LogProtocol: true, - LogRemoteIP: true, - LogHost: true, - LogMethod: true, - LogURI: true, - LogURIPath: true, - LogRoutePath: true, - LogRequestID: true, - LogReferer: true, - LogUserAgent: true, - LogStatus: true, - LogError: true, - LogContentLength: true, - LogResponseSize: true, - LogHeaders: []string{"accept-encoding", "User-Agent"}, - LogQueryParams: []string{"lang", "checked"}, - LogFormValues: []string{"csrf", "multiple"}, + LogLatency: true, + LogProtocol: true, + LogRemoteIP: true, + LogHost: true, + LogMethod: true, + LogURI: true, + LogURIPath: true, + LogRoutePath: true, + LogRequestID: true, + LogReferer: true, + LogUserAgent: true, + LogStatus: true, + LogError: true, + LogContentLength: true, + LogResponseSize: true, + LogHeaders: []string{"accept-encoding", "User-Agent"}, + LogQueryParams: []string{"lang", "checked"}, + LogFormValues: []string{"csrf", "multiple"}, + AnonymizeRemoteIP: true, timeNow: func() time.Time { if isFirstNowCall { isFirstNowCall = false @@ -346,7 +348,7 @@ func TestRequestLogger_allFields(t *testing.T) { assert.Equal(t, time.Unix(1631045377, 0), expect.StartTime) assert.Equal(t, 10*time.Second, expect.Latency) assert.Equal(t, "HTTP/1.1", expect.Protocol) - assert.Equal(t, "8.8.8.8", expect.RemoteIP) + assert.Equal(t, "8.8.8.0", expect.RemoteIP) assert.Equal(t, "example.com", expect.Host) assert.Equal(t, http.MethodPost, expect.Method) assert.Equal(t, "/test?lang=en&checked=1&checked=2", expect.URI)