From e3ef4084d2b6613fdfa11fcba39052ea46df0975 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 22 Aug 2026 11:40:27 +0100 Subject: [PATCH] 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. --- auth.go | 58 ++--- auth_test.go | 23 ++ llmgateway.go | 46 ++-- mcp_a2a.go | 2 +- mcp_a2a_test.go | 50 +++++ providers.go | 18 ++ providers_test.go | 33 +++ routes_passthrough.go | 175 +++++++++++++++- routes_passthrough_test.go | 418 +++++++++++++++++++++++++++++++++++++ 9 files changed, 766 insertions(+), 57 deletions(-) diff --git a/auth.go b/auth.go index b78ac6a..ccfa218 100644 --- a/auth.go +++ b/auth.go @@ -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, } } diff --git a/auth_test.go b/auth_test.go index bf01aac..723fade 100644 --- a/auth_test.go +++ b/auth_test.go @@ -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) { a, err := newAuthStore(testAuthCfg()) // inline user "a" / sk-secret in group eng if err != nil { diff --git a/llmgateway.go b/llmgateway.go index e284d5b..a67e4d9 100644 --- a/llmgateway.go +++ b/llmgateway.go @@ -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 { diff --git a/mcp_a2a.go b/mcp_a2a.go index 3c1f059..607eaed 100644 --- a/mcp_a2a.go +++ b/mcp_a2a.go @@ -216,7 +216,7 @@ func (g *Gateway) handleTargetProxy(w http.ResponseWriter, r *http.Request, u *u 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 diff --git a/mcp_a2a_test.go b/mcp_a2a_test.go index 2636453..77842d0 100644 --- a/mcp_a2a_test.go +++ b/mcp_a2a_test.go @@ -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", "spoofed@example.com") + 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) { diff --git a/providers.go b/providers.go index 76b7485..7f28a33 100644 --- a/providers.go +++ b/providers.go @@ -386,6 +386,24 @@ func validateMetadataPath(providerName, metadataPath string) error { 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 // by provider name. Every provider name is validated (validateConfigName) // and every ProviderConfig value must be non-nil before anything else runs, diff --git a/providers_test.go b/providers_test.go index ba7beec..77a64f6 100644 --- a/providers_test.go +++ b/providers_test.go @@ -6,6 +6,39 @@ import ( "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 diff --git a/routes_passthrough.go b/routes_passthrough.go index 1ed3c6e..3419e94 100644 --- a/routes_passthrough.go +++ b/routes_passthrough.go @@ -65,6 +65,75 @@ var clientNegotiationHeaders = map[string]bool{ "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 // 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 @@ -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: // "/{providerName}/{rest...}" reverse-proxies rest, verbatim, to // 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 // providerHTTPError. // -// Checks run in this order: group authorization (403) before capability -// checks (Upgrade→501, path validity→400) before rate limits (429/503) — -// a caller who cannot use providerName at all learns that first, rather -// than learning something about how they tried to use it. +// Checks run in this order: group authorization — provider (403), model +// when the body carries one (403), path allowlist (403) — before +// capability checks (Upgrade→501, path validity→400) before rate limits +// (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 // 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 // structure, including its "/v1beta/models/{model}:generateContent" // 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) { if !grp.allowsProvider(providerName) { writeOAIError(w, http.StatusForbidden, "invalid_request_error", "provider access denied") 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") != "" { writeOAIError(w, http.StatusNotImplemented, "invalid_request_error", "websocket/upgrade passthrough not supported") return @@ -290,7 +437,7 @@ func (g *Gateway) handlePassthrough(w http.ResponseWriter, r *http.Request, u *u 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 { // A build/connection/copy failure already wrote its own response // (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 // request, and Accept-Encoding (clientNegotiationHeaders) besides, so // 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 // 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 @@ -355,7 +511,7 @@ type proxyResult struct { // passes false, since target-proxy accounting never goes past the // request-count checkAndCount already ran before calling in, and teeing a // 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) // gosec G704 (SSRF via taint analysis) flags upstreamURL as // 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. 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 { injectAuth(upstreamReq) } diff --git a/routes_passthrough_test.go b/routes_passthrough_test.go index 074ba9b..21d6604 100644 --- a/routes_passthrough_test.go +++ b/routes_passthrough_test.go @@ -4,6 +4,7 @@ import ( "bytes" "compress/gzip" "context" + "errors" "io" "net/http" "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) } } + +// --- 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", "spoofed@example.com") + 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) + } +}