Files
traefik-llmgateway/providers_test.go
T
lukaszraczylo e3ef4084d2 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.
2026-08-22 11:40:27 +01:00

98 lines
3.8 KiB
Go

package traefikllmgateway
import (
"context"
"net/http"
"testing"
)
// TestProviderPassthroughEnabled is the security+performance audit
// (2026-08-22) table for ProviderConfig.Passthrough's default: nil (the
// zero value — every provider configured before this field existed)
// means enabled, matching prior behavior exactly; only an explicit false
// disables it.
func TestProviderPassthroughEnabled(t *testing.T) {
trueVal, falseVal := true, false
cases := []struct {
pc *ProviderConfig
name string
want bool
}{
{name: "nil field defaults to enabled", pc: &ProviderConfig{Type: "openai"}, want: true},
{name: "explicit true stays enabled", pc: &ProviderConfig{Type: "openai", Passthrough: &trueVal}, want: true},
{name: "explicit false disables", pc: &ProviderConfig{Type: "openai", Passthrough: &falseVal}, want: false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": c.pc}}
if got := providerPassthroughEnabled(cfg, "openai"); got != c.want {
t.Errorf("providerPassthroughEnabled = %v, want %v", got, c.want)
}
})
}
t.Run("unknown provider name defaults to enabled", func(t *testing.T) {
cfg := &Config{Providers: map[string]*ProviderConfig{}}
if !providerPassthroughEnabled(cfg, "notconfigured") {
t.Error("want true for a provider name absent from cfg.Providers")
}
})
}
// TestAttemptRecorderFromContext_RoundTrip proves withAttemptRecorder/
// attemptRecorderFromContext round-trip a non-nil recorder, and that the
// SAME func value comes back out (asserted indirectly here, by observing
// it fire when invoked) — Feature A's (v0.22) chokepoint plumbing between
// providers.go, retry.go's policy.do, and routes_passthrough.go's
// proxyUpstream.
func TestAttemptRecorderFromContext_RoundTrip(t *testing.T) {
var gotResp *http.Response
var gotErr error
rec := attemptRecorder(func(resp *http.Response, err error) {
gotResp = resp
gotErr = err
})
ctx := withAttemptRecorder(context.Background(), rec)
got := attemptRecorderFromContext(ctx)
if got == nil {
t.Fatal("attemptRecorderFromContext returned nil after withAttemptRecorder set one")
}
wantResp := &http.Response{StatusCode: http.StatusOK}
got(wantResp, nil)
if gotResp != wantResp {
t.Errorf("gotResp = %v, want %v (the recorder round-tripped through ctx must be the same func)", gotResp, wantResp)
}
if gotErr != nil {
t.Errorf("gotErr = %v, want nil", gotErr)
}
}
// TestAttemptRecorderFromContext_NoneSet proves a ctx that never called
// withAttemptRecorder — the common case: registry.go's discovery
// listModels calls, and handleTargetProxy's MCP/A2A target proxy
// (mcp_a2a.go), neither of which is Feature A's accounting scope —
// returns a nil recorder, not a panic or a zero-value func that panics
// when invoked.
func TestAttemptRecorderFromContext_NoneSet(t *testing.T) {
if got := attemptRecorderFromContext(context.Background()); got != nil {
t.Errorf("attemptRecorderFromContext(bare ctx) = %v, want nil", got)
}
}
// TestWithAttemptRecorder_NilRecorder_ReturnsSameContext proves passing a
// nil recorder is a safe no-op a caller may call unconditionally — it
// does not wrap ctx in a no-op value that would still satisfy a later
// "!= nil" nil-check with a non-functional func.
func TestWithAttemptRecorder_NilRecorder_ReturnsSameContext(t *testing.T) {
ctx := context.Background()
got := withAttemptRecorder(ctx, nil)
if got != ctx { //nolint:staticcheck // deliberately comparing context identity, not using it as a map key
t.Error("withAttemptRecorder(ctx, nil) returned a different context; want ctx unchanged")
}
if rec := attemptRecorderFromContext(got); rec != nil {
t.Errorf("attemptRecorderFromContext = %v, want nil after a nil recorder was passed in", rec)
}
}