diff --git a/README.md b/README.md index a97ec9b..d8aeb8b 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,31 @@ logger.SetFilter(func filteredURIs(req *http.Request) (bool, error) { }) ``` +## Header sanitization and redaction +By default, sensitive headers (`Authorization`, `Proxy-Authorization`, `Cookie`, and `Set-Cookie`) are sanitized. +You can bypass sanitization by setting `SkipSanitize: true`. + +### Redacting specific headers +You can specify headers to redact with `[REDACTED]`: + +```go +logger.SanitizeHeader([]string{"Authorization", "Cookie", "X-Api-Key"}) +``` + +### Custom header sanitization function +You can also set a custom sanitization function for header values: + +```go +logger.SetHeaderSanitizer(func(name, value string) string { + if strings.EqualFold(name, "X-Custom-Auth") { + return "[REDACTED]" + } + return value +}) +``` + ## Formatters You can define a formatter for any media type by implementing the Formatter interface. We provide a JSONFormatter for convenience (it is not enabled by default). + diff --git a/httpretty.go b/httpretty.go index 6fb2b26..145ceba 100644 --- a/httpretty.go +++ b/httpretty.go @@ -69,6 +69,7 @@ import ( "sync" "github.com/henvic/httpretty/internal/color" + "github.com/henvic/httpretty/internal/header" ) // Formatter can be used to format body. @@ -85,6 +86,16 @@ func WithHide(ctx context.Context) context.Context { return context.WithValue(ctx, contextHide{}, struct{}{}) } +// HeaderSanitizer allows you to sanitize or redact HTTP headers. +// It receives the header key and value, and returns the sanitized value. +type HeaderSanitizer func(name, value string) string + +// DefaultRedactedValue is the placeholder used when redacting headers. +const DefaultRedactedValue = "[REDACTED]" + +// RedactedValue is an alias for DefaultRedactedValue. +const RedactedValue = DefaultRedactedValue + // Logger provides a way for you to print client and server-side information about your HTTP traffic. type Logger struct { // SkipRequestInfo avoids printing a line showing the request URI on all requests plus a line @@ -118,6 +129,12 @@ type Logger struct { // SkipSanitize bypasses sanitizing headers containing credentials (such as Authorization). SkipSanitize bool + // SanitizeHeaders contains a list of header names to redact with [REDACTED]. + SanitizeHeaders []string + + // HeaderSanitizer allows you to set a custom function to sanitize header values. + HeaderSanitizer HeaderSanitizer + // Colors set ANSI escape codes that terminals use to print text in different colors. Colors bool @@ -137,12 +154,14 @@ type Logger struct { // If value is not set and Content-Length is not sent, 4096 bytes is considered. MaxResponseBody int64 - mu sync.Mutex // ensures atomic writes; protects the following fields - w io.Writer - filter Filter - skipHeader map[string]struct{} - bodyFilter BodyFilter - flusher Flusher + mu sync.Mutex // ensures atomic writes; protects the following fields + w io.Writer + filter Filter + skipHeader map[string]struct{} + sanitizeHeader map[string]struct{} + headerSanitizer HeaderSanitizer + bodyFilter BodyFilter + flusher Flusher } // Filter allows you to skip requests. @@ -196,6 +215,32 @@ func (l *Logger) SkipHeader(headers []string) { l.skipHeader = m } +// SanitizeHeader allows you to redact specific headers with [REDACTED]. +// This method is concurrency safe. +func (l *Logger) SanitizeHeader(headers []string) { + l.mu.Lock() + defer l.mu.Unlock() + m := map[string]struct{}{} + for _, h := range headers { + m[textproto.CanonicalMIMEHeaderKey(h)] = struct{}{} + } + l.sanitizeHeader = m +} + +// RedactHeaders is an alias for SanitizeHeader. +// This method is concurrency safe. +func (l *Logger) RedactHeaders(headers []string) { + l.SanitizeHeader(headers) +} + +// SetHeaderSanitizer allows you to set a function to sanitize headers. +// Pass nil to remove the header sanitizer. This method is concurrency safe. +func (l *Logger) SetHeaderSanitizer(f HeaderSanitizer) { + l.mu.Lock() + defer l.mu.Unlock() + l.headerSanitizer = f +} + // SetBodyFilter allows you to set a function to skip printing a body. // Pass nil to remove the body filter. This method is concurrency safe. func (l *Logger) SetBodyFilter(f BodyFilter) { @@ -253,6 +298,32 @@ func (l *Logger) cloneSkipHeader() map[string]struct{} { return m } +func (l *Logger) cloneSanitizeHeader() map[string]struct{} { + l.mu.Lock() + sanitized := l.sanitizeHeader + l.mu.Unlock() + + m := map[string]struct{}{} + for _, h := range l.SanitizeHeaders { + m[textproto.CanonicalMIMEHeaderKey(h)] = struct{}{} + } + for h := range sanitized { + m[h] = struct{}{} + } + + return m +} + +func (l *Logger) getHeaderSanitizer() HeaderSanitizer { + l.mu.Lock() + f := l.headerSanitizer + if f == nil { + f = l.HeaderSanitizer + } + l.mu.Unlock() + return f +} + type contextHide struct{} type roundTripper struct { @@ -442,3 +513,18 @@ func (j *JSONFormatter) Format(w io.Writer, src []byte) error { } return json.Indent(dst, src, "", " ") } + +// AuthorizationSanitizer is used to sanitize Authorization and Proxy-Authorization headers. +func AuthorizationSanitizer(unsafe string) string { + return header.AuthorizationSanitizer(unsafe) +} + +// CookieSanitizer is used to sanitize Cookie header values. +func CookieSanitizer(unsafe string) string { + return header.CookieSanitizer(unsafe) +} + +// SetCookieSanitizer is used to sanitize Set-Cookie header values. +func SetCookieSanitizer(unsafe string) string { + return header.SetCookieSanitizer(unsafe) +} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..a549a45 --- /dev/null +++ b/logger_test.go @@ -0,0 +1,351 @@ +package httpretty + +import ( + "bytes" + "net/http" + "strings" + "sync" + "testing" +) + +func TestSanitizeHeader(t *testing.T) { + t.Parallel() + req, err := http.NewRequest(http.MethodGet, "http://example.com/api", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-Api-Key", "super-secret-key-123") + req.Header.Set("X-Custom-Token", "custom-token-xyz") + req.Header.Set("User-Agent", "TestClient/1.0") + + logger := &Logger{ + RequestHeader: true, + } + logger.SanitizeHeader([]string{"X-Api-Key", "X-Custom-Token"}) + + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.PrintRequest(req) + + output := buf.String() + if !strings.Contains(output, "> X-Api-Key: [REDACTED]") { + t.Errorf("expected X-Api-Key to be redacted, got:\n%s", output) + } + if !strings.Contains(output, "> X-Custom-Token: [REDACTED]") { + t.Errorf("expected X-Custom-Token to be redacted, got:\n%s", output) + } + if !strings.Contains(output, "> User-Agent: TestClient/1.0") { + t.Errorf("expected User-Agent to remain unredacted, got:\n%s", output) + } +} + +func TestSanitizeHeaderDefaultOverride(t *testing.T) { + t.Parallel() + req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer sensitive-token-here") + req.Header.Set("Cookie", "session_id=secret-session-id") + + logger := &Logger{ + RequestHeader: true, + } + // Redact Authorization and Cookie with [REDACTED] instead of default block characters + logger.SanitizeHeader([]string{"Authorization", "Cookie"}) + + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.PrintRequest(req) + + output := buf.String() + if !strings.Contains(output, "> Authorization: [REDACTED]") { + t.Errorf("expected Authorization to be redacted with [REDACTED], got:\n%s", output) + } + if strings.Contains(output, "████████████████████") { + t.Errorf("expected default block sanitization to be overridden by [REDACTED], got:\n%s", output) + } + if !strings.Contains(output, "> Cookie: [REDACTED]") { + t.Errorf("expected Cookie to be redacted with [REDACTED], got:\n%s", output) + } +} + +func TestSanitizeHeaderStructField(t *testing.T) { + t.Parallel() + req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-Secret-Auth", "secret-value") + + logger := &Logger{ + RequestHeader: true, + SanitizeHeaders: []string{"X-Secret-Auth"}, + } + + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.PrintRequest(req) + + output := buf.String() + if !strings.Contains(output, "> X-Secret-Auth: [REDACTED]") { + t.Errorf("expected X-Secret-Auth to be redacted from struct field config, got:\n%s", output) + } +} + +func TestRedactHeadersAlias(t *testing.T) { + t.Parallel() + req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-Token", "secret-token") + + logger := &Logger{ + RequestHeader: true, + } + logger.RedactHeaders([]string{"X-Token"}) + + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.PrintRequest(req) + + output := buf.String() + if !strings.Contains(output, "> X-Token: [REDACTED]") { + t.Errorf("expected X-Token to be redacted using RedactHeaders alias, got:\n%s", output) + } +} + +func TestSetHeaderSanitizer(t *testing.T) { + t.Parallel() + req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-Custom-Token", "supersecret12345") + req.Header.Set("Authorization", "Bearer topsecretpassword") + + logger := &Logger{ + RequestHeader: true, + } + logger.SetHeaderSanitizer(func(name, value string) string { + if strings.EqualFold(name, "X-Custom-Token") { + return value[:5] + "****" + } + return value + }) + + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.PrintRequest(req) + + output := buf.String() + if !strings.Contains(output, "> X-Custom-Token: super****") { + t.Errorf("expected masked X-Custom-Token, got:\n%s", output) + } + // Authorization should fall through to default sanitizers since custom sanitizer returned original value + if !strings.Contains(output, "> Authorization: Bearer ████████████████████") { + t.Errorf("expected Authorization to fall through to default sanitizer, got:\n%s", output) + } +} + +func TestSetHeaderSanitizerOverrideDefault(t *testing.T) { + t.Parallel() + req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer topsecretpassword") + + logger := &Logger{ + RequestHeader: true, + } + logger.SetHeaderSanitizer(func(name, value string) string { + if strings.EqualFold(name, "Authorization") { + return "Bearer [REDACTED]" + } + return value + }) + + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.PrintRequest(req) + + output := buf.String() + if !strings.Contains(output, "> Authorization: Bearer [REDACTED]") { + t.Errorf("expected Authorization to be customized by HeaderSanitizer, got:\n%s", output) + } + if strings.Contains(output, "████████████████████") { + t.Errorf("did not expect default block sanitization, got:\n%s", output) + } +} + +func TestSetHeaderSanitizerPanic(t *testing.T) { + t.Parallel() + req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-Bad-Header", "bad-value") + + logger := &Logger{ + RequestHeader: true, + } + logger.SetHeaderSanitizer(func(name, value string) string { + if name == "X-Bad-Header" { + panic("intentional panic in sanitizer") + } + return value + }) + + var buf bytes.Buffer + logger.SetOutput(&buf) + + // Verify PrintRequest does not crash when HeaderSanitizer panics + logger.PrintRequest(req) + + output := buf.String() + if !strings.Contains(output, "panic while sanitizing header X-Bad-Header") { + t.Errorf("expected panic warning in output, got:\n%s", output) + } + if !strings.Contains(output, "> X-Bad-Header: bad-value") { + t.Errorf("expected fallback to original header value after panic, got:\n%s", output) + } +} + +func TestSanitizeHeaderCaseInsensitive(t *testing.T) { + t.Parallel() + req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil) + if err != nil { + t.Fatal(err) + } + req.Header["x-custom-key"] = []string{"secret-key"} + + logger := &Logger{ + RequestHeader: true, + } + logger.SanitizeHeader([]string{"X-CUSTOM-KEY"}) + + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.PrintRequest(req) + + output := buf.String() + if !strings.Contains(output, "[REDACTED]") { + t.Errorf("expected case-insensitive match to redact header, got:\n%s", output) + } +} + +func TestSanitizeHeaderSkipSanitize(t *testing.T) { + t.Parallel() + req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-Api-Key", "raw-secret-key") + req.Header.Set("Authorization", "Bearer raw-token") + + logger := &Logger{ + RequestHeader: true, + SkipSanitize: true, + } + logger.SanitizeHeader([]string{"X-Api-Key", "Authorization"}) + + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.PrintRequest(req) + + output := buf.String() + if !strings.Contains(output, "> X-Api-Key: raw-secret-key") { + t.Errorf("expected raw X-Api-Key with SkipSanitize=true, got:\n%s", output) + } + if !strings.Contains(output, "> Authorization: Bearer raw-token") { + t.Errorf("expected raw Authorization with SkipSanitize=true, got:\n%s", output) + } +} + +func TestSanitizeHeaderResponse(t *testing.T) { + t.Parallel() + resp := &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Proto: "HTTP/1.1", + Header: http.Header{}, + } + resp.Header.Set("X-Server-Secret", "server-secret-value") + resp.Header.Set("Set-Cookie", "session=12345; Secure") + + logger := &Logger{ + ResponseHeader: true, + } + logger.SanitizeHeader([]string{"X-Server-Secret"}) + + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.PrintResponse(resp) + + output := buf.String() + if !strings.Contains(output, "< X-Server-Secret: [REDACTED]") { + t.Errorf("expected response header X-Server-Secret to be redacted, got:\n%s", output) + } + // Set-Cookie should still be sanitized with default sanitizer + if !strings.Contains(output, "< Set-Cookie: session=████████████████████; Secure") { + t.Errorf("expected Set-Cookie to retain default sanitization, got:\n%s", output) + } +} + +func TestExportedSanitizers(t *testing.T) { + t.Parallel() + // Test exported sanitizers for issue #14 (accessible from outside internal package) + authSanitized := AuthorizationSanitizer("Bearer mysecret123") + if authSanitized != "Bearer ████████████████████" { + t.Errorf("AuthorizationSanitizer error: got %q", authSanitized) + } + + cookieSanitized := CookieSanitizer("session=abc123") + if cookieSanitized != "session=████████████████████" { + t.Errorf("CookieSanitizer error: got %q", cookieSanitized) + } + + setCookieSanitized := SetCookieSanitizer("session=abc123; Path=/; Secure") + if setCookieSanitized != "session=████████████████████; Path=/; Secure" { + t.Errorf("SetCookieSanitizer error: got %q", setCookieSanitized) + } +} + +func TestSanitizeHeaderConcurrency(t *testing.T) { + t.Parallel() + logger := &Logger{ + RequestHeader: true, + } + logger.SanitizeHeader([]string{"X-Token", "Authorization"}) + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil) + if err != nil { + t.Errorf("cannot create request: %v", err) + return + } + req.Header.Set("X-Token", "token-val") + req.Header.Set("Authorization", "Bearer val") + req.Header.Set("Other", "val") + + var buf bytes.Buffer + l := &Logger{ + RequestHeader: true, + } + l.SanitizeHeader([]string{"X-Token"}) + l.SetOutput(&buf) + l.PrintRequest(req) + + if !strings.Contains(buf.String(), "> X-Token: [REDACTED]") { + t.Errorf("missing [REDACTED] in concurrent test") + } + }(i) + } + wg.Wait() +} diff --git a/printer.go b/printer.go index 783c772..a726aa6 100644 --- a/printer.go +++ b/printer.go @@ -739,9 +739,7 @@ func (p *printer) format(s ...any) string { } func (p *printer) printHeaders(prefix rune, h http.Header) { - if !p.logger.SkipSanitize { - h = header.Sanitize(header.DefaultSanitizers, h) - } + h = p.sanitizeHeaders(h) longest, sorted := sortHeaderKeys(h, p.logger.cloneSkipHeader()) for _, key := range sorted { @@ -759,6 +757,63 @@ func (p *printer) printHeaders(prefix rune, h http.Header) { } } +func (p *printer) sanitizeHeaders(h http.Header) http.Header { + if p.logger.SkipSanitize { + return h + } + + sanitizedHeaders := p.logger.cloneSanitizeHeader() + customSanitizer := p.logger.getHeaderSanitizer() + + redacted := http.Header{} + for k, values := range h { + canonicalKey := http.CanonicalHeaderKey(k) + redactedValues := make([]string, len(values)) + for i, v := range values { + if _, ok := sanitizedHeaders[canonicalKey]; ok { + redactedValues[i] = DefaultRedactedValue + continue + } + if customSanitizer != nil { + if sanitizedVal, ok := p.safeHeaderSanitize(customSanitizer, k, v); ok { + redactedValues[i] = sanitizedVal + continue + } + } + if s, ok := header.DefaultSanitizers[canonicalKey]; ok { + redactedValues[i] = s(v) + continue + } + redactedValues[i] = v + } + redacted[k] = redactedValues + } + return redacted +} + +func (p *printer) safeHeaderSanitize(f HeaderSanitizer, name, value string) (string, bool) { + var ( + res string + panicked bool + ) + func() { + defer func() { + if e := recover(); e != nil { + panicked = true + p.printf("* panic while sanitizing header %s: %v\n", p.format(color.FgBlue, name), p.format(color.FgRed, e)) + } + }() + res = f(name, value) + }() + if panicked { + return value, false + } + if res != value { + return res, true + } + return res, false +} + func sortHeaderKeys(h http.Header, skipped map[string]struct{}) (int, []string) { var ( keys = make([]string, 0, len(h))