mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-17 05:37:43 +00:00
fix(passthrough): close Content-Type bypass and matcher-parity bug
Independent review measured two real gaps in the round-1 model
enforcement and fixed both here, plus documentation and comment
regressions the round introduced.
MUST FIX: peekPassthroughModel's Content-Type gate was an ALLOW-list
("application/json" or skip") and fully client-controlled. The
reviewer sent "Content-Type: text/plain" with {"model":"EXPENSIVE"}
and got a 200 from a group scoped to a cheap model. This commit
replaces the whole gating approach:
1. A group with an empty Models list has nothing for allowsModel to
reject, so the request body is never read at all
(group.hasModelRestriction). This is the overwhelming majority of
traffic, including the operator's own "home" group, and removes
the memory-buffering concern entirely for it.
2. For a group WITH a restricted Models list, the Content-Type gate
is now a SKIP-list of genuinely binary types (multipart/, audio/,
image/, video/, application/octet-stream). Every other
Content-Type, including "text/plain" or none at all, is
inspected. The attacker no longer controls whether the check
runs.
3. The peek reads at most 64KiB (maxModelPeekBytes), restored via
io.MultiReader so the remainder streams straight to the upstream
instead of a second full buffer. A bounded JSON token walk
(scanTopLevelModel) finds a top-level "model" key even when the
rest of the body is truncated past the cap.
4. A restricted group whose peek window never finds "model" is
denied (403), fail closed. This also means a Gemini passthrough
request (its model lives in the URL, never the body) is always
denied for a model-restricted group; documented in
maxModelPeekBytes' own comment, the handler's doc comment, and
the README.
Matcher parity: the round-1 version glued providerName+"/" onto
whatever the body sent unconditionally, so an already-prefixed body
("openai/x") produced "openai/openai/x" and false-403'd a legitimate
client. allowsPassthroughModel now reuses modelRegistry.
splitConfiguredProvider — the same rule resolve/resolveAgainst apply
to a client-given id — to strip a leading segment only when it names
an actually configured provider, before generating candidates.
Also in this commit:
- Restored ~26 lines of doc comments fieldalignment -fix silently
dropped when it reordered the group struct (auth.go: cache,
cacheTTL) and ProviderConfig (llmgateway.go: MetadataPath, and the
Passthrough field's own new comment). Verified nothing else was
reordered by re-running fieldalignment: 0 issues.
- Documented the path.Match footgun a non-empty PassthroughPaths
inherits: "*" does not cross "/", so ["*"] denies almost all real
multi-segment passthrough traffic rather than allowing it. Empty is
the actual allow-all value. Documented on the config field, the
allowsPassthroughPath method, and the handler's own doc comment.
- Renamed the local "model" that shadowed the outer request-model
variable near extractPassthroughUsage's call site to "respModel" —
the new gating already scopes the outer one to a narrower block, so
this was no longer a live shadow, but the rename removes any future
ambiguity between the request's model and the response's.
- README: rewrote the two "Unified vs. passthrough" table cells that
described the OPPOSITE of this feature's actual behavior, added a
"Native passthrough model authorization" section, and added
providers.<name>.passthrough / groups.<name>.passthroughPaths rows
to the config tables.
New tests: the Content-Type-bypass regression (text/plain, absent,
form-urlencoded, all now 403), the matcher-parity case (already-
prefixed body allowed), fail-closed on a binary body for a restricted
group, skip-entirely for an unrestricted group, byte-identical body
forwarding for a body larger than the peek cap and for a
ContentLength<0 (chunked) request, and a scanTopLevelModel unit table
covering truncation before/after the model field, nested values, and
malformed input.
Gate re-run in full after these changes: gofmt, go vet, fieldalignment,
golangci-lint (0 issues), go test -race (ok), make yaegi-check (OK),
make integration (ok, real Traefik + Redis). Re-ran the interpreted
Yaegi harness: RTT/req unchanged at 3.00, cmds/req unchanged at 74.00.
This commit is contained in:
@@ -25,15 +25,31 @@ type user struct {
|
||||
// group describes a group's access rules and default limits. Empty
|
||||
// providers, models, mcpServers, or agents means all are allowed.
|
||||
type group struct {
|
||||
limits *LimitsConfig
|
||||
cache *bool
|
||||
name string
|
||||
providers []string
|
||||
models []string
|
||||
mcpServers []string
|
||||
agents []string
|
||||
limits *LimitsConfig
|
||||
// cache is GroupConfig.Cache carried through unchanged: nil inherits
|
||||
// the global cache.enabled setting, non-nil overrides it for this
|
||||
// group's requests. Resolved by groupCacheEnabled (cache.go).
|
||||
cache *bool
|
||||
name string
|
||||
providers []string
|
||||
models []string
|
||||
mcpServers []string
|
||||
agents []string
|
||||
// passthroughPaths is GroupConfig.PassthroughPaths carried through
|
||||
// unchanged (security+performance audit, 2026-08-22) — see
|
||||
// allowsPassthroughPath below.
|
||||
passthroughPaths []string
|
||||
cacheTTL time.Duration
|
||||
// cacheTTL is GroupConfig.CacheTTL parsed and validated at construction
|
||||
// (newAuthStore below): 0 inherits the global responseCache's TTL
|
||||
// (effectiveTTL, cache.go); a positive value sets the TTL written
|
||||
// when THIS group's own request populates a cache entry. It is not a
|
||||
// per-group scope — cache entries are shared across every group that
|
||||
// can reach the model (groupCacheEnabled, cache.go), so a positive
|
||||
// cacheTTL only ever controls a write's TTL, never which group can
|
||||
// later read the entry. GroupConfig.CacheTTL == "" is the only input
|
||||
// that produces 0 here — every other value either becomes a positive
|
||||
// duration or fails newAuthStore as a constructor error.
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
// allowsProvider reports whether name matches one of the group's provider
|
||||
@@ -58,6 +74,20 @@ func (grp *group) allowsModel(id string) bool {
|
||||
return matchesGlob(grp.models, id)
|
||||
}
|
||||
|
||||
// hasModelRestriction reports whether grp's Models glob is non-empty —
|
||||
// whether there is anything for allowsModel to actually reject (security
|
||||
// review fix, 2026-08-22, round 2). handlePassthrough (routes_
|
||||
// passthrough.go) checks this BEFORE peeking a passthrough request's body
|
||||
// at all: a group with an empty Models list (matchesGlob's own
|
||||
// empty-means-all contract — the live cluster's "home" group and every
|
||||
// other group that has not opted into model restrictions) has nothing
|
||||
// allowsModel could ever deny, so there is no reason to read, buffer, or
|
||||
// even look at the request body for model enforcement — zero risk, and
|
||||
// exactly today's behavior for the overwhelming majority of traffic.
|
||||
func (grp *group) hasModelRestriction() bool {
|
||||
return len(grp.models) > 0
|
||||
}
|
||||
|
||||
// allowsMCP reports whether name matches one of the group's MCP-server glob
|
||||
// patterns. An empty pattern list allows every server.
|
||||
func (grp *group) allowsMCP(name string) bool {
|
||||
@@ -73,10 +103,13 @@ func (grp *group) allowsAgent(name string) bool {
|
||||
// allowsPassthroughPath reports whether rest — the path segment after the
|
||||
// provider name in a native passthrough request (passthroughRoute's own
|
||||
// "rest", routes_passthrough.go) — matches one of the group's
|
||||
// PassthroughPaths glob patterns (security+performance audit,
|
||||
// 2026-08-22). An empty pattern list allows every path, the same
|
||||
// fail-open default every other allowsX method on group already applies
|
||||
// (matchesGlob's own empty-means-all contract) — do not invert it.
|
||||
// PassthroughPaths glob patterns (security review, 2026-08-22). An empty
|
||||
// pattern list allows every path, the same fail-open default every other
|
||||
// allowsX method on group already applies (matchesGlob's own
|
||||
// empty-means-all contract) — do not invert it. See GroupConfig.
|
||||
// PassthroughPaths' own doc comment (llmgateway.go) for a path.Match
|
||||
// footgun a non-empty list inherits: "*" does not cross "/", so ["*"]
|
||||
// is NOT "allow everything".
|
||||
func (grp *group) allowsPassthroughPath(rest string) bool {
|
||||
return matchesGlob(grp.passthroughPaths, rest)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user