mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user