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:
2026-10-15 22:20:07 +01:00
parent 8c4bd16a22
commit d0d29b8c52
10 changed files with 272 additions and 6 deletions
+40 -1
View File
@@ -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]")
}