fix(mcp): recover panics in tools/list fan-out goroutines

Fixes finding 2 from the 2026-08-22 security audit: the per-server
goroutines in mcpFederatedToolsList carried only defer wg.Done(), unlike
every other off-request goroutine in this package (limiter.spawn,
modelRegistry.refreshProvider/captureModelMetadata), which all guard
against a panic crashing the whole shared Traefik process. This codebase
has repeatedly hit interpreter-level panics (registry.go reflect.Set,
cache.go CFG index-out-of-range, providers.go errors.As), so an
unrecovered panic here is not hypothetical.

Add the same defer-recover pattern used at the other three sites: a
panicking backend now degrades to "that server failed" and is logged,
exactly like an HTTP error from it, instead of taking down the process.
This commit is contained in:
2026-08-22 15:26:22 +01:00
parent 26ff1a8b5b
commit 8bc54d15b5
2 changed files with 104 additions and 2 deletions
+21 -2
View File
@@ -703,7 +703,7 @@ const mcpFederatedFanoutConcurrency = 4
// replaces used, and the one pugbot's mcpclient and agentkit have already
// persisted into their own tool-id databases (e.g.
// "brave-search_brave_web_search" — verified live). A server that errors,
// times out, or returns an unparsable/invalid result is logged
// times out, panics, or returns an unparsable/invalid result is logged
// and skipped, not surfaced as a whole-call failure — UNLESS every single
// attempted server failed, in which case an empty tools list would
// misleadingly look like "this caller's group has no MCP access" — see
@@ -712,7 +712,7 @@ const mcpFederatedFanoutConcurrency = 4
// countTargetRequests (limits.go) attributes one request to EVERY server
// ATTEMPTED here, in ONE batched call after wg.Wait() — "attempted", not
// "reached" or "succeeded": a server this gateway dialed and got a
// response (or a timeout, or a connection refusal) from still had
// response (or a timeout, a connection refusal, or a panic) from still had
// a real request sent to it and a real slot of this gateway's outbound
// capacity spent on it, which is what these counters exist to track. A
// single federated tools/list call can move several targets' own
@@ -730,6 +730,25 @@ func (g *Gateway) mcpFederatedToolsList(w http.ResponseWriter, r *http.Request,
wg.Add(1)
go func(name string) {
defer wg.Done()
// Unrecovered-panic guard (security audit finding 2,
// 2026-08-22): this goroutine runs off the request's own
// goroutine, so a panic here has no ServeHTTP caller to
// unwind into and would crash the whole shared Traefik
// process — the same reasoning limiter.spawn (limits.go) and
// modelRegistry.refreshProvider/captureModelMetadata
// (registry.go) already apply to their own off-request
// goroutines. A panicking backend must degrade to "that
// server failed", exactly like an HTTP error from it, never
// a process-wide crash.
defer func() {
if rec := recover(); rec != nil {
g.logf("federated tools/list: server %q panicked: %v", name, rec)
mu.Lock()
failed = append(failed, name)
mu.Unlock()
}
}()
sem <- struct{}{}
defer func() { <-sem }()