From 6df3ea3cf7a82c053488b77fd11ed442dc713e95 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 24 May 2026 15:25:09 +0200 Subject: [PATCH] test(proxy): add unit tests for redacting sensitive fields in logs Introduced `TestRedactMappingForLog_ScrubsSensitiveFields` and `TestRedactMappingForLog_HandlesEmptyOrNilFields` to verify redaction behavior on mappings. Sensitive fields like `auth_token`, header-auth values, and custom headers are replaced with placeholder values for added security in debug logs. --- proxy/server.go | 34 +++++++++++++++++++++++-- proxy/server_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/proxy/server.go b/proxy/server.go index 7faca6a94..33c0b93f0 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -38,6 +38,7 @@ import ( "google.golang.org/grpc/keepalive" grpcstatus "google.golang.org/grpc/status" "google.golang.org/protobuf/encoding/protojson" + goproto "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/netbirdio/netbird/proxy/internal/accesslog" @@ -1417,12 +1418,41 @@ var mappingJSONMarshal = protojson.MarshalOptions{ UseProtoNames: true, } +// redactMappingForLog returns a deep copy of the mapping with sensitive fields +// (auth_token, header-auth hashed values, custom upstream headers) replaced so +// debug logs never carry credentials. +func redactMappingForLog(m *proto.ProxyMapping) *proto.ProxyMapping { + const placeholder = "[REDACTED]" + c := goproto.Clone(m).(*proto.ProxyMapping) + if c.GetAuthToken() != "" { + c.AuthToken = placeholder + } + if c.Auth != nil { + for _, h := range c.Auth.GetHeaderAuths() { + if h.GetHashedValue() != "" { + h.HashedValue = placeholder + } + } + } + for _, p := range c.GetPath() { + opts := p.GetOptions() + if opts == nil || len(opts.CustomHeaders) == 0 { + continue + } + redacted := make(map[string]string, len(opts.CustomHeaders)) + for k := range opts.CustomHeaders { + redacted[k] = placeholder + } + opts.CustomHeaders = redacted + } + return c +} + func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMapping) { - // The full proto dump carries auth_token and header-auth values; gate on debug. debug := s.Logger != nil && s.Logger.IsLevelEnabled(log.DebugLevel) for _, mapping := range mappings { if debug { - raw, err := mappingJSONMarshal.Marshal(mapping) + raw, err := mappingJSONMarshal.Marshal(redactMappingForLog(mapping)) if err != nil { raw = []byte(fmt.Sprintf("", err)) } diff --git a/proxy/server_test.go b/proxy/server_test.go index 549ac65f0..10d38f250 100644 --- a/proxy/server_test.go +++ b/proxy/server_test.go @@ -10,6 +10,8 @@ import ( log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/proto" ) func TestDebugEndpointDisabledByDefault(t *testing.T) { @@ -143,3 +145,60 @@ func TestStopSkipsShutdownWhenNeverStarted(t *testing.T) { err := srv.Stop(ctx) assert.NoError(t, err, "Stop on an unstarted server should not block on the cancelled ctx") } + +func TestRedactMappingForLog_ScrubsSensitiveFields(t *testing.T) { + original := &proto.ProxyMapping{ + Id: "svc-1", + Domain: "example.com", + AuthToken: "super-secret-token", + Auth: &proto.Authentication{ + SessionKey: "pubkey-not-secret", + HeaderAuths: []*proto.HeaderAuth{ + {Header: "Authorization", HashedValue: "argon2-hash-1"}, + {Header: "X-Api-Key", HashedValue: "argon2-hash-2"}, + }, + }, + Path: []*proto.PathMapping{ + { + Path: "/api", + Target: "10.0.0.1:8080", + Options: &proto.PathTargetOptions{ + CustomHeaders: map[string]string{ + "Authorization": "Bearer upstream-token", + "X-Tenant": "acme", + }, + }, + }, + }, + } + + redacted := redactMappingForLog(original) + + assert.Equal(t, "super-secret-token", original.AuthToken, "original must not be mutated") + assert.Equal(t, "argon2-hash-1", original.Auth.HeaderAuths[0].HashedValue, "original header hash must not be mutated") + assert.Equal(t, "Bearer upstream-token", original.Path[0].Options.CustomHeaders["Authorization"], "original custom header must not be mutated") + + assert.Equal(t, "[REDACTED]", redacted.AuthToken, "auth_token must be redacted") + require.Len(t, redacted.Auth.HeaderAuths, 2, "header auths must be preserved in count") + assert.Equal(t, "Authorization", redacted.Auth.HeaderAuths[0].Header, "header name must be preserved") + assert.Equal(t, "[REDACTED]", redacted.Auth.HeaderAuths[0].HashedValue, "hashed_value must be redacted") + assert.Equal(t, "[REDACTED]", redacted.Auth.HeaderAuths[1].HashedValue, "hashed_value must be redacted for every header auth") + assert.Equal(t, "pubkey-not-secret", redacted.Auth.SessionKey, "session_key (public) must be preserved") + + headers := redacted.Path[0].Options.CustomHeaders + require.Len(t, headers, 2, "custom header keys must be preserved") + assert.Equal(t, "[REDACTED]", headers["Authorization"], "custom header values must be redacted") + assert.Equal(t, "[REDACTED]", headers["X-Tenant"], "every custom header value must be redacted") + + assert.Equal(t, "svc-1", redacted.Id, "non-sensitive fields must round-trip") + assert.Equal(t, "example.com", redacted.Domain, "non-sensitive fields must round-trip") +} + +func TestRedactMappingForLog_HandlesEmptyOrNilFields(t *testing.T) { + empty := &proto.ProxyMapping{Id: "svc-empty"} + redacted := redactMappingForLog(empty) + + assert.Equal(t, "", redacted.AuthToken, "empty auth_token must remain empty (no placeholder)") + assert.Nil(t, redacted.Auth, "nil Auth must remain nil") + assert.Empty(t, redacted.Path, "empty Path must remain empty") +}