mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-15 05:29: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:
@@ -251,6 +251,56 @@ func TestHandleTargetProxy_ClientAPIKeyHeaderStripped_NoUpstreamInjection(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTargetProxy_DangerousHeadersStripped is the MCP/A2A-target
|
||||
// half of the security+performance audit's (2026-08-22) additive header
|
||||
// strip — proxyUpstream applies dangerousClientHeaders/
|
||||
// dangerousClientHeaderPrefixes on BOTH proxy paths, so an MCP target
|
||||
// must never see a client-spoofed identity/forwarding header, exactly
|
||||
// like a native passthrough provider.
|
||||
func TestHandleTargetProxy_DangerousHeadersStripped(t *testing.T) {
|
||||
var got http.Header
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := CreateConfig()
|
||||
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
||||
cfg.MCPServers = map[string]*TargetConfig{"alpha": {URL: srv.URL}}
|
||||
cfg.Groups = map[string]*GroupConfig{"default": {}}
|
||||
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice"}}}
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||
h, err := New(context.Background(), next, cfg, "llmgw")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp/alpha/tools/list", nil)
|
||||
req.Header.Set("Authorization", "Bearer sk-alice")
|
||||
req.Header.Set("Forwarded", "for=203.0.113.1")
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.1")
|
||||
req.Header.Set("X-Auth-Request-Email", "[email protected]")
|
||||
req.Header.Set("X-Remote-User", "spoofed-admin")
|
||||
req.Header.Set("X-Remote-Groups", "admin")
|
||||
req.Header.Set("Cookie", "session=stolen")
|
||||
req.Header.Set("X-Client-Custom", "keep-me")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, hdr := range []string{"Forwarded", "X-Forwarded-For", "X-Auth-Request-Email", "X-Remote-User", "X-Remote-Groups", "Cookie"} {
|
||||
if v := got.Get(hdr); v != "" {
|
||||
t.Errorf("upstream MCP target saw %s = %q, want stripped", hdr, v)
|
||||
}
|
||||
}
|
||||
if got.Get("X-Client-Custom") != "keep-me" {
|
||||
t.Error("upstream did not see ordinary header X-Client-Custom — strip must not be a full allowlist inversion")
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSE-safe streaming through the target proxy ---
|
||||
|
||||
func TestHandleTargetProxy_SSE_FlushesIncrementally(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user