fix(auth): reject duplicate and empty user names

Fixes finding 5 from the 2026-08-22 security audit: newAuthStore and
replaceFileUsers rejected duplicate API keys but not duplicate names, and
counters are keyed on u.name (routes_unified.go), so two users sharing a
name silently shared llmgw:user:{name}:* counters and both appeared in
buildAdminUsage; an empty name collapsed every such user into
llmgw:user::*.

- buildEntry now rejects an empty user name outright — the source of
  both bugs, since duplicate names and an empty name are the same root
  cause (two config entries resolving to the same counter key).
- newAuthStore and replaceFileUsers each reject a duplicate name WITHIN
  their own source (two inline users, or two file users) — but not
  across sources: a file user overriding an inline user of the same name
  stays intentional and unaffected, per replaceFileUsers' own documented
  merge semantics.
- Deliberately does NOT extend configNamePattern's character
  restriction to user names: that pattern exists because provider/
  mcpServers/agents names are embedded as URL path segments, which a
  user name never is here. Restricting the charset further risked
  rejecting real operator-chosen names this round has no visibility
  into; the live users file's steve-cw/kevinsandom already pass the
  non-empty check unaffected.

Also adds TestDefaultPreserving_LiveClusterShape_LoadsAndServesIdentically
(admin_test.go), modeling the live cluster's 5 friends + 4 home users and
11 MCP servers to prove all five fixes in this round load and serve
identically at that scale.
This commit is contained in:
2026-08-22 15:26:32 +01:00
parent 800f63ac53
commit d2a0c4860f
3 changed files with 220 additions and 0 deletions
+96
View File
@@ -3,6 +3,7 @@ package traefikllmgateway
import (
"fmt"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
@@ -719,3 +720,98 @@ func TestAuthStore_Identify_SuccessfulAuthDoesNotRecordFailure(t *testing.T) {
}
}
}
// --- security audit finding 5: duplicate/empty user names ---
func TestNewAuthStore_EmptyUserName_ReturnsError(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{{Name: "", Group: "eng", APIKey: "sk-secret"}}},
}
if _, err := newAuthStore(cfg); err == nil {
t.Fatal("want error for an empty user name")
}
}
func TestNewAuthStore_DuplicateUserName_ReturnsError(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{
{Name: "dup", Group: "eng", APIKey: "sk-one"},
{Name: "dup", Group: "eng", APIKey: "sk-two"},
}},
}
_, err := newAuthStore(cfg)
if err == nil {
t.Fatal("want error for two inline users sharing the same name")
}
if !strings.Contains(err.Error(), "duplicate user name") {
t.Errorf("error = %q, want it to mention the duplicate-name check specifically (not the API-key one)", err.Error())
}
}
func TestAuthStore_ReplaceFileUsers_EmptyUserName_ReturnsError(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
if err := a.replaceFileUsers([]*UserConfig{{Name: "", Group: "eng", APIKey: "sk-file1"}}); err == nil {
t.Fatal("want error for a file user with an empty name")
}
}
func TestAuthStore_ReplaceFileUsers_DuplicateUserNameWithinFile_ReturnsError(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
err = a.replaceFileUsers([]*UserConfig{
{Name: "dup", Group: "eng", APIKey: "sk-file1"},
{Name: "dup", Group: "eng", APIKey: "sk-file2"},
})
if err == nil {
t.Fatal("want error for two file users sharing the same name")
}
if !strings.Contains(err.Error(), "duplicate user name") {
t.Errorf("error = %q, want it to mention the duplicate-name check specifically (not the API-key one)", err.Error())
}
// The store must still be usable — the rejected replace must not have
// partially mutated it.
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.Header.Set("Authorization", "Bearer sk-secret")
if _, _, ok := a.identify(r); !ok {
t.Fatal("want the original inline user still resolvable after a rejected duplicate-name replace")
}
}
// TestAuthStore_ReplaceFileUsers_SameNameAsInline_DifferentKey_OverrideAllowed
// proves the new duplicate-name check does NOT reject the intentional
// cross-source override replaceFileUsers' own doc comment describes: a
// file user of the same name as an inline user, even with a different API
// key, must still win — this is a feature (rotating/promoting a
// statically-configured user through the hot-reloadable file), not a
// duplicate.
func TestAuthStore_ReplaceFileUsers_SameNameAsInline_DifferentKey_OverrideAllowed(t *testing.T) {
a, err := newAuthStore(testAuthCfg()) // inline user "a" / sk-secret, group eng
if err != nil {
t.Fatal(err)
}
if err := a.replaceFileUsers([]*UserConfig{{Name: "a", Group: "eng", APIKey: "sk-file-a"}}); err != nil {
t.Fatalf("replaceFileUsers: want the same-name override allowed, got error: %v", err)
}
inlineReq := httptest.NewRequest("POST", "/v1/chat/completions", nil)
inlineReq.Header.Set("Authorization", "Bearer sk-secret")
if _, _, ok := a.identify(inlineReq); ok {
t.Fatal("want the inline key overridden once the file defines a same-named user")
}
fileReq := httptest.NewRequest("POST", "/v1/chat/completions", nil)
fileReq.Header.Set("Authorization", "Bearer sk-file-a")
u, _, ok := a.identify(fileReq)
if !ok || u.name != "a" {
t.Fatalf("want the file-sourced user resolvable under the overridden name, got %v ok=%v", u, ok)
}
}