Fix SEC-11: use json.Marshal for audit details
- Add internal/audit package with JSON() and JSONWithRoles() helpers
that use json.Marshal instead of fmt.Sprintf with %q
- Replace all fmt.Sprintf audit detail construction in:
- internal/server/server.go (10 occurrences)
- internal/ui/handlers_auth.go (4 occurrences)
- internal/grpcserver/auth.go (4 occurrences)
- Add tests for the helpers including edge-case Unicode,
null bytes, special characters, and odd argument counts
- Fix broken {"roles":%v} formatting that produced invalid JSON
Security: Audit log detail strings are now constructed via
json.Marshal, which correctly handles all Unicode edge cases
(U+2028, U+2029, null bytes, etc.) that fmt.Sprintf with %q
may mishandle. This prevents potential log injection or parsing
issues in audit event consumers.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
33
internal/audit/detail.go
Normal file
33
internal/audit/detail.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Package audit provides helpers for constructing audit log detail strings.
|
||||
package audit
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// JSON builds a JSON details string from key-value pairs for audit logging.
|
||||
// Uses json.Marshal for safe encoding rather than fmt.Sprintf with %q,
|
||||
// which is fragile for edge-case Unicode.
|
||||
func JSON(pairs ...string) string {
|
||||
if len(pairs)%2 != 0 {
|
||||
return "{}"
|
||||
}
|
||||
m := make(map[string]string, len(pairs)/2)
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
m[pairs[i]] = pairs[i+1]
|
||||
}
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// JSONWithRoles builds a JSON details string that includes a "roles" key
|
||||
// mapped to a string slice. This produces a proper JSON array for the value.
|
||||
func JSONWithRoles(roles []string) string {
|
||||
m := map[string][]string{"roles": roles}
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
163
internal/audit/detail_test.go
Normal file
163
internal/audit/detail_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pairs []string
|
||||
verify func(t *testing.T, result string)
|
||||
}{
|
||||
{
|
||||
name: "single pair",
|
||||
pairs: []string{"username", "alice"},
|
||||
verify: func(t *testing.T, result string) {
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if m["username"] != "alice" {
|
||||
t.Fatalf("expected alice, got %s", m["username"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple pairs",
|
||||
pairs: []string{"jti", "abc-123", "reason", "logout"},
|
||||
verify: func(t *testing.T, result string) {
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if m["jti"] != "abc-123" {
|
||||
t.Fatalf("expected abc-123, got %s", m["jti"])
|
||||
}
|
||||
if m["reason"] != "logout" {
|
||||
t.Fatalf("expected logout, got %s", m["reason"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "special characters in values",
|
||||
pairs: []string{"username", "user\"with\\quotes"},
|
||||
verify: func(t *testing.T, result string) {
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||
t.Fatalf("invalid JSON for special chars: %v", err)
|
||||
}
|
||||
if m["username"] != "user\"with\\quotes" {
|
||||
t.Fatalf("unexpected value: %s", m["username"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unicode edge cases",
|
||||
pairs: []string{"username", "user\u2028line\u2029sep"},
|
||||
verify: func(t *testing.T, result string) {
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||
t.Fatalf("invalid JSON for unicode: %v", err)
|
||||
}
|
||||
if m["username"] != "user\u2028line\u2029sep" {
|
||||
t.Fatalf("unexpected value: %s", m["username"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "null bytes in value",
|
||||
pairs: []string{"data", "before\x00after"},
|
||||
verify: func(t *testing.T, result string) {
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||
t.Fatalf("invalid JSON for null bytes: %v", err)
|
||||
}
|
||||
if m["data"] != "before\x00after" {
|
||||
t.Fatalf("unexpected value: %q", m["data"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "odd number of args returns empty object",
|
||||
pairs: []string{"key"},
|
||||
verify: func(t *testing.T, result string) {
|
||||
if result != "{}" {
|
||||
t.Fatalf("expected {}, got %s", result)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no args returns empty object",
|
||||
pairs: nil,
|
||||
verify: func(t *testing.T, result string) {
|
||||
if result != "{}" {
|
||||
t.Fatalf("expected {}, got %s", result)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := JSON(tc.pairs...)
|
||||
tc.verify(t, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONWithRoles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
roles []string
|
||||
verify func(t *testing.T, result string)
|
||||
}{
|
||||
{
|
||||
name: "multiple roles",
|
||||
roles: []string{"admin", "editor"},
|
||||
verify: func(t *testing.T, result string) {
|
||||
var m map[string][]string
|
||||
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(m["roles"]) != 2 || m["roles"][0] != "admin" || m["roles"][1] != "editor" {
|
||||
t.Fatalf("unexpected roles: %v", m["roles"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty roles",
|
||||
roles: []string{},
|
||||
verify: func(t *testing.T, result string) {
|
||||
var m map[string][]string
|
||||
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(m["roles"]) != 0 {
|
||||
t.Fatalf("expected empty roles, got %v", m["roles"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "roles with special characters",
|
||||
roles: []string{"role\"special"},
|
||||
verify: func(t *testing.T, result string) {
|
||||
var m map[string][]string
|
||||
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if m["roles"][0] != "role\"special" {
|
||||
t.Fatalf("unexpected role: %s", m["roles"][0])
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := JSONWithRoles(tc.roles)
|
||||
tc.verify(t, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user