mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-19 05:47:48 +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:
+61
-19
@@ -65,14 +65,44 @@ type Config struct {
|
||||
|
||||
// ProviderConfig describes one upstream LLM provider.
|
||||
type ProviderConfig struct {
|
||||
Passthrough *bool `json:"passthrough,omitempty"`
|
||||
Type string `json:"type"`
|
||||
BaseURL string `json:"baseUrl,omitempty"`
|
||||
APIKey string `json:"apiKey"`
|
||||
DiscoveryInterval string `json:"discoveryInterval,omitempty"`
|
||||
MetadataPath string `json:"metadataPath,omitempty"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
Discovery bool `json:"discovery,omitempty"`
|
||||
// Passthrough gates whether this provider's native passthrough route
|
||||
// ("/{providerName}/{rest...}", routes_passthrough.go's
|
||||
// handlePassthrough) is reachable at all. nil or true (the default)
|
||||
// preserves prior behavior exactly: every configured provider stays
|
||||
// reachable through native passthrough unless an operator explicitly
|
||||
// opts it out — FAIL-OPEN BY DESIGN, the same default direction as
|
||||
// every allow-list field in this package (GroupConfig's Providers/
|
||||
// Models/MCPServers/Agents/PassthroughPaths: empty/unset permits, an
|
||||
// explicit restriction narrows). Set false to disable a provider's
|
||||
// native passthrough entirely — e.g. an operator who wants clients
|
||||
// reaching a provider only through the unified /v1/* routes, never
|
||||
// its raw native API. A disabled provider's passthrough prefix is
|
||||
// treated exactly like an unconfigured one: ServeHTTP falls through
|
||||
// to the ordinary unknown-route 404, without even reaching auth
|
||||
// (security+performance audit, 2026-08-22). This does not affect the
|
||||
// MCP/A2A target proxy (mcp_a2a.go), which has its own routing
|
||||
// prefix and its own allow-list (GroupConfig.MCPServers/Agents) —
|
||||
// operators who route native tools like macstudio-rerank/parakeet-
|
||||
// mlx/piper-tts through THIS field's route must leave it unset or
|
||||
// true to keep that traffic flowing.
|
||||
Passthrough *bool `json:"passthrough,omitempty"`
|
||||
Type string `json:"type"`
|
||||
BaseURL string `json:"baseUrl,omitempty"`
|
||||
APIKey string `json:"apiKey"`
|
||||
DiscoveryInterval string `json:"discoveryInterval,omitempty"`
|
||||
// MetadataPath is an optional second discovery endpoint (feature
|
||||
// v0.23), fetched alongside the provider's normal listModels call
|
||||
// when set: a path such as "/api/v0/models" (LM Studio's own,
|
||||
// non-OpenAI-compatible models endpoint) that reports per-model
|
||||
// context length. Only openai-type adapters read this field
|
||||
// (provider_openai.go's openaiAdapter.fetchModelMetadata) — an
|
||||
// anthropic/gemini provider configuring it has no effect. Empty (the
|
||||
// default) captures no metadata, preserving prior behavior exactly.
|
||||
// A failed or absent fetch is always non-fatal to discovery itself:
|
||||
// no metadata is captured, nothing else changes.
|
||||
MetadataPath string `json:"metadataPath,omitempty"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
Discovery bool `json:"discovery,omitempty"`
|
||||
}
|
||||
|
||||
// GroupConfig describes a group's access and limits. Empty/omitted
|
||||
@@ -103,17 +133,29 @@ type GroupConfig struct {
|
||||
Agents []string `json:"agents,omitempty"`
|
||||
// PassthroughPaths restricts which REST path this group's native
|
||||
// passthrough requests may address, glob-matched (matchesGlob,
|
||||
// auth.go) against passthroughRoute's own "rest" — the path segment
|
||||
// AFTER the provider name (e.g. "v1/files" for a request to
|
||||
// "/openai/v1/files"). Empty/omitted (the default) means every path
|
||||
// is allowed, preserving prior behavior exactly and matching every
|
||||
// other allow-list field's own empty-means-all semantics in this
|
||||
// struct — DO NOT invert that convention here. Set a non-empty list
|
||||
// to restrict a group to specific native endpoints (e.g.
|
||||
// ["v1/chat/completions"]), without narrowing any group that leaves
|
||||
// it unset (security+performance audit, 2026-08-22). The "home"
|
||||
// group's own providers/models/mcpServers/agents lists are already
|
||||
// empty (unrestricted); leaving this unset too keeps it that way.
|
||||
// auth.go, via Go's path.Match) against passthroughRoute's own
|
||||
// "rest" — the ESCAPED path segment (r.URL.EscapedPath()-derived,
|
||||
// never percent-decoded) AFTER the provider name (e.g. "v1/files" for
|
||||
// a request to "/openai/v1/files"). Empty/omitted (the default) means
|
||||
// every path is allowed, preserving prior behavior exactly and
|
||||
// matching every other allow-list field's own empty-means-all
|
||||
// semantics in this struct — DO NOT invert that convention here. Set
|
||||
// a non-empty list to restrict a group to specific native endpoints
|
||||
// (e.g. ["v1/chat/completions"]), without narrowing any group that
|
||||
// leaves it unset (security review, 2026-08-22). The "home" group's
|
||||
// own providers/models/mcpServers/agents lists are already empty
|
||||
// (unrestricted); leaving this unset too keeps it that way.
|
||||
//
|
||||
// FOOTGUN (path.Match's documented behavior, verified in review): "*"
|
||||
// matches within ONE path segment only — it does NOT cross "/". A
|
||||
// pattern of exactly ["*"] therefore does NOT mean "allow every
|
||||
// path": it denies every "rest" with more than one segment, which is
|
||||
// most real passthrough traffic (e.g. "v1/chat/completions", three
|
||||
// segments, never matches "*"). Leave PassthroughPaths unset/empty to
|
||||
// actually allow everything; if you need a genuine multi-segment
|
||||
// wildcard, list every segment explicitly (e.g. "v1/*") or repeat a
|
||||
// "*/*/*"-shaped pattern per depth — there is no "**" (path.Match has
|
||||
// no such construct).
|
||||
PassthroughPaths []string `json:"passthroughPaths,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user