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:
2026-08-22 15:26:38 +01:00
parent 3536c32667
commit 452be5151c
4 changed files with 389 additions and 201 deletions
+3 -28
View File
@@ -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)