From d07807b4f97ff387a8b8f74cef7ba2ca8931ffa0 Mon Sep 17 00:00:00 2001 From: Francesco Giudici Date: Thu, 12 Feb 2026 19:04:12 +0100 Subject: [PATCH 1/6] Headers: allow to drop sensitive and custom ones When publishing headertrace behind an API Gateway some HTTP Headers ment for internal usage are added during the HTTP request routing. Those headers can reveal sensitive detail of the internal infrastructure. While revealing those headers is part of the goal of headertrace, returning them to each client request could pose security issues. This commit adds an option to redact known sensitive headers and another one to allow dropping custom headers. When dropped, the headers are logged at the debug level. Fixes #1 Signed-off-by: Francesco Giudici --- cmd/cmd.go | 17 +++++++++++++---- pkg/headers/headers.go | 25 ++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/cmd/cmd.go b/cmd/cmd.go index 1733c0b..704dcdc 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -15,20 +15,26 @@ var ( port string host string headers []string + dropHeaders []string sentHeaders bool + privMode bool printVersion bool ) func init() { pflag.StringVarP(&host, "address", "a", "0.0.0.0", "IP address (or domain) to bind to") pflag.StringVarP(&port, "port", "p", "8080", "TCP port to bind to") - pflag.StringSliceVarP(&headers, "header", "H", []string{}, "Custom HTTP headers to add to the HTTP responses (key:value format)") + pflag.StringSliceVarP(&headers, "header", "H", []string{}, "Custom HTTP headers to add to the HTTP responses (key1:value1,key2:value2 format)") + pflag.StringSliceVarP(&dropHeaders, "drop-header", "D", []string{}, "Custom HTTP headers to drop from the HTTP responses (key1,key2 format)") + pflag.BoolVarP(&privMode, "privacy", "P", false, "Enable privacy mode (drop X-Forwarded and Cloudflare headers from the response)") pflag.BoolVarP(&sentHeaders, "sent", "s", false, "Include the original HTTP headers added to the response in the body") pflag.BoolVarP(&printVersion, "version", "v", false, "Print version and exit") } type server struct { headers map[string]string + dropHeaders []string + privMode bool sentHeaders bool } @@ -37,7 +43,7 @@ func (s *server) Get(w http.ResponseWriter, r *http.Request) { logging.Infof("Received request: %s", hdrs.RemoteHostInfo(r)) // Convert headers to map - headers := hdrs.ToMap(r.Header) + headers := hdrs.ToMap(r.Header, s.dropHeaders, s.privMode) var xHeadersPtr *map[string]string protocol := r.Proto @@ -53,7 +59,7 @@ func (s *server) Get(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) if s.sentHeaders { - xHeaders := hdrs.ToMap(w.Header()) + xHeaders := hdrs.ToMap(w.Header(), nil, false) xHeadersPtr = &xHeaders } @@ -93,7 +99,10 @@ func Execute() error { } // Create server instance - srv := &server{headers: customHeaders, sentHeaders: sentHeaders} + srv := &server{headers: customHeaders, + dropHeaders: dropHeaders, + privMode: privMode, + sentHeaders: sentHeaders} // Create handler from the generated code handler := api.Handler(srv) diff --git a/pkg/headers/headers.go b/pkg/headers/headers.go index 0e45ef1..510266c 100644 --- a/pkg/headers/headers.go +++ b/pkg/headers/headers.go @@ -3,7 +3,10 @@ package headers import ( "fmt" "net/http" + "slices" "strings" + + "github.com/fgiudici/headertrace/pkg/logging" ) // Slice2Map takes a slice of header strings in "key:value" format and returns a map. @@ -24,14 +27,34 @@ func SliceToMap(headerStrings []string) (map[string]string, error) { } // ToMap converts an http.Header to a "key:value" map. -func ToMap(headers http.Header) map[string]string { +// It takes a list of headers to drop and a privacy mode flag to exclude headers that may reveal +// sensitive information of the internal network. Note that enabling debug logging will log all droppped headers. +func ToMap(headers http.Header, dropHeaders []string, privmode bool) map[string]string { headerMap := make(map[string]string) for key, values := range headers { + if slices.Contains(dropHeaders, key) { + logging.Debugf("Dropping header '%s':'%s'", key, strings.Join(values, ",")) + continue + } + if privmode { + if isCloudflareHeader(key) || isXForwardedHeader(key) { + logging.Debugf("Dropping header '%s':'%s' (privacy mode)", key, strings.Join(values, ",")) + continue + } + } headerMap[key] = strings.Join(values, ",") } return headerMap } +func isCloudflareHeader(header string) bool { + return strings.HasPrefix(header, "CF-") || strings.HasPrefix(header, "Cf-") +} + +func isXForwardedHeader(header string) bool { + return strings.HasPrefix(header, "X-Forwarded-") || header == "X-Real-Ip" +} + func RemoteHostInfo(r *http.Request) string { remoteAddr := r.RemoteAddr userAgent := r.Header.Get("User-Agent") From 0e50680eb8cc73259854039ddb3a860c699d8a39 Mon Sep 17 00:00:00 2001 From: Francesco Giudici Date: Fri, 13 Feb 2026 10:19:03 +0100 Subject: [PATCH 2/6] Fix typo and enforce CamelCase var naming Spotted by Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Francesco Giudici --- pkg/headers/headers.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/headers/headers.go b/pkg/headers/headers.go index 510266c..b80eed3 100644 --- a/pkg/headers/headers.go +++ b/pkg/headers/headers.go @@ -28,15 +28,15 @@ func SliceToMap(headerStrings []string) (map[string]string, error) { // ToMap converts an http.Header to a "key:value" map. // It takes a list of headers to drop and a privacy mode flag to exclude headers that may reveal -// sensitive information of the internal network. Note that enabling debug logging will log all droppped headers. -func ToMap(headers http.Header, dropHeaders []string, privmode bool) map[string]string { +// sensitive information of the internal network. Note that enabling debug logging will log all dropped headers. +func ToMap(headers http.Header, dropHeaders []string, privMode bool) map[string]string { headerMap := make(map[string]string) for key, values := range headers { if slices.Contains(dropHeaders, key) { logging.Debugf("Dropping header '%s':'%s'", key, strings.Join(values, ",")) continue } - if privmode { + if privMode { if isCloudflareHeader(key) || isXForwardedHeader(key) { logging.Debugf("Dropping header '%s':'%s' (privacy mode)", key, strings.Join(values, ",")) continue From 201f68f2ce3f79cb4ac74593d8dc02d50e8c3f15 Mon Sep 17 00:00:00 2001 From: Francesco Giudici Date: Fri, 13 Feb 2026 10:39:57 +0100 Subject: [PATCH 3/6] pkg/cmd: reword 'privacy' and 'drop-header' help Also add general description and binary version to the help. Signed-off-by: Francesco Giudici --- cmd/cmd.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/cmd/cmd.go b/cmd/cmd.go index 704dcdc..a6e28cd 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "os" + "path/filepath" "github.com/fgiudici/headertrace/api" hdrs "github.com/fgiudici/headertrace/pkg/headers" @@ -22,12 +24,18 @@ var ( ) func init() { + pflag.Usage = func() { + fmt.Fprintf(os.Stderr, "HeaderTrace %s - A simple HTTP server that echoes back received HTTP headers\n\n", getVersion()) + fmt.Fprintf(os.Stderr, "Usage: %s [flags]\n\n", filepath.Base(os.Args[0])) + pflag.PrintDefaults() + } + pflag.StringVarP(&host, "address", "a", "0.0.0.0", "IP address (or domain) to bind to") pflag.StringVarP(&port, "port", "p", "8080", "TCP port to bind to") - pflag.StringSliceVarP(&headers, "header", "H", []string{}, "Custom HTTP headers to add to the HTTP responses (key1:value1,key2:value2 format)") - pflag.StringSliceVarP(&dropHeaders, "drop-header", "D", []string{}, "Custom HTTP headers to drop from the HTTP responses (key1,key2 format)") - pflag.BoolVarP(&privMode, "privacy", "P", false, "Enable privacy mode (drop X-Forwarded and Cloudflare headers from the response)") - pflag.BoolVarP(&sentHeaders, "sent", "s", false, "Include the original HTTP headers added to the response in the body") + pflag.StringSliceVarP(&headers, "header", "H", []string{}, "Custom HTTP headers to add to responses (key1:value1,key2:value2)") + pflag.StringSliceVarP(&dropHeaders, "drop-header", "D", []string{}, "HTTP headers to redact from request headers echoed in the response body (key1,key2)") + pflag.BoolVarP(&privMode, "privacy", "P", false, "Drop X-Forwarded and Cloudflare headers from request headers echoed in the response body") + pflag.BoolVarP(&sentHeaders, "sent", "s", false, "Dump the HTTP headers added in the response in the response body") pflag.BoolVarP(&printVersion, "version", "v", false, "Print version and exit") } From dc83dec1311d1dedc1b2172f2f47927968f0f5cf Mon Sep 17 00:00:00 2001 From: Francesco Giudici Date: Fri, 13 Feb 2026 10:56:31 +0100 Subject: [PATCH 4/6] pkg/headers: normalize headers before matching Signed-off-by: Francesco Giudici --- cmd/cmd.go | 2 +- pkg/headers/headers.go | 43 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/cmd/cmd.go b/cmd/cmd.go index a6e28cd..9766412 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -48,7 +48,7 @@ type server struct { // Get implements api.ServerInterface func (s *server) Get(w http.ResponseWriter, r *http.Request) { - logging.Infof("Received request: %s", hdrs.RemoteHostInfo(r)) + logging.Infof("Received request: %s", hdrs.GetRemoteHostInfo(r)) // Convert headers to map headers := hdrs.ToMap(r.Header, s.dropHeaders, s.privMode) diff --git a/pkg/headers/headers.go b/pkg/headers/headers.go index b80eed3..c3f7875 100644 --- a/pkg/headers/headers.go +++ b/pkg/headers/headers.go @@ -31,13 +31,16 @@ func SliceToMap(headerStrings []string) (map[string]string, error) { // sensitive information of the internal network. Note that enabling debug logging will log all dropped headers. func ToMap(headers http.Header, dropHeaders []string, privMode bool) map[string]string { headerMap := make(map[string]string) + normalizedDropHeaders := sliceToLower(dropHeaders) + for key, values := range headers { - if slices.Contains(dropHeaders, key) { + lowerKey := strings.ToLower(key) + if slices.Contains(normalizedDropHeaders, lowerKey) { logging.Debugf("Dropping header '%s':'%s'", key, strings.Join(values, ",")) continue } if privMode { - if isCloudflareHeader(key) || isXForwardedHeader(key) { + if isCloudflareHeader(lowerKey) || isXForwardedHeader(lowerKey) { logging.Debugf("Dropping header '%s':'%s' (privacy mode)", key, strings.Join(values, ",")) continue } @@ -47,15 +50,45 @@ func ToMap(headers http.Header, dropHeaders []string, privMode bool) map[string] return headerMap } +func sliceToLower(headers []string) []string { + lower := make([]string, len(headers)) + for i, h := range headers { + lower[i] = strings.ToLower(h) + } + return lower +} + +// isCloudflareHeader checks if a header is a Cloudflare-specific header that should be dropped in privacy mode. +// NOTE: it expects headers to be already normalized to lowercase. func isCloudflareHeader(header string) bool { - return strings.HasPrefix(header, "CF-") || strings.HasPrefix(header, "Cf-") + return strings.HasPrefix(header, "cf-") } +// isXForwardedHeader checks if a header is an X-Forwarded or X-Real-IP header that should be dropped in privacy mode. +// NOTE: it expects headers to be already normalized to lowercase. func isXForwardedHeader(header string) bool { - return strings.HasPrefix(header, "X-Forwarded-") || header == "X-Real-Ip" + return strings.HasPrefix(header, "x-forwarded-") || header == "x-real-ip" } -func RemoteHostInfo(r *http.Request) string { +// GetRemoteHostInfo extracts the remote host information from the request, inspecting common proxy headers like CF-Connecting-IP, X-Real-IP, and X-Forwarded-For. +// It returns a formatted string with the remote address and user agent. +func GetRemoteHostInfo(r *http.Request) string { + // Example of received headers: + // "Accept": "*/*", + // "Accept-Encoding": "gzip", + // "Cdn-Loop": "cloudflare; loops=1", + // "Cf-Connecting-Ip": "1.2.3.4", + // "Cf-Ipcountry": "IT", + // "Cf-Ray": "9cbdc3515d22baf3-MXP", + // "Cf-Visitor": "{\"scheme\":\"http\"}", + // "User-Agent": "curl/7.88.1", + // "X-Forwarded-For": "10.22.0.0", + // "X-Forwarded-Host": "headers.example.com", + // "X-Forwarded-Port": "80", + // "X-Forwarded-Proto": "http", + // "X-Forwarded-Server": "traefik-73f98ac65-z1drx", + // "X-Real-Ip": "10.22.0.0" + remoteAddr := r.RemoteAddr userAgent := r.Header.Get("User-Agent") From 83af511fa0e9fadcffc265874c69bacbbdfdf500 Mon Sep 17 00:00:00 2001 From: Francesco Giudici Date: Fri, 13 Feb 2026 11:28:26 +0100 Subject: [PATCH 5/6] pkg/headers: add more test coverage Signed-off-by: Francesco Giudici --- pkg/headers/headers.go | 9 +- pkg/headers/headers_test.go | 232 ++++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 4 deletions(-) diff --git a/pkg/headers/headers.go b/pkg/headers/headers.go index c3f7875..447fdfe 100644 --- a/pkg/headers/headers.go +++ b/pkg/headers/headers.go @@ -18,10 +18,11 @@ func SliceToMap(headerStrings []string) (map[string]string, error) { if len(parts) != 2 { return nil, fmt.Errorf("invalid header format '%s', expected 'key:value'", h) } + parts[0] = strings.TrimSpace(parts[0]) if parts[0] == "" { return nil, fmt.Errorf("header key cannot be empty in '%s'", h) } - headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + headers[parts[0]] = strings.TrimSpace(parts[1]) } return headers, nil } @@ -94,11 +95,11 @@ func GetRemoteHostInfo(r *http.Request) string { // Proxied through Cloudflare? if remote := r.Header.Get("CF-Connecting-IP"); remote != "" { - remoteAddr = fmt.Sprintf("%s (%s)", remote, r.Header.Get("Cf-Ipcountry")) + remoteAddr = fmt.Sprintf("%s(%s) [%s]", remote, r.Header.Get("Cf-Ipcountry"), remoteAddr) } else if remote := r.Header.Get("X-Real-Ip"); remote != "" { - remoteAddr = remote + remoteAddr = fmt.Sprintf("%s [%s]", remote, remoteAddr) } else if remote := r.Header.Get("X-Forwarded-For"); remote != "" { - remoteAddr = remote + remoteAddr = fmt.Sprintf("%s [%s]", remote, remoteAddr) } return fmt.Sprintf("%s %q - %s %s %q", remoteAddr, userAgent, r.Method, r.Proto, r.URL.String()) diff --git a/pkg/headers/headers_test.go b/pkg/headers/headers_test.go index 261ea8d..01bb605 100644 --- a/pkg/headers/headers_test.go +++ b/pkg/headers/headers_test.go @@ -1,7 +1,10 @@ package headers import ( + "net/http" + "net/http/httptest" "reflect" + "strings" "testing" ) @@ -57,6 +60,34 @@ func TestSliceToMap(t *testing.T) { input: []string{"Valid:header", "InvalidHeader"}, wantErr: true, }, + { + name: "empty input slice", + input: []string{}, + want: map[string]string{}, + }, + { + name: "whitespace only key and value", + input: []string{" : "}, + wantErr: true, + }, + { + name: "duplicate header keys", + input: []string{"X-Custom:first", "X-Custom:second"}, + want: map[string]string{"X-Custom": "second"}, + }, + { + name: "case sensitive keys", + input: []string{"x-custom:value1", "X-Custom:value2"}, + want: map[string]string{ + "x-custom": "value1", + "X-Custom": "value2", + }, + }, + { + name: "special characters in value", + input: []string{"X-Custom:!@#$%^&*()"}, + want: map[string]string{"X-Custom": "!@#$%^&*()"}, + }, } for _, tt := range tests { @@ -77,3 +108,204 @@ func TestSliceToMap(t *testing.T) { }) } } + +func TestToMap(t *testing.T) { + tests := []struct { + name string + headers http.Header + dropHeaders []string + privMode bool + want map[string]string + }{ + { + name: "basic headers", + headers: http.Header{ + "X-Custom": {"value"}, + }, + dropHeaders: []string{}, + privMode: false, + want: map[string]string{ + "X-Custom": "value", + }, + }, + { + name: "drop headers", + headers: http.Header{ + "X-Custom": {"value"}, + }, + dropHeaders: []string{"x-custom"}, + privMode: false, + want: map[string]string{}, + }, + { + name: "privMode drops Cloudflare headers", + headers: http.Header{ + "CF-Ray": {"9cbdc3515d22baf3-MXP"}, + "Cf-Visitor": {"{\"scheme\":\"https\"}"}, + "cf-Connecting-Ip": {"10.22.0.0"}, + "X-Custom": {"value"}, + }, + dropHeaders: []string{}, + privMode: true, + want: map[string]string{ + "X-Custom": "value", + }, + }, + { + name: "privMode drops X-Forwarded headers", + headers: http.Header{ + "X-Forwarded-For": {"10.22.0.0"}, + "X-forwarded-Host": {"example.com"}, + "x-forwarded-proto": {"https"}, + "X-Custom": {"value"}, + }, + dropHeaders: []string{}, + privMode: true, + want: map[string]string{ + "X-Custom": "value", + }, + }, + { + name: "privMode drops X-Real-IP header", + headers: http.Header{ + "X-Real-Ip": {"10.22.0.0"}, + "x-Real-Ip": {"10.22.0.0"}, + "X-real-ip": {"10.22.0.0"}, + "X-Custom": {"value"}, + }, + dropHeaders: []string{}, + privMode: true, + want: map[string]string{ + "X-Custom": "value", + }, + }, + { + name: "privMode false keeps all headers", + headers: http.Header{ + "CF-Ray": {"9cbdc3515d22baf3-MXP"}, + "X-Forwarded-For": {"10.22.0.0"}, + "X-Real-Ip": {"10.22.0.0"}, + "X-Custom": {"value"}, + }, + dropHeaders: []string{}, + privMode: false, + want: map[string]string{ + "CF-Ray": "9cbdc3515d22baf3-MXP", + "X-Forwarded-For": "10.22.0.0", + "X-Real-Ip": "10.22.0.0", + "X-Custom": "value", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ToMap(tt.headers, tt.dropHeaders, tt.privMode) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("ToMap() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetRemoteHostInfo(t *testing.T) { + tests := []struct { + name string + remoteAddr string + headers http.Header + method string + urlString string + expectedIP string + }{ + { + name: "uses CF-Connecting-IP with Cf-Ipcountry", + remoteAddr: "127.0.0.1:5000", + headers: http.Header{ + "CF-Connecting-IP": {"1.2.3.4"}, + "Cf-Ipcountry": {"US"}, + "X-Real-Ip": {"5.6.7.8"}, + "X-Forwarded-For": {"9.10.11.12"}, + }, + method: "GET", + urlString: "http://example.com/", + expectedIP: "1.2.3.4 (US)", + }, + { + name: "uses X-Real-IP when CF-Connecting-IP not available", + remoteAddr: "127.0.0.1:5000", + headers: http.Header{ + "X-Real-Ip": {"5.6.7.8"}, + "X-Forwarded-For": {"9.10.11.12"}, + }, + method: "GET", + urlString: "http://example.com/", + expectedIP: "5.6.7.8", + }, + { + name: "uses X-Forwarded-For when CF-Connecting-IP and X-Real-IP not available", + remoteAddr: "127.0.0.1:5000", + headers: http.Header{ + "X-Forwarded-For": {"9.10.11.12"}, + }, + method: "GET", + urlString: "http://example.com/", + expectedIP: "9.10.11.12", + }, + { + name: "uses r.RemoteAddr when no proxy headers available", + remoteAddr: "192.168.1.1:8080", + headers: http.Header{}, + method: "GET", + urlString: "http://example.com/", + expectedIP: "192.168.1.1:8080", + }, + { + name: "prefers cf-Connecting-IP over X-Real-IP", + remoteAddr: "127.0.0.1:5000", + headers: http.Header{ + "cf-Connecting-IP": {"1.2.3.4"}, + "cf-Ipcountry": {"IT"}, + "X-Real-Ip": {"5.6.7.8"}, + "X-Forwarded-For": {"9.10.11.12"}, + }, + method: "GET", + urlString: "http://example.com/", + expectedIP: "1.2.3.4 (IT)", + }, + { + name: "prefers x-Real-ip over X-Forwarded-For when CF-Connecting-IP missing", + remoteAddr: "127.0.0.1:5000", + headers: http.Header{ + "x-Real-ip": {"5.6.7.8"}, + "X-Forwarded-For": {"9.10.11.12"}, + }, + method: "GET", + urlString: "http://example.com/", + expectedIP: "5.6.7.8", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.urlString, nil) + req.RemoteAddr = tt.remoteAddr + for key, values := range tt.headers { + for _, value := range values { + req.Header.Add(key, value) + } + } + + got := GetRemoteHostInfo(req) + + // Verify that the expected IP is contained in the result + if !strings.Contains(got, tt.expectedIP) { + t.Fatalf("GetRemoteHostInfo() = %q, expected to contain IP %q", got, tt.expectedIP) + } + + // Verify the format includes method and proto + if !strings.Contains(got, tt.method) { + t.Fatalf("GetRemoteHostInfo() = %q, expected to contain method %q", got, tt.method) + } + }) + } +} From ff036eedaf2acb673b77a9037576f3c6c6258bff Mon Sep 17 00:00:00 2001 From: Francesco Giudici Date: Fri, 13 Feb 2026 11:32:22 +0100 Subject: [PATCH 6/6] test: fix expected format Signed-off-by: Francesco Giudici --- pkg/headers/headers_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/headers/headers_test.go b/pkg/headers/headers_test.go index 01bb605..fed1498 100644 --- a/pkg/headers/headers_test.go +++ b/pkg/headers/headers_test.go @@ -66,8 +66,8 @@ func TestSliceToMap(t *testing.T) { want: map[string]string{}, }, { - name: "whitespace only key and value", - input: []string{" : "}, + name: "whitespace only key and value", + input: []string{" : "}, wantErr: true, }, { @@ -228,7 +228,7 @@ func TestGetRemoteHostInfo(t *testing.T) { }, method: "GET", urlString: "http://example.com/", - expectedIP: "1.2.3.4 (US)", + expectedIP: "1.2.3.4(US)", }, { name: "uses X-Real-IP when CF-Connecting-IP not available", @@ -270,7 +270,7 @@ func TestGetRemoteHostInfo(t *testing.T) { }, method: "GET", urlString: "http://example.com/", - expectedIP: "1.2.3.4 (IT)", + expectedIP: "1.2.3.4(IT)", }, { name: "prefers x-Real-ip over X-Forwarded-For when CF-Connecting-IP missing",