mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-17 05:37:43 +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:
@@ -1081,39 +1081,14 @@ func buildBudgetProbes(scopes []limitScope, now time.Time) (probes []budgetProbe
|
||||
// call completes, one request may still push a scope over its budget — an
|
||||
// accepted trade-off, unchanged from before this round.
|
||||
func (l *limiter) checkAndCount(scopes []limitScope) *limitViolation {
|
||||
return l.checkAndCountWeighted(scopes, 1)
|
||||
}
|
||||
|
||||
// checkAndCountWeighted is checkAndCount generalized to charge `weight`
|
||||
// requests against every scope's requests-per-minute/requests-per-day
|
||||
// counters instead of exactly one (security audit finding 1b, 2026-08-22):
|
||||
// a route whose single incoming client request causes this gateway to
|
||||
// issue `weight` outbound backend calls — today, only the federated MCP
|
||||
// tools/list fan-out (mcp_federation.go's handleMCPFederated, weight =
|
||||
// len(allowedMCPServerNames(grp))) — must charge its real cost, not
|
||||
// undercount a fan-out as a single request. weight<=0 is treated as 1
|
||||
// (never zero-cost, never negative); checkAndCount(scopes) is exactly
|
||||
// checkAndCountWeighted(scopes, 1), so every other call site's behavior is
|
||||
// completely unchanged by this method existing. Semantics beyond the
|
||||
// weighted delta are identical to checkAndCount's own doc comment in
|
||||
// every other respect: one storeIncrAndGetMulti round trip, requests-
|
||||
// per-minute/requests-per-day compared against the just-incremented
|
||||
// (now weighted) count, token/cost budgets read and compared unweighted
|
||||
// (a fan-out has no tokens or USD cost of its own — those probes come
|
||||
// from buildBudgetProbes, untouched by weight), violation precedence
|
||||
// unchanged.
|
||||
func (l *limiter) checkAndCountWeighted(scopes []limitScope, weight int64) *limitViolation {
|
||||
if weight <= 0 {
|
||||
weight = 1
|
||||
}
|
||||
now := l.now()
|
||||
|
||||
entries := make([]counterIncr, 0, len(scopes)*checkAndCountKeysPerScope)
|
||||
for _, sc := range scopes {
|
||||
entries = append(entries,
|
||||
newCounterIncr(sc.kind, sc.id, metricReq, windowMin, now, weight, minWindowTTL),
|
||||
newCounterIncr(sc.kind, sc.id, metricReq, windowDay, now, weight, dayWindowTTL),
|
||||
newCounterIncr(sc.kind, sc.id, metricReq, windowHour, now, weight, hourWindowTTL),
|
||||
newCounterIncr(sc.kind, sc.id, metricReq, windowMin, now, 1, minWindowTTL),
|
||||
newCounterIncr(sc.kind, sc.id, metricReq, windowDay, now, 1, dayWindowTTL),
|
||||
newCounterIncr(sc.kind, sc.id, metricReq, windowHour, now, 1, hourWindowTTL),
|
||||
)
|
||||
}
|
||||
probes, reads := buildBudgetProbes(scopes, now)
|
||||
|
||||
Reference in New Issue
Block a user