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
+80
View File
@@ -1994,3 +1994,83 @@ func TestAdminTargets_AccessListsMatchEnforcement_AndCountersReflectTraffic(t *t
t.Errorf("bot2.Access = %v, want %v (restricted's own agents list excludes it)", bot2.Access, want)
}
}
// --- default-preserving: the live cluster's real shape (security audit, 2026-08-22) ---
// TestDefaultPreserving_LiveClusterShape_LoadsAndServesIdentically models
// the live production shape this round's five fixes must not break
// (project brief): 5 "friend" users plus 4 "home" users (steve-cw and
// kevinsandom are named live users; the rest are representative — real
// names this test has no visibility into, which is exactly why finding
// 5's fix stops short of restricting the name charset, see buildEntry's
// own doc comment, auth.go) and 11 MCP servers. A2A agents are out of
// scope here: none of this round's five findings touch mcp_a2a.go or any
// A2A-specific code path, so there is no mechanism by which they could
// regress agent handling.
//
// It proves construction succeeds unchanged (finding 5's new empty/
// duplicate-name checks accept every one of these real names), a normal
// federated tools/list call from an ordinary user still succeeds under a
// realistic per-minute limit (finding 1b's per-backend weighting does not
// spuriously throttle ordinary usage — 11 servers is comfortably under a
// 60/min budget), and the admin dashboard's usage poll still returns
// exactly one row per user and per group in a single store round trip
// (finding 4's chunking is a no-op at 10 users, well under
// adminUsageChunkScopes).
func TestDefaultPreserving_LiveClusterShape_LoadsAndServesIdentically(t *testing.T) {
friendNames := []string{"steve-cw", "kevinsandom", "friend3", "friend4", "friend5"}
homeNames := []string{"home1", "home2", "home3", "home4"}
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Admin = &AdminConfig{Enabled: true}
cfg.Groups = map[string]*GroupConfig{
"friends": {Limits: &LimitsConfig{RequestsPerMinute: 60}},
"home": {Limits: &LimitsConfig{RequestsPerMinute: 120}},
}
cfg.MCPServers = make(map[string]*TargetConfig, 11)
for i := 0; i < 11; i++ {
srv := newMockJSONRPCServer(t, []mcpTool{{Name: "lookup"}})
cfg.MCPServers[fmt.Sprintf("mcp%d", i)] = &TargetConfig{URL: srv.srv.URL}
}
inline := []*UserConfig{{Name: "admin1", Group: "home", Admin: true, APIKey: "sk-admin1"}}
for _, name := range friendNames {
inline = append(inline, &UserConfig{Name: name, Group: "friends", APIKey: "sk-" + name})
}
for _, name := range homeNames {
inline = append(inline, &UserConfig{Name: name, Group: "home", APIKey: "sk-" + name})
}
cfg.Users = &UsersConfig{Inline: inline}
h, err := New(context.Background(), http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), cfg, "llmgw")
if err != nil {
t.Fatalf("New: want the realistic live-cluster config to load unchanged, got error: %v", err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, newFederatedRequest(t, "sk-steve-cw", jsonrpcRequest{JSONRPC: jsonrpcVersion, Method: "tools/list", ID: json.RawMessage("1")}))
if rec.Code != http.StatusOK {
t.Fatalf("tools/list status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
usageReq := httptest.NewRequest("GET", adminUsagePath, nil)
usageReq.Header.Set("Authorization", "Bearer sk-admin1")
usageRec := httptest.NewRecorder()
h.ServeHTTP(usageRec, usageReq)
if usageRec.Code != http.StatusOK {
t.Fatalf("admin usage status = %d, want 200, body=%s", usageRec.Code, usageRec.Body.String())
}
var usage adminUsageResponse
if err := json.Unmarshal(usageRec.Body.Bytes(), &usage); err != nil {
t.Fatalf("decode admin usage: %v", err)
}
wantUsers := len(friendNames) + len(homeNames) + 1 // + admin1
if len(usage.Users) != wantUsers {
t.Errorf("admin usage users = %d, want %d", len(usage.Users), wantUsers)
}
if len(usage.Groups) != 2 {
t.Errorf("admin usage groups = %d, want 2", len(usage.Groups))
}
}