From fcc88c9b1fce20809916967cd05ee126f79f65d8 Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Thu, 27 Aug 2026 10:29:14 -0400 Subject: [PATCH] feat: add response header redaction - Add RedactResponseHeaders to EndpointConfig to support filtering sensitive/internal response headers during recording. - Implement wildcard-aware, case-insensitive response header redaction on RecordedResponse. - Add Go unit tests covering YAML configuration parsing and response header redactions. --- internal/config/config.go | 1 + internal/config/config_test.go | 15 +++--- internal/record/recording_https_proxy.go | 3 ++ internal/store/store.go | 11 +++++ internal/store/store_test.go | 60 ++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 5c0803c..bf381df 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,6 +31,7 @@ type EndpointConfig struct { SourceType string `yaml:"source_type"` Health string `yaml:"health"` RedactRequestHeaders []string `yaml:"redact_request_headers"` + RedactResponseHeaders []string `yaml:"redact_response_headers"` ResponseHeaderReplacements []HeaderReplacement `yaml:"response_header_replacements"` } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e398c29..79a3d9c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -41,6 +41,8 @@ func TestReadConfigWithFs(t *testing.T) { target_type: https redact_request_headers: - X-Goog-Api-Key + redact_response_headers: + - X-Google-* - target_host: api.example.com target_port: 8080 source_port: 8081 @@ -51,12 +53,13 @@ func TestReadConfigWithFs(t *testing.T) { wantConfig: &TestServerConfig{ Endpoints: []EndpointConfig{ { - TargetHost: "www.google.com", - TargetPort: 443, - SourcePort: 1443, - SourceType: "http", - TargetType: "https", - RedactRequestHeaders: []string{"X-Goog-Api-Key"}, + TargetHost: "www.google.com", + TargetPort: 443, + SourcePort: 1443, + SourceType: "http", + TargetType: "https", + RedactRequestHeaders: []string{"X-Goog-Api-Key"}, + RedactResponseHeaders: []string{"X-Google-*"}, }, { TargetHost: "api.example.com", diff --git a/internal/record/recording_https_proxy.go b/internal/record/recording_https_proxy.go index 4becf1a..31893fc 100644 --- a/internal/record/recording_https_proxy.go +++ b/internal/record/recording_https_proxy.go @@ -187,6 +187,9 @@ func (r *RecordingHTTPSProxy) recordResponse(recReq *store.RecordedRequest, resp return err } + // Redact response headers matching config patterns + recordedResponse.RedactHeaders(r.config.RedactResponseHeaders) + recordFile, ok := r.seenFiles[fileName] if !ok { r.seenFiles[fileName] = store.RecordFile{RecordID: fileName, Interactions: []*store.RecordInteraction{}} diff --git a/internal/store/store.go b/internal/store/store.go index 2241e89..f00b2ef 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -163,6 +163,17 @@ func (r *RecordedRequest) RedactHeaders(headers []string) { } } +// RedactHeaders removes the specified headers matching exact names or wildcard prefixes from the RecordedResponse. +func (r *RecordedResponse) RedactHeaders(patterns []string) { + for _, pattern := range patterns { + for key := range r.Headers { + if strings.EqualFold(key, pattern) || (strings.HasSuffix(pattern, "*") && strings.HasPrefix(strings.ToLower(key), strings.ToLower(strings.TrimSuffix(pattern, "*")))) { + delete(r.Headers, key) + } + } + } +} + func NewRecordedResponse(resp *http.Response, redactor *redact.Redact, body []byte) (*RecordedResponse, error) { if resp.Header.Get("Content-Encoding") == "gzip" { gzipReader, err := gzip.NewReader(bytes.NewReader(body)) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 32d9f3d..6556024 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -366,3 +366,63 @@ type errorReader struct{} func (e *errorReader) Read(p []byte) (n int, err error) { return 0, fmt.Errorf("simulated error") } + +func TestRecordedResponse_RedactHeaders(t *testing.T) { + testCases := []struct { + name string + response RecordedResponse + patterns []string + expectedHeaders map[string]string + }{ + { + name: "Redact exact matches", + response: RecordedResponse{ + Headers: map[string]string{ + "Content-Type": "application/json", + "X-Google-Service": "firebase", + "Server": "ESF", + }, + }, + patterns: []string{"X-Google-Service", "Server"}, + expectedHeaders: map[string]string{ + "Content-Type": "application/json", + }, + }, + { + name: "Redact wildcard patterns", + response: RecordedResponse{ + Headers: map[string]string{ + "Content-Type": "application/json", + "X-Google-Service": "firebase", + "X-Google-Gfe-Version": "1.0", + "Server": "ESF", + }, + }, + patterns: []string{"X-Google-*"}, + expectedHeaders: map[string]string{ + "Content-Type": "application/json", + "Server": "ESF", + }, + }, + { + name: "Redact case insensitivity", + response: RecordedResponse{ + Headers: map[string]string{ + "Content-Type": "application/json", + "x-google-service": "firebase", + }, + }, + patterns: []string{"X-Google-Service"}, + expectedHeaders: map[string]string{ + "Content-Type": "application/json", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.response.RedactHeaders(tc.patterns) + require.Equal(t, tc.expectedHeaders, tc.response.Headers, "RedactHeaders() result mismatch") + }) + } +}