mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-20 05:56:56 +00:00
fix(security): bound fan-out and method surface and scrub relayed text
- Add a process-wide gate on MCP tools/list backend connections. The per-request limit alone let N callers open N times as many. - Reject CONNECT and TRACE on native passthrough and on MCP and A2A targets with 405. - Scrub URL query strings and the target URL from a backend JSON-RPC error message before it reaches the caller. - Replace the value of redis.address in GET /admin/api/config. The users file path stays visible by design. - Escape line breaks in every log line so an upstream string cannot forge log entries. - Add stripClientAnthropicBeta. When true, POST /v1/messages does not forward the client anthropic-beta header. The default stays false.
This commit is contained in:
+40
-1
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -1137,6 +1138,10 @@ func lastSSEDataLine(body []byte) []byte {
|
||||
// concurrency or backend count — see its own doc comment.
|
||||
const mcpFederatedFanoutConcurrency = 8
|
||||
|
||||
// mcpFederatedFanoutGlobalConcurrency caps the backend connections every
|
||||
// concurrent federated tools/list call may hold together.
|
||||
const mcpFederatedFanoutGlobalConcurrency = 32
|
||||
|
||||
// mcpFederatedToolsList implements the federated "tools/list": it fans
|
||||
// out one per-server fetch (below) per server in names (handleMCPFederated's
|
||||
// own allowedMCPServerNames(grp) call, made once there so the exact set
|
||||
@@ -1281,6 +1286,23 @@ func (g *Gateway) mcpFederatedToolsList(w http.ResponseWriter, format mcpRespons
|
||||
}
|
||||
defer func() { <-sem }()
|
||||
|
||||
// The per-request semaphore above bounds one tools/list call;
|
||||
// fanoutGate bounds every concurrent call together, so N
|
||||
// callers cannot open N x mcpFederatedFanoutConcurrency
|
||||
// connections to the backends. A nil gate (a Gateway built
|
||||
// without New) is unbounded, as before.
|
||||
if g.fanoutGate != nil {
|
||||
select {
|
||||
case g.fanoutGate <- struct{}{}:
|
||||
defer func() { <-g.fanoutGate }()
|
||||
case <-fanoutCtx.Done():
|
||||
mu.Lock()
|
||||
failed = append(failed, name)
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// F10: this goroutine won the semaphore, so it is about to
|
||||
// actually dial the server — recorded now, unconditionally,
|
||||
// regardless of what the paginated fetch below returns, so
|
||||
@@ -1629,5 +1651,22 @@ func (g *Gateway) mcpFederatedToolsCall(w http.ResponseWriter, format mcpRespons
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONRPCEnvelope(w, format, jsonrpcResponse{ID: req.ID, Result: resp.Result, Error: resp.Error})
|
||||
relayed := resp.Error
|
||||
if relayed != nil {
|
||||
scrubbed := *relayed
|
||||
scrubbed.Message = scrubRelayedBackendMessage(relayed.Message, g.cfg.MCPServers[serverName].URL)
|
||||
relayed = &scrubbed
|
||||
}
|
||||
writeJSONRPCEnvelope(w, format, jsonrpcResponse{ID: req.ID, Result: resp.Result, Error: relayed})
|
||||
}
|
||||
|
||||
// urlQueryPattern matches the query string of any URL in free text.
|
||||
var urlQueryPattern = regexp.MustCompile(`(https?://[^\s?#"']+)\?[^\s#"']*`)
|
||||
|
||||
// scrubRelayedBackendMessage removes credentials a backend's own error text
|
||||
// can carry before the text reaches a caller: the configured target URL in
|
||||
// any form, and the query string of every URL, where API keys travel.
|
||||
func scrubRelayedBackendMessage(msg, targetURL string) string {
|
||||
msg = sanitizeProviderErr(msg, targetURL)
|
||||
return urlQueryPattern.ReplaceAllString(msg, "$1?[redacted]")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user