mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +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:
@@ -25,27 +25,15 @@ 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 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
|
||||
// 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
|
||||
limits *LimitsConfig
|
||||
cache *bool
|
||||
name string
|
||||
providers []string
|
||||
models []string
|
||||
mcpServers []string
|
||||
agents []string
|
||||
passthroughPaths []string
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
// allowsProvider reports whether name matches one of the group's provider
|
||||
@@ -82,6 +70,17 @@ func (grp *group) allowsAgent(name string) bool {
|
||||
return matchesGlob(grp.agents, name)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (grp *group) allowsPassthroughPath(rest string) bool {
|
||||
return matchesGlob(grp.passthroughPaths, rest)
|
||||
}
|
||||
|
||||
// cloneStringSlice returns an independent copy of s, preserving nil (a nil
|
||||
// s returns nil, never an empty non-nil slice — groupSummary's own
|
||||
// providers/models/mcpServers/agents fields rely on that nil-ness to stay
|
||||
@@ -188,14 +187,15 @@ func newAuthStore(cfg *Config) (*authStore, error) {
|
||||
return nil, err
|
||||
}
|
||||
a.groups[name] = &group{
|
||||
limits: gc.Limits,
|
||||
cache: gc.Cache,
|
||||
cacheTTL: cacheTTL,
|
||||
name: name,
|
||||
providers: gc.Providers,
|
||||
models: gc.Models,
|
||||
mcpServers: gc.MCPServers,
|
||||
agents: gc.Agents,
|
||||
limits: gc.Limits,
|
||||
cache: gc.Cache,
|
||||
cacheTTL: cacheTTL,
|
||||
name: name,
|
||||
providers: gc.Providers,
|
||||
models: gc.Models,
|
||||
mcpServers: gc.MCPServers,
|
||||
agents: gc.Agents,
|
||||
passthroughPaths: gc.PassthroughPaths,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user