mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
feat(passthrough): add model, path, and header authorization hardening
handlePassthrough checked only the provider allowlist, never the model or the path. A tenant scoped to one cheap model could reach a provider's entire native API (files, batches, fine-tuning) on the operator's key, and client identity headers reached keyless in-cluster MCP/A2A targets unfiltered. This change closes both gaps, additively: every new field defaults to reproducing prior behavior exactly. Model enforcement: when a passthrough JSON body carries a top-level "model" field, handlePassthrough checks it against the group's model glob, in both the bare and provider-prefixed form (the same dual-candidate matcher the unified route's resolveAgainst already applies). A body with no inspectable model (Gemini's URL-embedded model, a multipart upload, a non-JSON body) falls back to provider-only authorization, unchanged from before this round. Per-provider toggle: ProviderConfig.Passthrough, nil or true by default, lets an operator disable a provider's native passthrough route. A disabled provider now reports the ordinary unknown-route 404, checked in ServeHTTP's own route gate, before auth even runs. Per-group path allowlist: GroupConfig.PassthroughPaths, empty by default (matching every other allow-list field's empty-means-all semantics), restricts which native rest path a group may address. Dangerous-header stripping: proxyUpstream now additionally strips Forwarded, X-Forwarded-*, X-Auth-*, X-Remote-User, X-Remote-Groups, and Cookie on both the native-passthrough and MCP/A2A target-proxy paths, plus OpenAI-Organization, OpenAI-Project, and anthropic-beta on native passthrough only. This is an additive denylist expansion, not an allowlist inversion; ordinary headers still pass through. A dedicated test asserts a config with none of the new fields set behaves identically to before this change. The operator's home group (providers/models/mcpServers/agents/passthroughPaths all empty) stays fully unrestricted, and macstudio-rerank/parakeet-mlx/piper-tts stay reachable through native passthrough by default.
This commit is contained in:
+28
-18
@@ -65,23 +65,14 @@ type Config struct {
|
||||
|
||||
// ProviderConfig describes one upstream LLM provider.
|
||||
type ProviderConfig struct {
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// GroupConfig describes a group's access and limits. Empty/omitted
|
||||
@@ -110,6 +101,20 @@ type GroupConfig struct {
|
||||
CacheTTL string `json:"cacheTTL,omitempty"`
|
||||
MCPServers []string `json:"mcpServers,omitempty"`
|
||||
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.
|
||||
PassthroughPaths []string `json:"passthroughPaths,omitempty"`
|
||||
}
|
||||
|
||||
// LimitsConfig holds request/token/cost limits. Zero means unlimited.
|
||||
@@ -547,7 +552,12 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if providerName, rest, ok := passthroughRoute(r.URL.EscapedPath()); ok {
|
||||
if _, known := g.adapters[providerName]; known {
|
||||
// providerPassthroughEnabled (providers.go, security+performance
|
||||
// audit 2026-08-22): a provider with Passthrough explicitly set
|
||||
// false is treated exactly like an unconfigured one here — the
|
||||
// request falls through to the same unknown-route 404 below,
|
||||
// without even reaching auth.identify.
|
||||
if _, known := g.adapters[providerName]; known && providerPassthroughEnabled(g.cfg, providerName) {
|
||||
u, grp, ok := g.auth.identify(r)
|
||||
g.logAuthEvent(ok, authEventUserName(u), r)
|
||||
if !ok {
|
||||
|
||||
Reference in New Issue
Block a user