mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
fix(mcp): stop weighting tools/list budget, widen fan-out, split caps
Addresses critical finding 3 and important findings 3-4 from security review round 2, 2026-08-22. Critical finding 3 — the tools/list-per-backend weight charged against the caller's own requests-per-minute/day budget (security audit finding 1b) was not default-preserving: both live groups have an empty MCP allow-list (all 11 servers), so it silently cut every configured requests-per-minute budget by ~11x with no config change, and examples/kubernetes.yaml's shipped requests-per-minute: 10 would 429 the very first tools/list call ever made. Reverted: checkAndCountWeighted removed from limits.go, handleMCPFederated back to a plain checkAndCount. The real amplification stays fully visible to an operator via countTargetRequests' existing per-server 'mcp' target-scope counters — a separate scope from the user/group budget — rather than silently consuming tenant quota. The fan-out semaphore (below) is the actual protection against the amplification finding 1 described. Important finding 3 — the fan-out semaphore (4 concurrent backends) tripled worst-case latency (907ms measured at 300ms/server, was ~300ms) and pushed the hung-backend worst case to ceil(11/4)x20s=60s, past the ~30s timeout many MCP clients use. Raised to 8, and every backend in one tools/list call now shares ONE overall toolsListBackendTimeout deadline (fanoutCtx) instead of each getting a fresh one from its own staggered start — restoring this function's original 'bounds the whole call regardless of server count' invariant even with bounded concurrency. Important finding 4 — the 4MiB response cap (sized for the tools/list fan-out's memory-amplification risk) silently applied to tools/call too, where exactly one backend is ever contacted and a legitimate result (an image, an extracted document) can be much larger. tools/call now uses mcpBackendCallResponseMaxBytes (10MiB, the pre-finding-1c budget); tools/list keeps the 4MiB cap. A response that hits either cap is detected via a cap+1 read (never silently truncated into a confusing parse failure) and reported to the client as 'response too large'.
This commit is contained in:
+33
-22
@@ -1998,34 +1998,39 @@ func TestAdminTargets_AccessListsMatchEnforcement_AndCountersReflectTraffic(t *t
|
||||
// --- 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.
|
||||
// the live production shape this round's 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 fixes 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).
|
||||
// duplicate-name checks accept every one of these real names), a
|
||||
// friends-group user's configured requests-per-minute budget still buys
|
||||
// EXACTLY that many tools/list calls against all 11 servers — not
|
||||
// budget/11 — regressing security review round 2's critical finding 3
|
||||
// (weighting tools/list by backend count was reverted specifically
|
||||
// because it was not default-preserving at this exact shape: both live
|
||||
// groups have an empty MCP allow-list, i.e. all 11 servers, so a naive
|
||||
// per-backend weight would have silently cut every configured
|
||||
// requests-per-minute budget by ~11x here), 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"}
|
||||
const friendsRPM = 60
|
||||
|
||||
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}},
|
||||
"friends": {Limits: &LimitsConfig{RequestsPerMinute: friendsRPM}},
|
||||
"home": {Limits: &LimitsConfig{RequestsPerMinute: 120}},
|
||||
}
|
||||
|
||||
@@ -2049,10 +2054,16 @@ func TestDefaultPreserving_LiveClusterShape_LoadsAndServesIdentically(t *testing
|
||||
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())
|
||||
// Drives friendsRPM calls, not just one (security review round 2:
|
||||
// a single-call assertion cannot distinguish "budget buys N calls"
|
||||
// from "budget buys N/11 calls" — exactly the blind spot that let the
|
||||
// weighted-charge regression ship undetected).
|
||||
for i := 1; i <= friendsRPM; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, newFederatedRequest(t, "sk-steve-cw", jsonrpcRequest{JSONRPC: jsonrpcVersion, Method: "tools/list", ID: json.RawMessage(fmt.Sprintf("%d", i))}))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("tools/list call %d/%d status = %d, want 200 (budget must buy all %d calls against 11 servers), body=%s", i, friendsRPM, rec.Code, friendsRPM, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
usageReq := httptest.NewRequest("GET", adminUsagePath, nil)
|
||||
|
||||
Reference in New Issue
Block a user