mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
fix(routes): admit before decode, bound in-flight bodies, narrow unified cap
Close a HIGH-severity OOM path on the unified and media JSON routes (security review finding 1). Three parts: - checkAndCount now runs via a new admitRequest helper BEFORE the request body is read at all, on both routes_unified.go's runUnified and every routes_media.go handler. The rate-limit scopes need only the already-resolved user/group, so a caller already over their requestsPerMinute is refused without paying for the read+decode of a body that was always going to be discarded. Measured: an encoding/json decode into map[string]any costs ~11x the wire body in live heap, so the prior ordering let an already-limited caller burn ~110MiB per request before ever seeing the 429. - A new bounded, buffered-channel semaphore (Gateway.bodyAdmission, mirroring limits.go's spawnTokens shape) caps how many unified/media requests may concurrently hold a decoded body in memory. Acquired non-blockingly at each route's entry via acquireBodyAdmission; on exhaustion, returns 503 with Retry-After rather than blocking. Sized by defaultBodyAdmissionCap (GOMAXPROCS * 8, clamped to [16, 128]); Config.MaxInFlightBodyRequests, when set, always overrides the self-tuned default. - The unified chat/embeddings routes now decode against a new, narrower maxUnifiedRequestBytes (4MiB) instead of the shared maxRequestBytes (10MiB), which the media JSON routes, audio/transcriptions' multipart cap, and the MCP federation JSON-RPC routes keep unchanged. 1MiB (the audit's own first suggestion) was evaluated and rejected: this package documents and exercises inline base64 image_url content parts in chat messages (translate_anthropic.go, translate_gemini.go), and a single moderately-sized photo commonly exceeds 1MiB once base64-encoded. 4MiB preserves that documented single-inline-image case while still cutting worst-case per-request decode amplification from ~110MiB to ~44MiB. routes_media.go's resolveMediaRequest is split: model resolution stays (renamed resolveMediaModel), the limit check moves into the shared admitRequest. Tests: admission-before-decode is asserted directly (a 429'd request's body is never Read), semaphore exhaustion is asserted deterministically by filling the channel directly, the self-tune/override contract and its clamp bounds are pinned, and a zero-config run of ordinary traffic is asserted to see no new 503s. A dedicated test proves the chosen 4MiB cap still accepts a realistic ~1.5MiB inline image — the case 1MiB would have broken.
This commit is contained in:
+77
-1
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Config is the plugin's dynamic configuration, populated by Traefik from
|
||||
@@ -61,6 +62,22 @@ type Config struct {
|
||||
// above: its own Enabled field is the on/off signal.
|
||||
Cache CacheConfig `json:"cache"`
|
||||
PassthroughUnknown bool `json:"passthroughUnknown,omitempty"`
|
||||
// MaxInFlightBodyRequests caps how many unified and media JSON/
|
||||
// multipart requests (routes_unified.go's runUnified; routes_media.go's
|
||||
// handleImagesGenerations/handleAudioSpeech/handleAudioTranscriptions)
|
||||
// may concurrently hold a decoded request body in memory at once
|
||||
// (security review finding 1b, 2026-08-22) — see
|
||||
// acquireBodyAdmission's own doc comment (routes_unified.go) for the
|
||||
// OOM this bounds. 0 (the default) self-tunes from GOMAXPROCS
|
||||
// (defaultBodyAdmissionCap) rather than adding an operator knob for a
|
||||
// value the runtime can already infer (house rule: prefer self-tuning
|
||||
// over operator knobs where the right value can be inferred; add a
|
||||
// flag only when it genuinely cannot be). A positive value pins an
|
||||
// exact cap instead and always wins over the auto-tuned default
|
||||
// (house rule: an explicit override always wins) — e.g. a deployment
|
||||
// with a known, tighter memory budget than GOMAXPROCS alone would
|
||||
// infer.
|
||||
MaxInFlightBodyRequests int `json:"maxInFlightBodyRequests,omitempty"`
|
||||
}
|
||||
|
||||
// ProviderConfig describes one upstream LLM provider.
|
||||
@@ -303,6 +320,45 @@ func CreateConfig() *Config {
|
||||
return &Config{}
|
||||
}
|
||||
|
||||
// minBodyAdmissionCap/maxBodyAdmissionCap clamp defaultBodyAdmissionCap's
|
||||
// self-tuned result (security review finding 1b, 2026-08-22): the floor
|
||||
// keeps a single-vCPU sidecar from self-tuning down to a cap so small it
|
||||
// throttles ordinary traffic, and the ceiling keeps a very large host from
|
||||
// self-tuning up to a cap so high the semaphore stops bounding anything —
|
||||
// each in-flight request can hold tens of MiB (maxUnifiedRequestBytes's
|
||||
// own doc comment, routes_unified.go), so even the ceiling still bounds
|
||||
// worst-case memory to a fixed, known order of magnitude.
|
||||
const (
|
||||
minBodyAdmissionCap = 16
|
||||
maxBodyAdmissionCap = 128
|
||||
)
|
||||
|
||||
// bodyAdmissionCapPerCPU is defaultBodyAdmissionCap's GOMAXPROCS
|
||||
// multiplier: generous headroom for a body-buffering request, which spends
|
||||
// most of its lifetime waiting on network I/O (upstream latency) rather
|
||||
// than burning CPU, so a cap tied 1:1 to CPU count would leave real
|
||||
// concurrency on the table.
|
||||
const bodyAdmissionCapPerCPU = 8
|
||||
|
||||
// defaultBodyAdmissionCap returns the self-tuned in-flight cap
|
||||
// acquireBodyAdmission's semaphore is sized to when Config.
|
||||
// MaxInFlightBodyRequests is left at 0 (security review finding 1b,
|
||||
// 2026-08-22): GOMAXPROCS-derived rather than a single constant that would
|
||||
// be wrong for both a 1-vCPU sidecar and a 32-vCPU ingress node alike
|
||||
// (house rule: prefer self-tuning over operator knobs where the right
|
||||
// value can be inferred from the environment), clamped to
|
||||
// [minBodyAdmissionCap, maxBodyAdmissionCap].
|
||||
func defaultBodyAdmissionCap() int {
|
||||
n := runtime.GOMAXPROCS(0) * bodyAdmissionCapPerCPU
|
||||
if n < minBodyAdmissionCap {
|
||||
return minBodyAdmissionCap
|
||||
}
|
||||
if n > maxBodyAdmissionCap {
|
||||
return maxBodyAdmissionCap
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Gateway is the Traefik middleware handler.
|
||||
type Gateway struct {
|
||||
next http.Handler
|
||||
@@ -329,7 +385,14 @@ type Gateway struct {
|
||||
// pooled connections through. nil when config.Redis is absent, same
|
||||
// as buildRedisClient's own nil-for-unconfigured contract.
|
||||
redisClient *respClient
|
||||
name string
|
||||
// bodyAdmission is the buffered-channel semaphore acquireBodyAdmission
|
||||
// (routes_unified.go) claims from and releases: security review
|
||||
// finding 1b, 2026-08-22. Sized once, here, by newGateway (see
|
||||
// defaultBodyAdmissionCap/Config.MaxInFlightBodyRequests) — never
|
||||
// resized afterward, matching a Go channel's own fixed-capacity
|
||||
// contract.
|
||||
bodyAdmission chan struct{}
|
||||
name string
|
||||
}
|
||||
|
||||
// New creates the middleware. NOTE: no tail call — Yaegi zeroes
|
||||
@@ -384,6 +447,19 @@ func newGateway(ctx context.Context, next http.Handler, config *Config, name str
|
||||
}
|
||||
g := &Gateway{next: next, name: name, cfg: config, auth: auth}
|
||||
|
||||
// bodyAdmissionCap: an explicit MaxInFlightBodyRequests always wins
|
||||
// over the self-tuned default (house rule: explicit override always
|
||||
// wins) — see Config.MaxInFlightBodyRequests' own doc comment. 0 (the
|
||||
// field's zero value, and every config that predates this field) means
|
||||
// "self-tune", never "cap at zero" — a channel with a zero buffer
|
||||
// would reject every request outright, which is never the intent of
|
||||
// an operator who simply never set this field.
|
||||
bodyAdmissionCap := defaultBodyAdmissionCap()
|
||||
if config.MaxInFlightBodyRequests > 0 {
|
||||
bodyAdmissionCap = config.MaxInFlightBodyRequests
|
||||
}
|
||||
g.bodyAdmission = make(chan struct{}, bodyAdmissionCap)
|
||||
|
||||
if config.Users != nil && config.Users.File != "" {
|
||||
if err = attachUsersFile(auth, config.Users.File, g); err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user