fix: address coderabbit findings on access log
- Escape \n and \r in escapeQuoted to prevent log injection via user-controlled fields (User-Agent, Referer, request target) in CLF/Combined formats - Simplify AccessLogger trustedNets parameter from variadic ...[]* to plain []*net.IPNet
- commit
- 01b6bc5268a759b9d8fc81d5d3dbee68e028b948
- author
- brice · · 2026-04-03 15:46 UTC
- parent
- be4bda7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +14 -16
M internal/adapters/in/http/middleware/accesslog.go +2 -7
| @@ -26,12 +26,7 @@ type AccessLogWriter interface { | |||
| 26 | 26 | // | |
| 27 | 27 | // Write failures are reported as warnings through the application logger and | |
| 28 | 28 | // never fail the HTTP response. | |
| 29 | - | func AccessLogger(writer AccessLogWriter, excludeHealthChecks bool, log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.Handler) http.Handler { | |
| 30 | - | var nets []*net.IPNet | |
| 31 | - | if len(trustedNets) > 0 { | |
| 32 | - | nets = trustedNets[0] | |
| 33 | - | } | |
| 34 | - | ||
| 29 | + | func AccessLogger(writer AccessLogWriter, excludeHealthChecks bool, log zerowrap.Logger, trustedNets []*net.IPNet) func(http.Handler) http.Handler { | |
| 35 | 30 | return func(next http.Handler) http.Handler { | |
| 36 | 31 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 37 | 32 | start := time.Now() | |
| @@ -45,7 +40,7 @@ func AccessLogger(writer AccessLogWriter, excludeHealthChecks bool, log zerowrap | |||
| 45 | 40 | ||
| 46 | 41 | // Resolve client IP before calling the inner handler so we see the | |
| 47 | 42 | // original remote address before any mutation. | |
| 48 | - | clientIP := GetClientIP(r, nets) | |
| 43 | + | clientIP := GetClientIP(r, trustedNets) | |
| 49 | 44 | ||
| 50 | 45 | next.ServeHTTP(rw, r) | |
| 51 | 46 | ||
| @@ -26,12 +26,7 @@ type AccessLogWriter interface { | |||||
| 26 | // | 26 | // | ||
| 27 | // Write failures are reported as warnings through the application logger and | 27 | // Write failures are reported as warnings through the application logger and | ||
| 28 | // never fail the HTTP response. | 28 | // never fail the HTTP response. | ||
| 29 | - | func AccessLogger(writer AccessLogWriter, excludeHealthChecks bool, log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.Handler) http.Handler { | 29 | + | func AccessLogger(writer AccessLogWriter, excludeHealthChecks bool, log zerowrap.Logger, trustedNets []*net.IPNet) func(http.Handler) http.Handler { |
| 30 | - | var nets []*net.IPNet | |||
| 31 | - | if len(trustedNets) > 0 { | |||
| 32 | - | nets = trustedNets[0] | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | return func(next http.Handler) http.Handler { | 30 | return func(next http.Handler) http.Handler { | ||
| 36 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 31 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 37 | start := time.Now() | 32 | start := time.Now() | ||
| @@ -45,7 +40,7 @@ func AccessLogger(writer AccessLogWriter, excludeHealthChecks bool, log zerowrap | |||||
| 45 | 40 | ||||
| 46 | // Resolve client IP before calling the inner handler so we see the | 41 | // Resolve client IP before calling the inner handler so we see the | ||
| 47 | // original remote address before any mutation. | 42 | // original remote address before any mutation. | ||
| 48 | - | clientIP := GetClientIP(r, nets) | 43 | + | clientIP := GetClientIP(r, trustedNets) |
| 49 | 44 | ||||
| 50 | next.ServeHTTP(rw, r) | 45 | next.ServeHTTP(rw, r) | ||
| 51 | 46 | ||||
M internal/adapters/in/http/middleware/accesslog_test.go +7 -7
| @@ -27,7 +27,7 @@ func TestAccessLogger_EmitsEntryPerRequest(t *testing.T) { | |||
| 27 | 27 | mock := &mockAccessLogWriter{} | |
| 28 | 28 | log := testLogger() | |
| 29 | 29 | ||
| 30 | - | handler := AccessLogger(mock, false, log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 30 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 31 | 31 | w.WriteHeader(http.StatusOK) | |
| 32 | 32 | _, _ = w.Write([]byte("hello")) | |
| 33 | 33 | })) | |
| @@ -62,7 +62,7 @@ func TestAccessLogger_RequestIDReused(t *testing.T) { | |||
| 62 | 62 | mock := &mockAccessLogWriter{} | |
| 63 | 63 | log := testLogger() | |
| 64 | 64 | ||
| 65 | - | handler := AccessLogger(mock, false, log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 65 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 66 | 66 | w.WriteHeader(http.StatusOK) | |
| 67 | 67 | })) | |
| 68 | 68 | ||
| @@ -80,7 +80,7 @@ func TestAccessLogger_ExcludeHealthCheckUA(t *testing.T) { | |||
| 80 | 80 | mock := &mockAccessLogWriter{} | |
| 81 | 81 | log := testLogger() | |
| 82 | 82 | ||
| 83 | - | handler := AccessLogger(mock, true, log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 83 | + | handler := AccessLogger(mock, true, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 84 | 84 | w.WriteHeader(http.StatusOK) | |
| 85 | 85 | })) | |
| 86 | 86 | ||
| @@ -97,7 +97,7 @@ func TestAccessLogger_ExcludeLoopbackIP(t *testing.T) { | |||
| 97 | 97 | mock := &mockAccessLogWriter{} | |
| 98 | 98 | log := testLogger() | |
| 99 | 99 | ||
| 100 | - | handler := AccessLogger(mock, true, log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 100 | + | handler := AccessLogger(mock, true, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 101 | 101 | w.WriteHeader(http.StatusOK) | |
| 102 | 102 | })) | |
| 103 | 103 | ||
| @@ -114,7 +114,7 @@ func TestAccessLogger_NoExcludeWhenDisabled(t *testing.T) { | |||
| 114 | 114 | mock := &mockAccessLogWriter{} | |
| 115 | 115 | log := testLogger() | |
| 116 | 116 | ||
| 117 | - | handler := AccessLogger(mock, false, log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 117 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 118 | 118 | w.WriteHeader(http.StatusOK) | |
| 119 | 119 | })) | |
| 120 | 120 | ||
| @@ -131,7 +131,7 @@ func TestAccessLogger_WriteFailureDoesNotAffectResponse(t *testing.T) { | |||
| 131 | 131 | mock := &mockAccessLogWriter{err: fmt.Errorf("disk full")} | |
| 132 | 132 | log := testLogger() | |
| 133 | 133 | ||
| 134 | - | handler := AccessLogger(mock, false, log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 134 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 135 | 135 | w.WriteHeader(http.StatusOK) | |
| 136 | 136 | _, _ = w.Write([]byte("ok")) | |
| 137 | 137 | })) | |
| @@ -154,7 +154,7 @@ func TestAccessLogger_PanicRecoveryChain_Captures500(t *testing.T) { | |||
| 154 | 154 | panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 155 | 155 | panic("test panic") | |
| 156 | 156 | }) | |
| 157 | - | chain := AccessLogger(mock, false, log)(PanicRecovery(log)(panicHandler)) | |
| 157 | + | chain := AccessLogger(mock, false, log, nil)(PanicRecovery(log)(panicHandler)) | |
| 158 | 158 | ||
| 159 | 159 | req := httptest.NewRequest(http.MethodGet, "/crash", nil) | |
| 160 | 160 | rw := httptest.NewRecorder() | |
| @@ -27,7 +27,7 @@ func TestAccessLogger_EmitsEntryPerRequest(t *testing.T) { | |||||
| 27 | mock := &mockAccessLogWriter{} | 27 | mock := &mockAccessLogWriter{} | ||
| 28 | log := testLogger() | 28 | log := testLogger() | ||
| 29 | 29 | ||||
| 30 | - | handler := AccessLogger(mock, false, log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 30 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 31 | w.WriteHeader(http.StatusOK) | 31 | w.WriteHeader(http.StatusOK) | ||
| 32 | _, _ = w.Write([]byte("hello")) | 32 | _, _ = w.Write([]byte("hello")) | ||
| 33 | })) | 33 | })) | ||
| @@ -62,7 +62,7 @@ func TestAccessLogger_RequestIDReused(t *testing.T) { | |||||
| 62 | mock := &mockAccessLogWriter{} | 62 | mock := &mockAccessLogWriter{} | ||
| 63 | log := testLogger() | 63 | log := testLogger() | ||
| 64 | 64 | ||||
| 65 | - | handler := AccessLogger(mock, false, log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 65 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 66 | w.WriteHeader(http.StatusOK) | 66 | w.WriteHeader(http.StatusOK) | ||
| 67 | })) | 67 | })) | ||
| 68 | 68 | ||||
| @@ -80,7 +80,7 @@ func TestAccessLogger_ExcludeHealthCheckUA(t *testing.T) { | |||||
| 80 | mock := &mockAccessLogWriter{} | 80 | mock := &mockAccessLogWriter{} | ||
| 81 | log := testLogger() | 81 | log := testLogger() | ||
| 82 | 82 | ||||
| 83 | - | handler := AccessLogger(mock, true, log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 83 | + | handler := AccessLogger(mock, true, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 84 | w.WriteHeader(http.StatusOK) | 84 | w.WriteHeader(http.StatusOK) | ||
| 85 | })) | 85 | })) | ||
| 86 | 86 | ||||
| @@ -97,7 +97,7 @@ func TestAccessLogger_ExcludeLoopbackIP(t *testing.T) { | |||||
| 97 | mock := &mockAccessLogWriter{} | 97 | mock := &mockAccessLogWriter{} | ||
| 98 | log := testLogger() | 98 | log := testLogger() | ||
| 99 | 99 | ||||
| 100 | - | handler := AccessLogger(mock, true, log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 100 | + | handler := AccessLogger(mock, true, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 101 | w.WriteHeader(http.StatusOK) | 101 | w.WriteHeader(http.StatusOK) | ||
| 102 | })) | 102 | })) | ||
| 103 | 103 | ||||
| @@ -114,7 +114,7 @@ func TestAccessLogger_NoExcludeWhenDisabled(t *testing.T) { | |||||
| 114 | mock := &mockAccessLogWriter{} | 114 | mock := &mockAccessLogWriter{} | ||
| 115 | log := testLogger() | 115 | log := testLogger() | ||
| 116 | 116 | ||||
| 117 | - | handler := AccessLogger(mock, false, log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 117 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 118 | w.WriteHeader(http.StatusOK) | 118 | w.WriteHeader(http.StatusOK) | ||
| 119 | })) | 119 | })) | ||
| 120 | 120 | ||||
| @@ -131,7 +131,7 @@ func TestAccessLogger_WriteFailureDoesNotAffectResponse(t *testing.T) { | |||||
| 131 | mock := &mockAccessLogWriter{err: fmt.Errorf("disk full")} | 131 | mock := &mockAccessLogWriter{err: fmt.Errorf("disk full")} | ||
| 132 | log := testLogger() | 132 | log := testLogger() | ||
| 133 | 133 | ||||
| 134 | - | handler := AccessLogger(mock, false, log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 134 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 135 | w.WriteHeader(http.StatusOK) | 135 | w.WriteHeader(http.StatusOK) | ||
| 136 | _, _ = w.Write([]byte("ok")) | 136 | _, _ = w.Write([]byte("ok")) | ||
| 137 | })) | 137 | })) | ||
| @@ -154,7 +154,7 @@ func TestAccessLogger_PanicRecoveryChain_Captures500(t *testing.T) { | |||||
| 154 | panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 154 | panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 155 | panic("test panic") | 155 | panic("test panic") | ||
| 156 | }) | 156 | }) | ||
| 157 | - | chain := AccessLogger(mock, false, log)(PanicRecovery(log)(panicHandler)) | 157 | + | chain := AccessLogger(mock, false, log, nil)(PanicRecovery(log)(panicHandler)) |
| 158 | 158 | ||||
| 159 | req := httptest.NewRequest(http.MethodGet, "/crash", nil) | 159 | req := httptest.NewRequest(http.MethodGet, "/crash", nil) | ||
| 160 | rw := httptest.NewRecorder() | 160 | rw := httptest.NewRecorder() | ||
M internal/adapters/out/accesslog/formatter.go +5 -2
| @@ -110,10 +110,13 @@ func buildRequestTarget(path, query string) string { | |||
| 110 | 110 | return path + "?" + query | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | - | // escapeQuoted escapes backslashes and double-quotes inside a value that will | |
| 114 | - | // be enclosed in double-quotes in the log line. | |
| 113 | + | // escapeQuoted escapes backslashes, double-quotes, and newline characters inside | |
| 114 | + | // a value that will be enclosed in double-quotes in the log line. Newline | |
| 115 | + | // escaping prevents log-injection via user-controlled fields such as User-Agent. | |
| 115 | 116 | func escapeQuoted(s string) string { | |
| 116 | 117 | s = strings.ReplaceAll(s, `\`, `\\`) | |
| 117 | 118 | s = strings.ReplaceAll(s, `"`, `\"`) | |
| 119 | + | s = strings.ReplaceAll(s, "\n", `\n`) | |
| 120 | + | s = strings.ReplaceAll(s, "\r", `\r`) | |
| 118 | 121 | return s | |
| 119 | 122 | } | |
| @@ -110,10 +110,13 @@ func buildRequestTarget(path, query string) string { | |||||
| 110 | return path + "?" + query | 110 | return path + "?" + query | ||
| 111 | } | 111 | } | ||
| 112 | 112 | ||||
| 113 | - | // escapeQuoted escapes backslashes and double-quotes inside a value that will | 113 | + | // escapeQuoted escapes backslashes, double-quotes, and newline characters inside |
| 114 | - | // be enclosed in double-quotes in the log line. | 114 | + | // a value that will be enclosed in double-quotes in the log line. Newline |
| 115 | + | // escaping prevents log-injection via user-controlled fields such as User-Agent. | |||
| 115 | func escapeQuoted(s string) string { | 116 | func escapeQuoted(s string) string { | ||
| 116 | s = strings.ReplaceAll(s, `\`, `\\`) | 117 | s = strings.ReplaceAll(s, `\`, `\\`) | ||
| 117 | s = strings.ReplaceAll(s, `"`, `\"`) | 118 | s = strings.ReplaceAll(s, `"`, `\"`) | ||
| 119 | + | s = strings.ReplaceAll(s, "\n", `\n`) | |||
| 120 | + | s = strings.ReplaceAll(s, "\r", `\r`) | |||
| 118 | return s | 121 | return s | ||
| 119 | } | 122 | } | ||