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:
@@ -25,27 +25,15 @@ type user struct {
|
|||||||
// group describes a group's access rules and default limits. Empty
|
// group describes a group's access rules and default limits. Empty
|
||||||
// providers, models, mcpServers, or agents means all are allowed.
|
// providers, models, mcpServers, or agents means all are allowed.
|
||||||
type group struct {
|
type group struct {
|
||||||
limits *LimitsConfig
|
limits *LimitsConfig
|
||||||
// cache is GroupConfig.Cache carried through unchanged: nil inherits
|
cache *bool
|
||||||
// the global cache.enabled setting, non-nil overrides it for this
|
name string
|
||||||
// group's requests. Resolved by groupCacheEnabled (cache.go).
|
providers []string
|
||||||
cache *bool
|
models []string
|
||||||
name string
|
mcpServers []string
|
||||||
providers []string
|
agents []string
|
||||||
models []string
|
passthroughPaths []string
|
||||||
mcpServers []string
|
cacheTTL time.Duration
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowsProvider reports whether name matches one of the group's provider
|
// 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)
|
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
|
// cloneStringSlice returns an independent copy of s, preserving nil (a nil
|
||||||
// s returns nil, never an empty non-nil slice — groupSummary's own
|
// s returns nil, never an empty non-nil slice — groupSummary's own
|
||||||
// providers/models/mcpServers/agents fields rely on that nil-ness to stay
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
a.groups[name] = &group{
|
a.groups[name] = &group{
|
||||||
limits: gc.Limits,
|
limits: gc.Limits,
|
||||||
cache: gc.Cache,
|
cache: gc.Cache,
|
||||||
cacheTTL: cacheTTL,
|
cacheTTL: cacheTTL,
|
||||||
name: name,
|
name: name,
|
||||||
providers: gc.Providers,
|
providers: gc.Providers,
|
||||||
models: gc.Models,
|
models: gc.Models,
|
||||||
mcpServers: gc.MCPServers,
|
mcpServers: gc.MCPServers,
|
||||||
agents: gc.Agents,
|
agents: gc.Agents,
|
||||||
|
passthroughPaths: gc.PassthroughPaths,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -302,6 +302,29 @@ func TestGroup_AllowsModel_EmptyListAllowsAnything(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGroup_AllowsPassthroughPath_EmptyListAllowsAll pins the zero-config
|
||||||
|
// default for GroupConfig.PassthroughPaths (security+performance audit,
|
||||||
|
// 2026-08-22): a group with no PassthroughPaths configured — every group
|
||||||
|
// that existed before this field did — must keep reaching every native
|
||||||
|
// passthrough path, exactly like allowsProvider/allowsModel/allowsMCP/
|
||||||
|
// allowsAgent's own empty-means-all defaults.
|
||||||
|
func TestGroup_AllowsPassthroughPath_EmptyListAllowsAll(t *testing.T) {
|
||||||
|
grp := &group{name: "eng"}
|
||||||
|
if !grp.allowsPassthroughPath("v1/files") {
|
||||||
|
t.Fatal("empty passthroughPaths list should allow all")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroup_AllowsPassthroughPath_Glob(t *testing.T) {
|
||||||
|
grp := &group{name: "eng", passthroughPaths: []string{"v1/chat/completions"}}
|
||||||
|
if !grp.allowsPassthroughPath("v1/chat/completions") {
|
||||||
|
t.Error("want exact match")
|
||||||
|
}
|
||||||
|
if grp.allowsPassthroughPath("v1/files") {
|
||||||
|
t.Error("want no match for a path outside the allowlist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthStore_ReplaceFileUsers_AddsAndSwapsUsers(t *testing.T) {
|
func TestAuthStore_ReplaceFileUsers_AddsAndSwapsUsers(t *testing.T) {
|
||||||
a, err := newAuthStore(testAuthCfg()) // inline user "a" / sk-secret in group eng
|
a, err := newAuthStore(testAuthCfg()) // inline user "a" / sk-secret in group eng
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+28
-18
@@ -65,23 +65,14 @@ type Config struct {
|
|||||||
|
|
||||||
// ProviderConfig describes one upstream LLM provider.
|
// ProviderConfig describes one upstream LLM provider.
|
||||||
type ProviderConfig struct {
|
type ProviderConfig struct {
|
||||||
Type string `json:"type"`
|
Passthrough *bool `json:"passthrough,omitempty"`
|
||||||
BaseURL string `json:"baseUrl,omitempty"`
|
Type string `json:"type"`
|
||||||
APIKey string `json:"apiKey"`
|
BaseURL string `json:"baseUrl,omitempty"`
|
||||||
DiscoveryInterval string `json:"discoveryInterval,omitempty"`
|
APIKey string `json:"apiKey"`
|
||||||
// MetadataPath is an optional second discovery endpoint (feature
|
DiscoveryInterval string `json:"discoveryInterval,omitempty"`
|
||||||
// v0.23), fetched alongside the provider's normal listModels call
|
MetadataPath string `json:"metadataPath,omitempty"`
|
||||||
// when set: a path such as "/api/v0/models" (LM Studio's own,
|
Models []string `json:"models,omitempty"`
|
||||||
// non-OpenAI-compatible models endpoint) that reports per-model
|
Discovery bool `json:"discovery,omitempty"`
|
||||||
// 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
|
// GroupConfig describes a group's access and limits. Empty/omitted
|
||||||
@@ -110,6 +101,20 @@ type GroupConfig struct {
|
|||||||
CacheTTL string `json:"cacheTTL,omitempty"`
|
CacheTTL string `json:"cacheTTL,omitempty"`
|
||||||
MCPServers []string `json:"mcpServers,omitempty"`
|
MCPServers []string `json:"mcpServers,omitempty"`
|
||||||
Agents []string `json:"agents,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.
|
// 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 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)
|
u, grp, ok := g.auth.identify(r)
|
||||||
g.logAuthEvent(ok, authEventUserName(u), r)
|
g.logAuthEvent(ok, authEventUserName(u), r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
+1
-1
@@ -216,7 +216,7 @@ func (g *Gateway) handleTargetProxy(w http.ResponseWriter, r *http.Request, u *u
|
|||||||
upstreamURL += "?" + r.URL.RawQuery
|
upstreamURL += "?" + r.URL.RawQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
g.proxyUpstream(w, r, upstreamURL, g.targetClient, nil, false, kind+" target (name "+name+")")
|
g.proxyUpstream(w, r, upstreamURL, g.targetClient, nil, nil, false, kind+" target (name "+name+")")
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateTargetURLs checks every configured MCP-server and agent entry at
|
// validateTargetURLs checks every configured MCP-server and agent entry at
|
||||||
|
|||||||
@@ -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 ---
|
// --- SSE-safe streaming through the target proxy ---
|
||||||
|
|
||||||
func TestHandleTargetProxy_SSE_FlushesIncrementally(t *testing.T) {
|
func TestHandleTargetProxy_SSE_FlushesIncrementally(t *testing.T) {
|
||||||
|
|||||||
@@ -386,6 +386,24 @@ func validateMetadataPath(providerName, metadataPath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// providerPassthroughEnabled reports whether providerName's native
|
||||||
|
// passthrough route is reachable (security+performance audit,
|
||||||
|
// 2026-08-22): true when ProviderConfig.Passthrough is nil (the default)
|
||||||
|
// or explicitly true; false only when an operator set it to false.
|
||||||
|
// cfg.Providers[providerName] is assumed to already be a known key —
|
||||||
|
// ServeHTTP's own passthrough route gate (llmgateway.go) checks
|
||||||
|
// g.adapters[providerName] first, and buildAdapters guarantees adapters
|
||||||
|
// and cfg.Providers share the same key set — but a missing key still
|
||||||
|
// defaults to true here rather than panicking, matching every other
|
||||||
|
// nil-means-default helper in this package.
|
||||||
|
func providerPassthroughEnabled(cfg *Config, providerName string) bool {
|
||||||
|
pc, ok := cfg.Providers[providerName]
|
||||||
|
if !ok || pc.Passthrough == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *pc.Passthrough
|
||||||
|
}
|
||||||
|
|
||||||
// buildAdapters resolves cfg.Providers into a map of providerAdapter keyed
|
// buildAdapters resolves cfg.Providers into a map of providerAdapter keyed
|
||||||
// by provider name. Every provider name is validated (validateConfigName)
|
// by provider name. Every provider name is validated (validateConfigName)
|
||||||
// and every ProviderConfig value must be non-nil before anything else runs,
|
// and every ProviderConfig value must be non-nil before anything else runs,
|
||||||
|
|||||||
@@ -6,6 +6,39 @@ import (
|
|||||||
"testing"
|
"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/
|
// TestAttemptRecorderFromContext_RoundTrip proves withAttemptRecorder/
|
||||||
// attemptRecorderFromContext round-trip a non-nil recorder, and that the
|
// attemptRecorderFromContext round-trip a non-nil recorder, and that the
|
||||||
// SAME func value comes back out (asserted indirectly here, by observing
|
// SAME func value comes back out (asserted indirectly here, by observing
|
||||||
|
|||||||
+166
-9
@@ -65,6 +65,75 @@ var clientNegotiationHeaders = map[string]bool{
|
|||||||
"Accept-Encoding": true,
|
"Accept-Encoding": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dangerousClientHeaders lists exact-match client-supplied headers
|
||||||
|
// stripped from the outgoing request on BOTH proxy paths (native provider
|
||||||
|
// passthrough and the MCP/A2A target proxy) — additive to
|
||||||
|
// hopByHopHeaders/gatewayCredentialHeaders/clientNegotiationHeaders,
|
||||||
|
// never a full allowlist inversion (security+performance audit,
|
||||||
|
// 2026-08-22). Each of these conveys a caller's identity, or a downstream
|
||||||
|
// auth-proxy's own asserted identity for THIS gateway's inbound edge, and
|
||||||
|
// has no legitimate client-to-LLM/MCP use once it reaches an upstream
|
||||||
|
// provider or an in-cluster MCP server/A2A agent: handleTargetProxy's own
|
||||||
|
// doc comment (mcp_a2a.go) already documents that a target trusts the
|
||||||
|
// gateway's network position, not a caller-supplied identity header —
|
||||||
|
// forwarding one of these would let a tenant impersonate a different
|
||||||
|
// identity to that target instead of relying on auth.identify, which has
|
||||||
|
// already established who the caller is.
|
||||||
|
var dangerousClientHeaders = map[string]bool{
|
||||||
|
"Forwarded": true,
|
||||||
|
"X-Remote-User": true,
|
||||||
|
"X-Remote-Groups": true,
|
||||||
|
"Cookie": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// dangerousClientHeaderPrefixes lists header-name PREFIXES (canonical
|
||||||
|
// textproto casing — net/http's own canonicalization, matching
|
||||||
|
// copyHeadersExcept's doc comment) stripped alongside
|
||||||
|
// dangerousClientHeaders on both proxy paths: every X-Forwarded-*
|
||||||
|
// (X-Forwarded-For, X-Forwarded-Host, X-Forwarded-User, ...) and X-Auth-*
|
||||||
|
// (X-Auth-Request-Email, X-Auth-Request-User, ...) header a client sends
|
||||||
|
// — the convention an in-cluster auth proxy (e.g. oauth2-proxy) uses to
|
||||||
|
// assert identity to whatever it fronts. A tenant must never spoof that
|
||||||
|
// convention simply by setting the header on their own request to this
|
||||||
|
// gateway.
|
||||||
|
var dangerousClientHeaderPrefixes = []string{"X-Forwarded-", "X-Auth-"}
|
||||||
|
|
||||||
|
// providerCredentialRetargetHeaders are additionally stripped from the
|
||||||
|
// outgoing request on the NATIVE PROVIDER PASSTHROUGH path only (passed as
|
||||||
|
// proxyUpstream's extraStrip; the MCP/A2A target proxy passes nil, since
|
||||||
|
// it never carries a provider credential to retarget in the first place):
|
||||||
|
// a tenant must not be able to redirect the operator's own upstream
|
||||||
|
// billing/attribution away from what the adapter configured, by simply
|
||||||
|
// setting these on their own request. anthropic-beta is included even
|
||||||
|
// though it also carries genuine opt-in feature flags (e.g. prompt
|
||||||
|
// caching) — an operator who wants to offer those through native
|
||||||
|
// passthrough sets them on the provider's own adapter/config surface, not
|
||||||
|
// by trusting an arbitrary client-supplied value straight onto the
|
||||||
|
// operator's own key (security+performance audit, 2026-08-22).
|
||||||
|
var providerCredentialRetargetHeaders = map[string]bool{
|
||||||
|
"Openai-Organization": true,
|
||||||
|
"Openai-Project": true,
|
||||||
|
"Anthropic-Beta": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripHeaderPrefixes deletes every header in h whose canonical name
|
||||||
|
// starts with one of prefixes — the prefix-matching half of the
|
||||||
|
// dangerous-header strip copyHeadersExcept's own exact-match excepts
|
||||||
|
// cannot express (security+performance audit, 2026-08-22): every
|
||||||
|
// X-Forwarded-* and X-Auth-* header a client sends, regardless of its
|
||||||
|
// exact suffix. Deleting the currently-visited (or a not-yet-visited) key
|
||||||
|
// from a map mid-range is well-defined in Go and safe here.
|
||||||
|
func stripHeaderPrefixes(h http.Header, prefixes []string) {
|
||||||
|
for k := range h {
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
if strings.HasPrefix(k, prefix) {
|
||||||
|
h.Del(k)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// copyHeadersExcept copies every header in src to dst, skipping any key
|
// copyHeadersExcept copies every header in src to dst, skipping any key
|
||||||
// present in any of excepts. Header keys from both an *http.Request
|
// present in any of excepts. Header keys from both an *http.Request
|
||||||
// parsed off the wire and an *http.Response parsed by the client's
|
// parsed off the wire and an *http.Response parsed by the client's
|
||||||
@@ -230,6 +299,40 @@ func extractPassthroughUsage(typeName, providerName string, body []byte) (usage,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// peekPassthroughModel best-effort reads a passthrough request's JSON body
|
||||||
|
// far enough to extract its top-level "model" field, restoring r.Body
|
||||||
|
// afterward — a fresh reader over the exact bytes read — so proxyUpstream
|
||||||
|
// can still forward the request to the upstream unchanged (security+
|
||||||
|
// performance audit, 2026-08-22). hasModel is false, with no error, for
|
||||||
|
// every shape handlePassthrough's own doc comment documents as "no
|
||||||
|
// inspectable model, fall back to provider-only auth": a non-JSON
|
||||||
|
// Content-Type (Gemini's URL-embedded model; a multipart audio/image
|
||||||
|
// upload — parakeet-mlx's transcription passthrough is exactly this
|
||||||
|
// shape, and this check skips reading its body entirely, at no cost), a
|
||||||
|
// body that fails to decode as JSON, or JSON with no non-empty top-level
|
||||||
|
// "model" string. err is non-nil only for an actual body-read failure
|
||||||
|
// (client disconnect, deadline) — the caller maps that to the same 400
|
||||||
|
// runUnified's own body-read failure uses (routes_unified.go), rather
|
||||||
|
// than silently treating an unreadable body as "provider-only, proceed".
|
||||||
|
func peekPassthroughModel(r *http.Request) (model string, hasModel bool, err error) {
|
||||||
|
if r.Body == nil || !strings.Contains(strings.ToLower(r.Header.Get("Content-Type")), "application/json") {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
body, readErr := io.ReadAll(io.LimitReader(r.Body, maxPassthroughBytes))
|
||||||
|
if readErr != nil {
|
||||||
|
return "", false, readErr
|
||||||
|
}
|
||||||
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(body, &payload) != nil || payload.Model == "" {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return payload.Model, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// handlePassthrough implements the native provider passthrough route:
|
// handlePassthrough implements the native provider passthrough route:
|
||||||
// "/{providerName}/{rest...}" reverse-proxies rest, verbatim, to
|
// "/{providerName}/{rest...}" reverse-proxies rest, verbatim, to
|
||||||
// providerName's configured upstream, with the client's gateway
|
// providerName's configured upstream, with the client's gateway
|
||||||
@@ -239,23 +342,67 @@ func extractPassthroughUsage(typeName, providerName string, body []byte) (usage,
|
|||||||
// in the gateway's own error envelope the way the unified routes wrap a
|
// in the gateway's own error envelope the way the unified routes wrap a
|
||||||
// providerHTTPError.
|
// providerHTTPError.
|
||||||
//
|
//
|
||||||
// Checks run in this order: group authorization (403) before capability
|
// Checks run in this order: group authorization — provider (403), model
|
||||||
// checks (Upgrade→501, path validity→400) before rate limits (429/503) —
|
// when the body carries one (403), path allowlist (403) — before
|
||||||
// a caller who cannot use providerName at all learns that first, rather
|
// capability checks (Upgrade→501, path validity→400) before rate limits
|
||||||
// than learning something about how they tried to use it.
|
// (429/503) — a caller who cannot use providerName/model/path at all
|
||||||
|
// learns that first, rather than learning something about how they tried
|
||||||
|
// to use it. The provider-level Passthrough toggle (ProviderConfig,
|
||||||
|
// llmgateway.go) is checked earlier still, by ServeHTTP's own route gate
|
||||||
|
// — a disabled provider never reaches this function at all, reported as
|
||||||
|
// the ordinary unknown-route 404 instead.
|
||||||
|
//
|
||||||
|
// MODEL ENFORCEMENT (security+performance audit, 2026-08-22): when the
|
||||||
|
// request body is JSON and carries a non-empty top-level "model" field
|
||||||
|
// (peekPassthroughModel), it is checked against grp.allowsModel — both
|
||||||
|
// the bare form and the "providerName/model" form, the identical
|
||||||
|
// dual-candidate matcher the unified route's own resolveAgainst applies
|
||||||
|
// (registry.go) — before proxying, closing the bypass where a tenant
|
||||||
|
// scoped to one cheap model could otherwise reach the SAME provider's
|
||||||
|
// entire native API (fine-tuning, files, batches, ...) on the operator's
|
||||||
|
// key merely by asking natively instead of through /v1/chat/completions.
|
||||||
|
// A body with no inspectable model (Gemini's URL-embedded model id, a
|
||||||
|
// multipart upload, a non-JSON body) falls back to provider-only
|
||||||
|
// authorization, unchanged from before this round — this is a strict
|
||||||
|
// narrowing of what a passthrough request may address, never a new way
|
||||||
|
// to allow one a plain grp.allowsProvider check would have refused.
|
||||||
|
//
|
||||||
|
// PATH ALLOWLIST (same audit): GroupConfig.PassthroughPaths, when
|
||||||
|
// non-empty, additionally restricts which rest path this group's
|
||||||
|
// passthrough requests may address (grp.allowsPassthroughPath). Empty
|
||||||
|
// (the default, matchesGlob's own empty-means-all contract) allows every
|
||||||
|
// path, exactly as before this field existed.
|
||||||
//
|
//
|
||||||
// For a Gemini provider, rest is appended to base() exactly as the client
|
// For a Gemini provider, rest is appended to base() exactly as the client
|
||||||
// sent it: there is no model extraction or URL rewriting here, so a
|
// sent it: there is no model extraction or URL rewriting here, so a
|
||||||
// Gemini passthrough client must address it with Gemini's own native URL
|
// Gemini passthrough client must address it with Gemini's own native URL
|
||||||
// structure, including its "/v1beta/models/{model}:generateContent"
|
// structure, including its "/v1beta/models/{model}:generateContent"
|
||||||
// paths — injectAuth still sets the same x-goog-api-key header it sets
|
// paths — injectAuth still sets the same x-goog-api-key header it sets
|
||||||
// for every other Gemini request.
|
// for every other Gemini request. Gemini's model id lives in that URL
|
||||||
|
// path, not the JSON body, so peekPassthroughModel never finds one for a
|
||||||
|
// Gemini request — a Gemini passthrough client is always authorized
|
||||||
|
// provider-only, exactly as it was before model enforcement existed.
|
||||||
func (g *Gateway) handlePassthrough(w http.ResponseWriter, r *http.Request, u *user, grp *group, providerName, rest string) {
|
func (g *Gateway) handlePassthrough(w http.ResponseWriter, r *http.Request, u *user, grp *group, providerName, rest string) {
|
||||||
if !grp.allowsProvider(providerName) {
|
if !grp.allowsProvider(providerName) {
|
||||||
writeOAIError(w, http.StatusForbidden, "invalid_request_error", "provider access denied")
|
writeOAIError(w, http.StatusForbidden, "invalid_request_error", "provider access denied")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model, hasModel, err := peekPassthroughModel(r)
|
||||||
|
if err != nil {
|
||||||
|
writeOAIError(w, http.StatusBadRequest, "invalid_request_error", "cannot read request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasModel && !grp.allowsModel(model) && !grp.allowsModel(providerName+"/"+model) {
|
||||||
|
writeOAIError(w, http.StatusForbidden, "invalid_request_error", "model access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !grp.allowsPassthroughPath(rest) {
|
||||||
|
writeOAIError(w, http.StatusForbidden, "invalid_request_error", "path access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if r.Header.Get("Upgrade") != "" {
|
if r.Header.Get("Upgrade") != "" {
|
||||||
writeOAIError(w, http.StatusNotImplemented, "invalid_request_error", "websocket/upgrade passthrough not supported")
|
writeOAIError(w, http.StatusNotImplemented, "invalid_request_error", "websocket/upgrade passthrough not supported")
|
||||||
return
|
return
|
||||||
@@ -290,7 +437,7 @@ func (g *Gateway) handlePassthrough(w http.ResponseWriter, r *http.Request, u *u
|
|||||||
g.limiter.recordProviderAttempt(providerName, "", resp, attemptErr)
|
g.limiter.recordProviderAttempt(providerName, "", resp, attemptErr)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
result, ok := g.proxyUpstream(w, r, upstreamURL, adapter.httpClient(), adapter.injectAuth, true, "passthrough (provider "+providerName+")")
|
result, ok := g.proxyUpstream(w, r, upstreamURL, adapter.httpClient(), adapter.injectAuth, providerCredentialRetargetHeaders, true, "passthrough (provider "+providerName+")")
|
||||||
if !ok || !result.isJSON {
|
if !ok || !result.isJSON {
|
||||||
// A build/connection/copy failure already wrote its own response
|
// A build/connection/copy failure already wrote its own response
|
||||||
// (or, for a canceled client context, wrote nothing at all — see
|
// (or, for a canceled client context, wrote nothing at all — see
|
||||||
@@ -334,7 +481,16 @@ type proxyResult struct {
|
|||||||
// credential (gatewayCredentialHeaders) are stripped from the outgoing
|
// credential (gatewayCredentialHeaders) are stripped from the outgoing
|
||||||
// request, and Accept-Encoding (clientNegotiationHeaders) besides, so
|
// request, and Accept-Encoding (clientNegotiationHeaders) besides, so
|
||||||
// Transport can negotiate and transparently decompress compression
|
// Transport can negotiate and transparently decompress compression
|
||||||
// itself. injectAuth, when non-nil, is called on the built request before
|
// itself — plus, additively (security+performance audit, 2026-08-22),
|
||||||
|
// every client-supplied identity/forwarding header (dangerousClientHeaders,
|
||||||
|
// dangerousClientHeaderPrefixes: Forwarded, X-Forwarded-*, X-Auth-*,
|
||||||
|
// X-Remote-User, X-Remote-Groups, Cookie) on BOTH callers, and every
|
||||||
|
// header in extraStrip on top of that — handlePassthrough passes
|
||||||
|
// providerCredentialRetargetHeaders (OpenAI-Organization, OpenAI-Project,
|
||||||
|
// anthropic-beta: a tenant must not retarget the operator's own upstream
|
||||||
|
// billing/attribution), handleTargetProxy passes nil, since an MCP/A2A
|
||||||
|
// target never carries a provider credential to retarget in the first
|
||||||
|
// place. injectAuth, when non-nil, is called on the built request before
|
||||||
// it is sent — handlePassthrough passes its adapter's injectAuth to swap
|
// it is sent — handlePassthrough passes its adapter's injectAuth to swap
|
||||||
// the client's key for the provider's own; handleTargetProxy passes nil,
|
// the client's key for the provider's own; handleTargetProxy passes nil,
|
||||||
// since an MCP server or A2A agent is an in-cluster target that receives
|
// since an MCP server or A2A agent is an in-cluster target that receives
|
||||||
@@ -355,7 +511,7 @@ type proxyResult struct {
|
|||||||
// passes false, since target-proxy accounting never goes past the
|
// passes false, since target-proxy accounting never goes past the
|
||||||
// request-count checkAndCount already ran before calling in, and teeing a
|
// request-count checkAndCount already ran before calling in, and teeing a
|
||||||
// response nobody will ever read back would only cost memory for nothing.
|
// response nobody will ever read back would only cost memory for nothing.
|
||||||
func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstreamURL string, client *http.Client, injectAuth func(*http.Request), accountJSON bool, logPrefix string) (result proxyResult, ok bool) {
|
func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstreamURL string, client *http.Client, injectAuth func(*http.Request), extraStrip map[string]bool, accountJSON bool, logPrefix string) (result proxyResult, ok bool) {
|
||||||
bodyReader := io.LimitReader(r.Body, maxPassthroughBytes)
|
bodyReader := io.LimitReader(r.Body, maxPassthroughBytes)
|
||||||
// gosec G704 (SSRF via taint analysis) flags upstreamURL as
|
// gosec G704 (SSRF via taint analysis) flags upstreamURL as
|
||||||
// request-derived: it is, by design — this is a reverse proxy, and its
|
// request-derived: it is, by design — this is a reverse proxy, and its
|
||||||
@@ -387,7 +543,8 @@ func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstream
|
|||||||
// bodyReader above may not actually deliver.
|
// bodyReader above may not actually deliver.
|
||||||
upstreamReq.ContentLength = -1
|
upstreamReq.ContentLength = -1
|
||||||
}
|
}
|
||||||
copyHeadersExcept(upstreamReq.Header, r.Header, hopByHopHeaders, gatewayCredentialHeaders, clientNegotiationHeaders)
|
copyHeadersExcept(upstreamReq.Header, r.Header, hopByHopHeaders, gatewayCredentialHeaders, clientNegotiationHeaders, dangerousClientHeaders, extraStrip)
|
||||||
|
stripHeaderPrefixes(upstreamReq.Header, dangerousClientHeaderPrefixes)
|
||||||
if injectAuth != nil {
|
if injectAuth != nil {
|
||||||
injectAuth(upstreamReq)
|
injectAuth(upstreamReq)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -987,3 +988,420 @@ func TestHandlePassthrough_ContextDeadlineExceeded_RecordsProviderFailure(t *tes
|
|||||||
t.Errorf("provider fails/day = %d, want 1 — a real context-deadline timeout must count as a provider-health failure (SHOULD-1)", fails)
|
t.Errorf("provider fails/day = %d, want 1 — a real context-deadline timeout must count as a provider-health failure (SHOULD-1)", fails)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- security+performance audit, 2026-08-22: model enforcement, per-
|
||||||
|
// provider toggle, per-group path allowlist, dangerous-header stripping ---
|
||||||
|
|
||||||
|
// errReadCloser is an io.ReadCloser whose Read always fails, used to
|
||||||
|
// exercise peekPassthroughModel's own body-read-failure path.
|
||||||
|
type errReadCloser struct{ err error }
|
||||||
|
|
||||||
|
func (e errReadCloser) Read([]byte) (int, error) { return 0, e.err }
|
||||||
|
func (e errReadCloser) Close() error { return nil }
|
||||||
|
|
||||||
|
// TestHandlePassthrough_ModelAllowed_Proxied proves a passthrough JSON
|
||||||
|
// body whose "model" field matches the group's model glob is proxied
|
||||||
|
// normally.
|
||||||
|
func TestHandlePassthrough_ModelAllowed_Proxied(t *testing.T) {
|
||||||
|
var upstreamCalled bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
upstreamCalled = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
|
||||||
|
cfg.Groups = map[string]*GroupConfig{"default": {Models: []string{"gpt-allowed"}}}
|
||||||
|
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.MethodPost, "/openai/v1/native-endpoint", strings.NewReader(`{"model":"gpt-allowed"}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer sk-alice")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
if !upstreamCalled {
|
||||||
|
t.Error("upstream must be called for an allowed model")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlePassthrough_ModelDenied_Returns403 closes the cheap-to-
|
||||||
|
// expensive-model bypass: a passthrough JSON body naming a model the
|
||||||
|
// group's glob does not match must never reach the upstream.
|
||||||
|
func TestHandlePassthrough_ModelDenied_Returns403(t *testing.T) {
|
||||||
|
var upstreamCalled bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
upstreamCalled = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
|
||||||
|
cfg.Groups = map[string]*GroupConfig{"default": {Models: []string{"gpt-allowed"}}}
|
||||||
|
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.MethodPost, "/openai/v1/fine-tuning/jobs", strings.NewReader(`{"model":"gpt-expensive"}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer sk-alice")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403, body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if upstreamCalled {
|
||||||
|
t.Error("upstream must never be called for a denied model")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlePassthrough_ModelAllowed_ProviderPrefixedForm proves model
|
||||||
|
// enforcement checks both the bare model id and the "provider/model" form
|
||||||
|
// — the identical dual-candidate matcher the unified route's own
|
||||||
|
// resolveAgainst applies (registry.go) — so a group glob written against
|
||||||
|
// the prefixed form still authorizes a native passthrough request naming
|
||||||
|
// the bare upstream id.
|
||||||
|
func TestHandlePassthrough_ModelAllowed_ProviderPrefixedForm(t *testing.T) {
|
||||||
|
var upstreamCalled bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
upstreamCalled = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
|
||||||
|
cfg.Groups = map[string]*GroupConfig{"default": {Models: []string{"openai/gpt-x"}}}
|
||||||
|
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.MethodPost, "/openai/v1/native-endpoint", strings.NewReader(`{"model":"gpt-x"}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer sk-alice")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
if !upstreamCalled {
|
||||||
|
t.Error("upstream must be called: the group's provider-prefixed glob authorizes the bare upstream id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlePassthrough_NoInspectableModel_FallsBackToProviderOnly covers
|
||||||
|
// every "no inspectable model" shape handlePassthrough's own doc comment
|
||||||
|
// documents: a non-JSON Content-Type (a multipart upload — parakeet-mlx's
|
||||||
|
// transcription passthrough shape). Even though the group's Models glob
|
||||||
|
// would deny the literal string used here if it were checked, the request
|
||||||
|
// must still succeed — falling back to provider-only authorization,
|
||||||
|
// unchanged from before model enforcement existed.
|
||||||
|
func TestHandlePassthrough_NoInspectableModel_FallsBackToProviderOnly(t *testing.T) {
|
||||||
|
var upstreamCalled bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
upstreamCalled = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
|
||||||
|
cfg.Groups = map[string]*GroupConfig{"default": {Models: []string{"gpt-allowed"}}}
|
||||||
|
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.MethodPost, "/openai/v1/audio/transcriptions", strings.NewReader("--boundary\r\nfake multipart body\r\n--boundary--"))
|
||||||
|
req.Header.Set("Authorization", "Bearer sk-alice")
|
||||||
|
req.Header.Set("Content-Type", "multipart/form-data; boundary=boundary")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (falls back to provider-only auth), body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !upstreamCalled {
|
||||||
|
t.Error("upstream must be called: a non-JSON body has no inspectable model, so model enforcement must not block it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlePassthrough_ModelBodyReadFailure_Returns400 covers
|
||||||
|
// peekPassthroughModel's own body-read-failure path, distinct from a body
|
||||||
|
// that merely fails to decode as JSON (which falls back to provider-only,
|
||||||
|
// not an error).
|
||||||
|
func TestHandlePassthrough_ModelBodyReadFailure_Returns400(t *testing.T) {
|
||||||
|
var upstreamCalled bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
upstreamCalled = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
|
||||||
|
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.MethodPost, "/openai/v1/native-endpoint", nil)
|
||||||
|
req.Body = errReadCloser{err: errors.New("boom: connection reset")}
|
||||||
|
req.Header.Set("Authorization", "Bearer sk-alice")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400, body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if upstreamCalled {
|
||||||
|
t.Error("upstream must never be called when the request body cannot be read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlePassthrough_ProviderPassthroughDisabled_Returns404 is the
|
||||||
|
// per-provider toggle's off case: ProviderConfig.Passthrough=false makes
|
||||||
|
// the native passthrough route behave exactly like an unconfigured
|
||||||
|
// provider — the ordinary unknown-route 404, without even reaching auth
|
||||||
|
// (no Authorization header is sent here, mirroring
|
||||||
|
// TestServeHTTP_UnknownProviderPrefix_Returns404's own proof style).
|
||||||
|
func TestHandlePassthrough_ProviderPassthroughDisabled_Returns404(t *testing.T) {
|
||||||
|
var upstreamCalled bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
upstreamCalled = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
disabled := false
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{
|
||||||
|
"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up", Passthrough: &disabled},
|
||||||
|
}
|
||||||
|
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, "/openai/v1/chat/completions", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404, body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if upstreamCalled {
|
||||||
|
t.Error("upstream must never be called when the provider's passthrough is disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlePassthrough_ProviderPassthroughExplicitTrue_StillEnabled
|
||||||
|
// proves Passthrough=true (not just the nil default) keeps native
|
||||||
|
// passthrough reachable — the toggle's other explicit value, not only its
|
||||||
|
// absence.
|
||||||
|
func TestHandlePassthrough_ProviderPassthroughExplicitTrue_StillEnabled(t *testing.T) {
|
||||||
|
enabled := true
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{
|
||||||
|
"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up", Passthrough: &enabled},
|
||||||
|
}
|
||||||
|
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, "/openai/v1/thing", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer sk-alice")
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlePassthrough_PassthroughPaths_Restrictive proves a non-empty
|
||||||
|
// GroupConfig.PassthroughPaths restricts which rest path the group may
|
||||||
|
// address, while still allowing the listed one.
|
||||||
|
func TestHandlePassthrough_PassthroughPaths_Restrictive(t *testing.T) {
|
||||||
|
var gotPaths []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPaths = append(gotPaths, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
|
||||||
|
cfg.Groups = map[string]*GroupConfig{"default": {PassthroughPaths: []string{"v1/chat/completions"}}}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
deniedReq := httptest.NewRequest(http.MethodGet, "/openai/v1/files", nil)
|
||||||
|
deniedReq.Header.Set("Authorization", "Bearer sk-alice")
|
||||||
|
deniedRec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(deniedRec, deniedReq)
|
||||||
|
if deniedRec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status for v1/files = %d, want 403, body=%s", deniedRec.Code, deniedRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedReq := httptest.NewRequest(http.MethodGet, "/openai/v1/chat/completions", nil)
|
||||||
|
allowedReq.Header.Set("Authorization", "Bearer sk-alice")
|
||||||
|
allowedRec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(allowedRec, allowedReq)
|
||||||
|
if allowedRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status for v1/chat/completions = %d, want 200, body=%s", allowedRec.Code, allowedRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(gotPaths) != 1 || gotPaths[0] != "/v1/chat/completions" {
|
||||||
|
t.Errorf("upstream paths reached = %v, want exactly one call for the allowlisted path", gotPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlePassthrough_DangerousHeadersStripped proves every
|
||||||
|
// client-supplied identity/forwarding header, plus the provider-billing-
|
||||||
|
// retargeting headers, are stripped before the request reaches the
|
||||||
|
// upstream provider — additive to the existing hop-by-hop/gateway-
|
||||||
|
// credential/Accept-Encoding strips.
|
||||||
|
func TestHandlePassthrough_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", BaseURL: srv.URL, APIKey: "sk-up"}}
|
||||||
|
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, "/openai/v1/thing", 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-Forwarded-User", "spoofed-admin")
|
||||||
|
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("OpenAI-Organization", "org-not-mine")
|
||||||
|
req.Header.Set("OpenAI-Project", "proj-not-mine")
|
||||||
|
req.Header.Set("Anthropic-Beta", "some-beta-flag")
|
||||||
|
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-Forwarded-User", "X-Auth-Request-Email",
|
||||||
|
"X-Remote-User", "X-Remote-Groups", "Cookie",
|
||||||
|
"Openai-Organization", "Openai-Project", "Anthropic-Beta",
|
||||||
|
} {
|
||||||
|
if v := got.Get(hdr); v != "" {
|
||||||
|
t.Errorf("upstream 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlePassthrough_ZeroConfig_NewFieldsUnset_BehavesIdenticallyToBefore
|
||||||
|
// is the GATE's own explicit requirement: a config setting NONE of this
|
||||||
|
// round's new fields (ProviderConfig.Passthrough, GroupConfig.
|
||||||
|
// PassthroughPaths) — every config that existed before this round — must
|
||||||
|
// behave exactly as it did before: the request is proxied, the client's
|
||||||
|
// gateway key is swapped for the provider's own, and the body reaches the
|
||||||
|
// upstream unmodified. Mirrors TestHandlePassthrough_
|
||||||
|
// ClientKeySwappedForProviderKey's own config shape deliberately, to
|
||||||
|
// pin the same outcome this round must not have changed.
|
||||||
|
func TestHandlePassthrough_ZeroConfig_NewFieldsUnset_BehavesIdenticallyToBefore(t *testing.T) {
|
||||||
|
var gotAuth string
|
||||||
|
var gotBody []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
gotBody, _ = io.ReadAll(r.Body)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("upstream-ok"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Deliberately no Passthrough, no PassthroughPaths, no Models
|
||||||
|
// restriction anywhere in this config.
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{
|
||||||
|
"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-provider-real"},
|
||||||
|
}
|
||||||
|
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.MethodPost, "/openai/v1/anything/at/all", strings.NewReader(`{"foo":"bar"}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer sk-alice")
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
if rec.Body.String() != "upstream-ok" {
|
||||||
|
t.Errorf("body = %q, want verbatim upstream body", rec.Body.String())
|
||||||
|
}
|
||||||
|
if gotAuth != "Bearer sk-provider-real" {
|
||||||
|
t.Errorf("Authorization = %q, want the provider's own key injected, unchanged from before this round", gotAuth)
|
||||||
|
}
|
||||||
|
if string(gotBody) != `{"foo":"bar"}` {
|
||||||
|
t.Errorf("upstream body = %q, want the client body preserved unchanged", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user