Skip to content

Commit 178c287

Browse files
arnaugiraltclaude
andcommitted
fix(observability): address review findings from improve-core-logging
- Pre-compute header name strings once at RequestLoggerMiddleware construction instead of per-request to avoid redundant allocations - Add product_id to all operational log lines that had vendor_id and marketplace_id for consistent per-product filtering - Replace sanitizeURL (kept path) with extractTargetHost (host only) in the transaction context parsed DEBUG log to prevent path segments from leaking sensitive data (e.g. /users/alice@example.com) - Add target_host to allow-list rejection log and remove misleading comment that described intent that wasn't implemented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b62cf27 commit 178c287

6 files changed

Lines changed: 55 additions & 37 deletions

File tree

internal/observability/request_logger.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ func (rc *ResponseCapturer) Status() int {
108108
// Uses defer to ensure logging occurs even if downstream handlers panic
109109
// (when used with panic recovery middleware).
110110
func RequestLoggerMiddleware(logger *slog.Logger, headerPrefix string, next http.Handler) http.Handler {
111+
vendorHdr := headerPrefix + "-Vendor-ID"
112+
marketplaceHdr := headerPrefix + "-Marketplace-ID"
113+
productHdr := headerPrefix + "-Product-ID"
114+
targetURLHdr := headerPrefix + "-Target-URL"
115+
111116
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112117
start := time.Now()
113118
ctx := r.Context()
@@ -123,10 +128,10 @@ func RequestLoggerMiddleware(logger *slog.Logger, headerPrefix string, next http
123128
"path", r.URL.Path,
124129
"status", capturer.Status(),
125130
"latency_ms", time.Since(start).Milliseconds(),
126-
"vendor_id", r.Header.Get(headerPrefix+"-Vendor-ID"),
127-
"marketplace_id", r.Header.Get(headerPrefix+"-Marketplace-ID"),
128-
"product_id", r.Header.Get(headerPrefix+"-Product-ID"),
129-
"target_host", extractHost(r.Header.Get(headerPrefix+"-Target-URL")),
131+
"vendor_id", r.Header.Get(vendorHdr),
132+
"marketplace_id", r.Header.Get(marketplaceHdr),
133+
"product_id", r.Header.Get(productHdr),
134+
"target_host", extractHost(r.Header.Get(targetURLHdr)),
130135
"client_ip", ClientIP(r),
131136
"remote_addr", r.RemoteAddr,
132137
)

internal/proxy/integration_test.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2026,7 +2026,7 @@ func TestIntegration_SlowPath_LogsCredentialInjection(t *testing.T) {
20262026
// Phase 6: DEBUG logpoints for context parsing
20272027
// =============================================================================
20282028

2029-
func TestProxy_ContextParsed_DebugLog_SanitizesURL(t *testing.T) {
2029+
func TestProxy_ContextParsed_DebugLog_LogsHostOnly(t *testing.T) {
20302030
// Arrange - capture DEBUG log output
20312031
var logBuffer bytes.Buffer
20322032
logger := slog.New(slog.NewJSONHandler(&logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}))
@@ -2042,8 +2042,8 @@ func TestProxy_ContextParsed_DebugLog_SanitizesURL(t *testing.T) {
20422042
srv := mustNewServerForTarget(t, testConfig(), backend.URL)
20432043
handler := srv.Handler()
20442044

2045-
// Construct target URL with sensitive query params and credentials in userinfo
2046-
targetURL := backend.URL + "/v1/resource?api_key=supersecret&token=abc123"
2045+
// URL with a sensitive path segment and query params — only the host should appear in logs
2046+
targetURL := backend.URL + "/v1/users/alice@example.com?api_key=supersecret&token=abc123"
20472047

20482048
req := httptest.NewRequest(http.MethodGet, "/proxy", nil)
20492049
req.Header.Set("X-Connect-Target-URL", targetURL)
@@ -2053,8 +2053,11 @@ func TestProxy_ContextParsed_DebugLog_SanitizesURL(t *testing.T) {
20532053
// Act
20542054
handler.ServeHTTP(rec, req)
20552055

2056-
// Assert - target_url in the DEBUG log must not contain query params
2056+
// Assert - only the host appears; path, query, and userinfo must not leak
20572057
logOutput := logBuffer.String()
2058+
if !strings.Contains(logOutput, `"msg":"transaction context parsed"`) {
2059+
t.Errorf("expected 'transaction context parsed' debug log, got: %s", logOutput)
2060+
}
20582061
if strings.Contains(logOutput, "supersecret") {
20592062
t.Errorf("log must not contain sensitive query value 'supersecret', got: %s", logOutput)
20602063
}
@@ -2064,8 +2067,11 @@ func TestProxy_ContextParsed_DebugLog_SanitizesURL(t *testing.T) {
20642067
if strings.Contains(logOutput, "token=") {
20652068
t.Errorf("log must not contain 'token=' query param, got: %s", logOutput)
20662069
}
2067-
if !strings.Contains(logOutput, `"msg":"transaction context parsed"`) {
2068-
t.Errorf("expected 'transaction context parsed' debug log, got: %s", logOutput)
2070+
if strings.Contains(logOutput, "alice@example.com") {
2071+
t.Errorf("log must not contain path segment 'alice@example.com', got: %s", logOutput)
2072+
}
2073+
if strings.Contains(logOutput, "/v1/users") {
2074+
t.Errorf("log must not contain URL path, got: %s", logOutput)
20692075
}
20702076
}
20712077

internal/proxy/server.go

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,7 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
515515
"vendor_id", txCtx.VendorID,
516516
"marketplace_id", txCtx.MarketplaceID,
517517
"product_id", txCtx.ProductID,
518-
"target_url", sanitizeURL(txCtx.TargetURL),
518+
"target_host", extractTargetHost(txCtx.TargetURL),
519519
)
520520

521521
targetURL, err := url.Parse(txCtx.TargetURL)
@@ -701,18 +701,15 @@ func (s *Server) detectSlowPathInjections(r *http.Request, before http.Header) (
701701
return r, len(injectedKeys)
702702
}
703703

704-
// sanitizeURL strips the query string, fragment, and userinfo from a URL
705-
// to prevent token or credential leakage in log output.
706-
// Returns an empty string if the input is not a valid URL.
707-
func sanitizeURL(rawURL string) string {
704+
// extractTargetHost parses rawURL and returns only the host (with port if present).
705+
// Used in log output to avoid leaking sensitive path or query information.
706+
// Returns an empty string if the URL is invalid or has no host.
707+
func extractTargetHost(rawURL string) string {
708708
u, err := url.Parse(rawURL)
709-
if err != nil {
709+
if err != nil || u.Host == "" {
710710
return ""
711711
}
712-
u.RawQuery = ""
713-
u.Fragment = ""
714-
u.User = nil
715-
return u.String()
712+
return u.Host
716713
}
717714

718715
// headerValuesEqual returns true if two header value slices are identical.
@@ -758,6 +755,7 @@ func (s *Server) handlePluginError(w http.ResponseWriter, traceID string, txCtx
758755
"trace_id", traceID,
759756
"vendor_id", txCtx.VendorID,
760757
"marketplace_id", txCtx.MarketplaceID,
758+
"product_id", txCtx.ProductID,
761759
"target_host", targetHost,
762760
"error", err,
763761
)
@@ -770,6 +768,7 @@ func (s *Server) handlePluginError(w http.ResponseWriter, traceID string, txCtx
770768
"trace_id", traceID,
771769
"vendor_id", txCtx.VendorID,
772770
"marketplace_id", txCtx.MarketplaceID,
771+
"product_id", txCtx.ProductID,
773772
"target_host", targetHost,
774773
)
775774
// Write 499 so RequestLoggerMiddleware logs the correct status instead of
@@ -782,6 +781,7 @@ func (s *Server) handlePluginError(w http.ResponseWriter, traceID string, txCtx
782781
"trace_id", traceID,
783782
"vendor_id", txCtx.VendorID,
784783
"marketplace_id", txCtx.MarketplaceID,
784+
"product_id", txCtx.ProductID,
785785
"target_host", targetHost,
786786
"error", err,
787787
)
@@ -855,6 +855,7 @@ func (s *Server) createReverseProxy(target *url.URL, traceID string, txCtx *sdk.
855855
"trace_id", traceID,
856856
"vendor_id", txCtx.VendorID,
857857
"marketplace_id", txCtx.MarketplaceID,
858+
"product_id", txCtx.ProductID,
858859
"target_host", target.Host,
859860
"error", err,
860861
)
@@ -900,6 +901,7 @@ func (s *Server) buildModifyResponse(traceID string, txCtx *sdk.TransactionConte
900901
"trace_id", traceID,
901902
"vendor_id", txCtx.VendorID,
902903
"marketplace_id", txCtx.MarketplaceID,
904+
"product_id", txCtx.ProductID,
903905
"target_host", resp.Request.URL.Host,
904906
"error", err,
905907
)
@@ -925,6 +927,7 @@ func (s *Server) buildModifyResponse(traceID string, txCtx *sdk.TransactionConte
925927
"content_length", resp.ContentLength,
926928
"vendor_id", txCtx.VendorID,
927929
"marketplace_id", txCtx.MarketplaceID,
930+
"product_id", txCtx.ProductID,
928931
"target_host", resp.Request.URL.Host,
929932
)
930933
return nil
@@ -946,6 +949,7 @@ func (s *Server) applyErrorNormalization(traceID string, txCtx *sdk.TransactionC
946949
"trace_id", traceID,
947950
"vendor_id", txCtx.VendorID,
948951
"marketplace_id", txCtx.MarketplaceID,
952+
"product_id", txCtx.ProductID,
949953
"target_host", resp.Request.URL.Host,
950954
"error", err,
951955
)

internal/proxy/server_helpers_test.go

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,36 @@ package proxy
55

66
import "testing"
77

8-
func TestSanitizeURL(t *testing.T) {
8+
func TestExtractTargetHost(t *testing.T) {
99
tests := []struct {
1010
name string
1111
input string
1212
want string
1313
}{
1414
{
15-
name: "strips query string",
16-
input: "https://api.vendor.com/v1?api_key=secret",
17-
want: "https://api.vendor.com/v1",
15+
name: "returns host only, strips path",
16+
input: "https://api.vendor.com/v1/users/123",
17+
want: "api.vendor.com",
1818
},
1919
{
20-
name: "strips fragment",
21-
input: "https://api.vendor.com/v1#section",
22-
want: "https://api.vendor.com/v1",
20+
name: "returns host only, strips query string",
21+
input: "https://api.vendor.com/v1?api_key=secret",
22+
want: "api.vendor.com",
2323
},
2424
{
25-
name: "strips userinfo",
25+
name: "returns host only, strips userinfo",
2626
input: "https://user:pass@api.vendor.com/v1",
27-
want: "https://api.vendor.com/v1",
27+
want: "api.vendor.com",
2828
},
2929
{
30-
name: "preserves path",
31-
input: "https://api.vendor.com/v1/users/123",
32-
want: "https://api.vendor.com/v1/users/123",
30+
name: "preserves port",
31+
input: "https://api.vendor.com:8443/v1",
32+
want: "api.vendor.com:8443",
3333
},
3434
{
3535
name: "strips all sensitive parts together",
36-
input: "https://user:pass@api.vendor.com/v1?token=abc#frag",
37-
want: "https://api.vendor.com/v1",
36+
input: "https://user:pass@api.vendor.com/v1/users/alice@example.com?token=abc#frag",
37+
want: "api.vendor.com",
3838
},
3939
{
4040
name: "invalid URL returns empty",
@@ -50,9 +50,9 @@ func TestSanitizeURL(t *testing.T) {
5050

5151
for _, tt := range tests {
5252
t.Run(tt.name, func(t *testing.T) {
53-
got := sanitizeURL(tt.input)
53+
got := extractTargetHost(tt.input)
5454
if got != tt.want {
55-
t.Errorf("sanitizeURL(%q) = %q, want %q", tt.input, got, tt.want)
55+
t.Errorf("extractTargetHost(%q) = %q, want %q", tt.input, got, tt.want)
5656
}
5757
})
5858
}

internal/router/middleware.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ func (m *AllowListMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request)
5555

5656
// Validate target URL against allow list
5757
if err := m.validator.Validate(targetURL); err != nil {
58-
// Log the host but not the full URL to avoid leaking query params
5958
slog.Warn("allow list validation failed",
6059
"trace_id", observability.TraceIDFromContext(r.Context()),
60+
"target_host", extractHostFromURL(targetURL),
6161
"error", err.Error(),
6262
"remote_addr", r.RemoteAddr,
6363
)

internal/router/middleware_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,9 @@ func TestAllowListMiddleware_ValidationFailed_HasTraceID(t *testing.T) {
380380
if !strings.Contains(logOutput, `"trace_id":"trace-fail-456"`) {
381381
t.Errorf("expected trace_id in failure log, got: %s", logOutput)
382382
}
383+
if !strings.Contains(logOutput, `"target_host":"evil.com"`) {
384+
t.Errorf("expected target_host in failure log, got: %s", logOutput)
385+
}
383386
}
384387

385388
func TestAllowListMiddleware_EmptyAllowListDeniesAll(t *testing.T) {

0 commit comments

Comments
 (0)