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.
This commit is contained in:
mlsmaycon
2026-05-24 15:25:09 +02:00
parent eadd6818a4
commit 6df3ea3cf7
2 changed files with 91 additions and 2 deletions

View File

@@ -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("<marshal error: %v>", err))
}

View File

@@ -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")
}