mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
Independent review measured two real gaps in the round-1 model
enforcement and fixed both here, plus documentation and comment
regressions the round introduced.
MUST FIX: peekPassthroughModel's Content-Type gate was an ALLOW-list
("application/json" or skip") and fully client-controlled. The
reviewer sent "Content-Type: text/plain" with {"model":"EXPENSIVE"}
and got a 200 from a group scoped to a cheap model. This commit
replaces the whole gating approach:
1. A group with an empty Models list has nothing for allowsModel to
reject, so the request body is never read at all
(group.hasModelRestriction). This is the overwhelming majority of
traffic, including the operator's own "home" group, and removes
the memory-buffering concern entirely for it.
2. For a group WITH a restricted Models list, the Content-Type gate
is now a SKIP-list of genuinely binary types (multipart/, audio/,
image/, video/, application/octet-stream). Every other
Content-Type, including "text/plain" or none at all, is
inspected. The attacker no longer controls whether the check
runs.
3. The peek reads at most 64KiB (maxModelPeekBytes), restored via
io.MultiReader so the remainder streams straight to the upstream
instead of a second full buffer. A bounded JSON token walk
(scanTopLevelModel) finds a top-level "model" key even when the
rest of the body is truncated past the cap.
4. A restricted group whose peek window never finds "model" is
denied (403), fail closed. This also means a Gemini passthrough
request (its model lives in the URL, never the body) is always
denied for a model-restricted group; documented in
maxModelPeekBytes' own comment, the handler's doc comment, and
the README.
Matcher parity: the round-1 version glued providerName+"/" onto
whatever the body sent unconditionally, so an already-prefixed body
("openai/x") produced "openai/openai/x" and false-403'd a legitimate
client. allowsPassthroughModel now reuses modelRegistry.
splitConfiguredProvider — the same rule resolve/resolveAgainst apply
to a client-given id — to strip a leading segment only when it names
an actually configured provider, before generating candidates.
Also in this commit:
- Restored ~26 lines of doc comments fieldalignment -fix silently
dropped when it reordered the group struct (auth.go: cache,
cacheTTL) and ProviderConfig (llmgateway.go: MetadataPath, and the
Passthrough field's own new comment). Verified nothing else was
reordered by re-running fieldalignment: 0 issues.
- Documented the path.Match footgun a non-empty PassthroughPaths
inherits: "*" does not cross "/", so ["*"] denies almost all real
multi-segment passthrough traffic rather than allowing it. Empty is
the actual allow-all value. Documented on the config field, the
allowsPassthroughPath method, and the handler's own doc comment.
- Renamed the local "model" that shadowed the outer request-model
variable near extractPassthroughUsage's call site to "respModel" —
the new gating already scopes the outer one to a narrower block, so
this was no longer a live shadow, but the rename removes any future
ambiguity between the request's model and the response's.
- README: rewrote the two "Unified vs. passthrough" table cells that
described the OPPOSITE of this feature's actual behavior, added a
"Native passthrough model authorization" section, and added
providers.<name>.passthrough / groups.<name>.passthroughPaths rows
to the config tables.
New tests: the Content-Type-bypass regression (text/plain, absent,
form-urlencoded, all now 403), the matcher-parity case (already-
prefixed body allowed), fail-closed on a binary body for a restricted
group, skip-entirely for an unrestricted group, byte-identical body
forwarding for a body larger than the peek cap and for a
ContentLength<0 (chunked) request, and a scanTopLevelModel unit table
covering truncation before/after the model field, nested values, and
malformed input.
Gate re-run in full after these changes: gofmt, go vet, fieldalignment,
golangci-lint (0 issues), go test -race (ok), make yaegi-check (OK),
make integration (ok, real Traefik + Redis). Re-ran the interpreted
Yaegi harness: RTT/req unchanged at 3.00, cmds/req unchanged at 74.00.
807 lines
38 KiB
Go
807 lines
38 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// maxPassthroughBytes caps a client passthrough request body: 32MiB,
|
|
// generous enough for a native multimodal payload (audio, image, or a
|
|
// large document) that the unified routes' 10MiB maxRequestBytes does not
|
|
// need to accommodate, while still bounding memory against an oversized
|
|
// or malicious body.
|
|
const maxPassthroughBytes = 32 << 20
|
|
|
|
// maxAccountingTeeBytes caps how much of a non-streaming JSON response
|
|
// body handlePassthrough tees off for best-effort usage accounting: 4MiB,
|
|
// generous for a usage object even inside a very large chat response,
|
|
// while the client-facing io.Copy streams every byte of the response —
|
|
// of any size — without ever waiting for the full body to buffer first.
|
|
const maxAccountingTeeBytes = 4 << 20
|
|
|
|
// maxModelPeekBytes caps how much of a passthrough request body
|
|
// peekPassthroughModel reads to find a top-level "model" field: 64KiB
|
|
// (security review fix, 2026-08-22, round 2). This is deliberately far
|
|
// smaller than maxPassthroughBytes: a legitimate LLM request always
|
|
// carries "model" in its first bytes, so 64KiB is generous headroom
|
|
// ahead of it while bounding memory to a small constant regardless of how
|
|
// large the rest of the body (a long messages/documents array) is. The
|
|
// body is never fully buffered to find this — see peekPassthroughModel's
|
|
// own doc comment for the io.MultiReader restoration that lets the
|
|
// remainder stream straight to the upstream, unread by this gateway.
|
|
//
|
|
// OPERATOR CONTRACT: a group with a non-empty Models list (GroupConfig,
|
|
// llmgateway.go) enforces model authorization on every native passthrough
|
|
// request whose body is inspected (see peekPassthroughModel's own gating)
|
|
// — and a request whose "model" field does not appear within this 64KiB
|
|
// window is DENIED (403), fail-closed, even if it would otherwise have
|
|
// been allowed. This includes a Gemini passthrough request: Gemini's
|
|
// model id lives in the URL path, never the JSON body, so a group with a
|
|
// restricted Models list and Gemini passthrough traffic will see every
|
|
// such request denied under this rule — route Gemini traffic for a
|
|
// model-restricted group through the unified /v1/* API instead, where
|
|
// modelRegistry.resolve enforces the same authorization against the
|
|
// URL-independent "model" field those routes already require.
|
|
const maxModelPeekBytes = 64 << 10
|
|
|
|
// passthroughBinaryContentTypePrefixes lists Content-Type prefixes
|
|
// peekPassthroughModel treats as genuinely binary and never reads at all
|
|
// (security review fix, 2026-08-22, round 2): multipart uploads
|
|
// (parakeet-mlx's transcription passthrough shape) and raw audio/image/
|
|
// video/octet-stream payloads carry no JSON "model" field to find, and
|
|
// reading them would only cost memory for nothing.
|
|
//
|
|
// This is a SKIP-list, not an ALLOW-list — the deliberate inversion of
|
|
// this gate's first version, which skipped the read unless Content-Type
|
|
// contained "application/json". That allow-list shape let a client bypass
|
|
// model enforcement outright merely by omitting Content-Type or sending
|
|
// an unexpected value (e.g. "text/plain"): the reviewer measured a
|
|
// {"model":"EXPENSIVE"} body reaching the upstream with a 200, because
|
|
// the Content-Type check alone decided whether the check ran, and
|
|
// Content-Type is entirely client-controlled. With a skip-list, every
|
|
// Content-Type NOT matching one of these prefixes — including "text/plain"
|
|
// and an ABSENT header — is inspected; the attacker no longer controls
|
|
// whether the check runs, only (still) what non-binary Content-Type they
|
|
// send.
|
|
var passthroughBinaryContentTypePrefixes = []string{
|
|
"multipart/",
|
|
"audio/",
|
|
"image/",
|
|
"video/",
|
|
"application/octet-stream",
|
|
}
|
|
|
|
// isPassthroughBinaryContentType reports whether contentType matches one
|
|
// of passthroughBinaryContentTypePrefixes, case-insensitively.
|
|
func isPassthroughBinaryContentType(contentType string) bool {
|
|
ct := strings.ToLower(contentType)
|
|
for _, prefix := range passthroughBinaryContentTypePrefixes {
|
|
if strings.HasPrefix(ct, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hopByHopHeaders lists the RFC 7230 §6.1 hop-by-hop headers, stripped
|
|
// from both the outgoing upstream request and the response copied back
|
|
// to the client — they describe this one TCP hop, not the end-to-end
|
|
// message, and must never be relayed by an intermediary.
|
|
var hopByHopHeaders = map[string]bool{
|
|
"Connection": true,
|
|
"Keep-Alive": true,
|
|
"Transfer-Encoding": true,
|
|
"Upgrade": true,
|
|
"Proxy-Authorization": true,
|
|
"Proxy-Connection": true,
|
|
"Te": true,
|
|
"Trailer": true,
|
|
}
|
|
|
|
// gatewayCredentialHeaders are the client's own gateway-authentication
|
|
// headers — auth.go's presentedKey reads exactly these two. Stripped from
|
|
// the outgoing upstream request so a client's gateway API key never
|
|
// reaches the upstream provider; adapter.injectAuth sets the provider's
|
|
// own credential afterward.
|
|
var gatewayCredentialHeaders = map[string]bool{
|
|
"Authorization": true,
|
|
"X-Api-Key": true,
|
|
}
|
|
|
|
// clientNegotiationHeaders are content-negotiation headers stripped from
|
|
// the outgoing upstream request only, never from the response:
|
|
// Accept-Encoding, so Go's http.Transport negotiates compression itself
|
|
// and transparently decompresses the reply — per its documented contract,
|
|
// that automatic decompression only happens when Transport itself added
|
|
// the header, not when the caller (this gateway, forwarding the client's
|
|
// own Accept-Encoding) set one explicitly. Without this strip, a client
|
|
// that sends "Accept-Encoding: gzip" (every mainstream OpenAI SDK and
|
|
// curl --compressed does) gets its exact header forwarded verbatim,
|
|
// Transport hands back raw gzip bytes, and the accounting JSON parse in
|
|
// handlePassthrough silently fails on binary it cannot decode.
|
|
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
|
|
// Transport are already canonicalized by net/http, so a plain map lookup
|
|
// against the canonical names above is exact.
|
|
func copyHeadersExcept(dst, src http.Header, excepts ...map[string]bool) {
|
|
for k, vs := range src {
|
|
skip := false
|
|
for _, ex := range excepts {
|
|
if ex[k] {
|
|
skip = true
|
|
break
|
|
}
|
|
}
|
|
if skip {
|
|
continue
|
|
}
|
|
dst[k] = append([]string(nil), vs...)
|
|
}
|
|
}
|
|
|
|
// passthroughRoute splits path into a candidate provider name and the
|
|
// remaining rest path, per the native passthrough convention
|
|
// "/{providerName}/{rest...}". Callers must pass path as
|
|
// r.URL.EscapedPath(), never the decoded r.URL.Path: a decoded path
|
|
// collapses a client's percent-encoded "%2f" into a literal "/" before it
|
|
// ever reaches this split, silently turning what the client sent as one
|
|
// opaque path segment into extra routing segments (and, downstream, into
|
|
// a "#" that would truncate the rest of the upstream URL at a fragment
|
|
// boundary if the encoded byte were "%23"). ok is false for an empty or
|
|
// "/"-only path, which carries no candidate provider name at all — the
|
|
// caller falls through to its next-route/404 handling in that case.
|
|
// passthroughRoute makes no claim the returned providerName is actually
|
|
// configured; the caller checks that against g.adapters before treating
|
|
// the request as passthrough.
|
|
func passthroughRoute(path string) (providerName, rest string, ok bool) {
|
|
trimmed := strings.TrimPrefix(path, "/")
|
|
if trimmed == "" {
|
|
return "", "", false
|
|
}
|
|
providerName, rest, _ = strings.Cut(trimmed, "/")
|
|
if providerName == "" {
|
|
return "", "", false
|
|
}
|
|
return providerName, rest, true
|
|
}
|
|
|
|
// hasTraversalSegment reports whether rest, once percent-decoded, contains
|
|
// a path segment exactly equal to "..". rest is still in its escaped form
|
|
// here (see passthroughRoute) — decoding it is a validation-only step,
|
|
// never used to build the outgoing upstream URL, so a legitimate
|
|
// percent-encoded segment (e.g. "a%2Fb" naming a literal "a/b" resource)
|
|
// still reaches the upstream exactly as the client sent it. Checking the
|
|
// decoded form, not the escaped one, is what catches an encoded traversal
|
|
// attempt like "..%2f..%2fsecret": as a single escaped segment it has no
|
|
// literal "/" for a naive split to catch, but a permissive upstream
|
|
// server decoding that same "%2f" itself would read it as "../../secret"
|
|
// — this check rejects it here instead. A rest that fails to
|
|
// url.PathUnescape at all is rejected too: a malformed percent-encoding
|
|
// is not a path this gateway can reason about safely.
|
|
func hasTraversalSegment(rest string) bool {
|
|
decoded, err := url.PathUnescape(rest)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
for _, seg := range strings.Split(decoded, "/") {
|
|
if seg == ".." {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// cappedAccountingBuffer is an io.Writer that accumulates up to
|
|
// maxAccountingTeeBytes of a response body, then silently discards the
|
|
// rest while still reporting every byte as written. It is the accounting
|
|
// side of an io.TeeReader wrapped around a non-streaming JSON response
|
|
// body in handlePassthrough: the client-facing io.Copy reads the real
|
|
// response (of any size) through the tee, and this buffer only ever
|
|
// retains the first maxAccountingTeeBytes of it for a best-effort usage
|
|
// parse afterward. truncated records whether the cap was hit, so the
|
|
// caller knows a parse would only fail on cut-off JSON and skips it.
|
|
type cappedAccountingBuffer struct {
|
|
buf bytes.Buffer
|
|
truncated bool
|
|
}
|
|
|
|
// Write implements io.Writer. It always reports success for the full
|
|
// input length — even once truncated is set and further bytes are being
|
|
// discarded — because this type is driven by io.TeeReader, whose Read
|
|
// aborts the underlying copy with an error the moment a tee Write returns
|
|
// anything short of len(p); silently dropping bytes past the cap must
|
|
// never disrupt the real, client-facing copy this buffer is only
|
|
// observing.
|
|
func (c *cappedAccountingBuffer) Write(p []byte) (int, error) {
|
|
n := len(p)
|
|
if c.truncated {
|
|
return n, nil
|
|
}
|
|
remaining := maxAccountingTeeBytes - c.buf.Len()
|
|
if remaining <= 0 {
|
|
c.truncated = true
|
|
return n, nil
|
|
}
|
|
if len(p) > remaining {
|
|
p = p[:remaining]
|
|
c.truncated = true
|
|
}
|
|
c.buf.Write(p) //nolint:errcheck // bytes.Buffer.Write never errors
|
|
return n, nil
|
|
}
|
|
|
|
// passthroughUsagePayload captures every shape the three provider types'
|
|
// native non-streaming JSON responses report token usage in, plus the
|
|
// optional top-level "model" field OpenAI- and Anthropic-shaped responses
|
|
// carry (Gemini's native response carries neither "model" nor any of the
|
|
// URL-supplied model id).
|
|
type passthroughUsagePayload struct {
|
|
Model string `json:"model"`
|
|
Usage struct {
|
|
PromptTokens int64 `json:"prompt_tokens"`
|
|
CompletionTokens int64 `json:"completion_tokens"`
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
} `json:"usage"`
|
|
UsageMetadata struct {
|
|
PromptTokenCount int64 `json:"promptTokenCount"`
|
|
CandidatesTokenCount int64 `json:"candidatesTokenCount"`
|
|
} `json:"usageMetadata"`
|
|
}
|
|
|
|
// extractPassthroughUsage best-effort parses body as one of the three
|
|
// provider types' native non-streaming JSON response shapes, returning
|
|
// its reported token usage and model id, keyed by typeName (one of
|
|
// providerTypeOpenAI/Anthropic/Gemini). The returned error is body's own
|
|
// json.Unmarshal error, when decoding failed — the caller logs it rather
|
|
// than discarding it, since a body that should have been JSON (its
|
|
// Content-Type said so) but did not decode as JSON is worth knowing
|
|
// about, not silently swallowing. Usage and model still come back as
|
|
// their best-effort zero/fallback values in that case: a zero usage is a
|
|
// no-op for the caller's account call, not a wrong charge. The model id
|
|
// falls back to "unknown/"+providerName when the response carries no
|
|
// "model" field of its own, which is always true for Gemini's native
|
|
// response.
|
|
func extractPassthroughUsage(typeName, providerName string, body []byte) (usage, string, error) {
|
|
var payload passthroughUsagePayload
|
|
err := json.Unmarshal(body, &payload)
|
|
|
|
model := payload.Model
|
|
if model == "" {
|
|
model = "unknown/" + providerName
|
|
}
|
|
|
|
switch typeName {
|
|
case providerTypeOpenAI:
|
|
return usage{prompt: payload.Usage.PromptTokens, completion: payload.Usage.CompletionTokens}, model, err
|
|
case providerTypeAnthropic:
|
|
return usage{prompt: payload.Usage.InputTokens, completion: payload.Usage.OutputTokens}, model, err
|
|
case providerTypeGemini:
|
|
return usage{prompt: payload.UsageMetadata.PromptTokenCount, completion: payload.UsageMetadata.CandidatesTokenCount}, model, err
|
|
default:
|
|
return usage{}, model, err
|
|
}
|
|
}
|
|
|
|
// peekPassthroughModel reads at most maxModelPeekBytes of a passthrough
|
|
// request body to find its top-level "model" field, then restores r.Body
|
|
// to stream the ORIGINAL, complete body to the upstream unchanged — the
|
|
// bytes already read prepended (io.MultiReader) to whatever remains
|
|
// unread on the real r.Body, never fully buffered in memory (security
|
|
// review fix, 2026-08-22, round 2: the first version buffered the whole
|
|
// body, up to 32MiB, into memory before forwarding it).
|
|
//
|
|
// Callers only invoke this once they have already decided enforcement is
|
|
// required (handlePassthrough checks group.hasModelRestriction() first) —
|
|
// this function itself has no fail-open/fail-closed opinion; hasModel
|
|
// simply reports whether a non-empty top-level "model" STRING was found
|
|
// within the peek window. It is skipped, at no cost — no read at all —
|
|
// for a Content-Type isPassthroughBinaryContentType recognizes as
|
|
// genuinely binary (multipart uploads, audio/image/video, octet-stream):
|
|
// none of those carry a JSON "model" field to find. Every OTHER
|
|
// Content-Type, including "text/plain" or an absent header, IS read: the
|
|
// caller decides what a "not found" result means, not this gate — see
|
|
// maxModelPeekBytes' own doc comment for why the caller's answer is
|
|
// fail-closed.
|
|
//
|
|
// The scan (scanTopLevelModel) is a bounded JSON TOKEN walk, not a full
|
|
// json.Unmarshal: the peek window is very often a PREFIX of a larger body
|
|
// (a long messages/documents array trailing the model field), and
|
|
// Unmarshal requires a complete, valid document — it would reject a
|
|
// legitimately-truncated-but-found "model" as if it were malformed JSON.
|
|
//
|
|
// err is non-nil only for a genuine body-read failure (client disconnect,
|
|
// deadline) distinct from merely reaching the end of a body shorter than
|
|
// the peek window — the caller maps err to the same 400 runUnified's own
|
|
// body-read failure uses (routes_unified.go).
|
|
func peekPassthroughModel(r *http.Request) (model string, hasModel bool, err error) {
|
|
if r.Body == nil || isPassthroughBinaryContentType(r.Header.Get("Content-Type")) {
|
|
return "", false, nil
|
|
}
|
|
|
|
head := make([]byte, maxModelPeekBytes)
|
|
n, readErr := io.ReadFull(r.Body, head)
|
|
switch readErr { //nolint:errorlint // io.ReadFull returns these sentinels bare, never wrapped — see its own doc comment
|
|
case nil:
|
|
// Read exactly maxModelPeekBytes: the body has at least that much
|
|
// left unread on r.Body, restored below via io.MultiReader.
|
|
case io.EOF, io.ErrUnexpectedEOF:
|
|
// Body was shorter than the peek window (io.EOF: empty; io.
|
|
// ErrUnexpectedEOF: 0 < n < len(head)) — head[:n] IS the whole
|
|
// body; r.Body is now exhausted, so appending it below is a
|
|
// harmless immediate EOF.
|
|
default:
|
|
return "", false, readErr
|
|
}
|
|
head = head[:n]
|
|
r.Body = io.NopCloser(io.MultiReader(bytes.NewReader(head), r.Body))
|
|
|
|
model, hasModel = scanTopLevelModel(head)
|
|
return model, hasModel, nil
|
|
}
|
|
|
|
// scanTopLevelModel walks head — a bounded, possibly-truncated PREFIX of
|
|
// a JSON request body (peekPassthroughModel's own maxModelPeekBytes cap)
|
|
// — as a stream of JSON tokens (encoding/json's Decoder.Token, the
|
|
// documented mechanism for parsing a document incrementally) and returns
|
|
// the string value of its top-level "model" key, without ever requiring
|
|
// head to be a complete, valid JSON document on its own: a legitimate
|
|
// request's "model" field sits well within the cap even when a trailing
|
|
// field (a long messages/documents array) does not, and this walk finds
|
|
// it regardless of what truncation or garbage follows. found is false —
|
|
// not an error — for anything else: head is not a JSON object at all,
|
|
// "model" never appears among its top-level keys before head runs out,
|
|
// or "model" is present but its value is not a string.
|
|
func scanTopLevelModel(head []byte) (model string, found bool) {
|
|
dec := json.NewDecoder(bytes.NewReader(head))
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
|
return "", false
|
|
}
|
|
for dec.More() {
|
|
keyTok, err := dec.Token()
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
key, ok := keyTok.(string)
|
|
if !ok {
|
|
return "", false // malformed: expected an object key
|
|
}
|
|
valTok, err := dec.Token()
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
if key == "model" {
|
|
s, ok := valTok.(string)
|
|
return s, ok && s != ""
|
|
}
|
|
if d, ok := valTok.(json.Delim); ok && (d == '{' || d == '[') {
|
|
if err := skipJSONValue(dec); err != nil {
|
|
return "", false
|
|
}
|
|
}
|
|
// Otherwise valTok was a scalar (string/float64/bool/nil) other
|
|
// than "model" — nothing further to do; loop to the next key.
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// skipJSONValue consumes the remainder of a nested JSON array/object
|
|
// value whose opening delimiter dec has already emitted, so
|
|
// scanTopLevelModel's own dec.More() call — which reports position in
|
|
// whatever object/array dec is CURRENTLY inside — correctly reports the
|
|
// outer top-level object's state again once this returns, rather than
|
|
// still appearing "inside" the value just skipped.
|
|
func skipJSONValue(dec *json.Decoder) error {
|
|
depth := 1
|
|
for depth > 0 {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d, ok := tok.(json.Delim); ok {
|
|
switch d {
|
|
case '{', '[':
|
|
depth++
|
|
case '}', ']':
|
|
depth--
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// allowsPassthroughModel reports whether grp's Models glob authorizes
|
|
// model for a native passthrough request already pinned to providerName
|
|
// by the URL (security review, 2026-08-22, round 2 — fixes a matcher-
|
|
// parity bug: gluing providerName+"/" onto model UNCONDITIONALLY, the
|
|
// round-1 version's approach, produces "openai/openai/x" and false-403s a
|
|
// legitimate client whose body already carries the prefixed form).
|
|
//
|
|
// This reuses modelRegistry.splitConfiguredProvider — the SAME rule
|
|
// resolve/resolveAgainst apply to a client-given model id (registry.go):
|
|
// a leading segment is stripped as a provider prefix ONLY when it names
|
|
// an ACTUALLY CONFIGURED provider, never blindly assumed. Three
|
|
// candidates are checked, covering this package's two established
|
|
// conventions for turning one model reference into every glob form an
|
|
// operator might reasonably write:
|
|
// - model, exactly as the client's body sent it (resolveAgainst's own
|
|
// "requestedID" candidate)
|
|
// - bareModel, model's own suffix once any genuinely-configured
|
|
// provider prefix is stripped — equal to model when it carried none
|
|
// (resolveAgainst's own "upstreamModel" candidate)
|
|
// - providerName+"/"+bareModel, the canonical id for the provider THIS
|
|
// route is actually pinned to (listFor's own p+"/"+id synthesis) —
|
|
// lets an operator write their glob against the addressed provider
|
|
// even when the client sends a bare model id
|
|
func (g *Gateway) allowsPassthroughModel(grp *group, providerName, model string) bool {
|
|
bareModel := model
|
|
if _, rest, ok := g.registry.splitConfiguredProvider(model); ok {
|
|
bareModel = rest
|
|
}
|
|
return grp.allowsModel(model) || grp.allowsModel(bareModel) || grp.allowsModel(providerName+"/"+bareModel)
|
|
}
|
|
|
|
// handlePassthrough implements the native provider passthrough route:
|
|
// "/{providerName}/{rest...}" reverse-proxies rest, verbatim, to
|
|
// providerName's configured upstream, with the client's gateway
|
|
// credential swapped for the provider's own. It is a raw proxy, not a
|
|
// translation layer like the unified routes — a non-2xx upstream status
|
|
// is forwarded to the client exactly as the upstream sent it, not wrapped
|
|
// in the gateway's own error envelope the way the unified routes wrap a
|
|
// providerHTTPError.
|
|
//
|
|
// Checks run in this order: group authorization — provider (403), model
|
|
// when the group restricts models at all (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 review, 2026-08-22, round 2 — closes a
|
|
// round-1 bypass): enforcement runs ONLY for a group with a non-empty
|
|
// Models list (grp.hasModelRestriction) — a group that has not opted into
|
|
// model restriction has nothing allowsModel could reject, so the request
|
|
// body is never even read, exactly as before this feature existed at all.
|
|
// For a restricted group, peekPassthroughModel reads (bounded,
|
|
// maxModelPeekBytes) for a top-level "model" field and this function
|
|
// checks it via allowsPassthroughModel — the round-1 version instead
|
|
// gated the READ ITSELF on Content-Type containing "application/json",
|
|
// which a client fully controls: sending "text/plain", an unexpected
|
|
// value, or no Content-Type at all skipped the check outright and let
|
|
// {"model":"EXPENSIVE"} straight through. There is no such escape now: a
|
|
// restricted group's every non-genuinely-binary request body is
|
|
// inspected (isPassthroughBinaryContentType's skip-list, not an
|
|
// allow-list), and one where "model" cannot be found within the peek
|
|
// window is DENIED (403) — fail CLOSED, not open, the opposite direction
|
|
// from a body with no inspectable model under round 1. This is
|
|
// deliberate: it only affects a group that already opted into model
|
|
// restriction, and a legitimate LLM request always carries "model" well
|
|
// within maxModelPeekBytes. See maxModelPeekBytes' own doc comment for
|
|
// the Gemini-passthrough interaction this creates (its model id lives in
|
|
// the URL, never the body) and the operator-facing contract.
|
|
//
|
|
// PATH ALLOWLIST (same review): 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 — see its own doc comment
|
|
// for a path.Match footgun this allow-list inherits (a literal "*"
|
|
// pattern does NOT mean "allow everything").
|
|
//
|
|
// 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.
|
|
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
|
|
}
|
|
|
|
if grp.hasModelRestriction() {
|
|
model, hasModel, err := peekPassthroughModel(r)
|
|
if err != nil {
|
|
writeOAIError(w, http.StatusBadRequest, "invalid_request_error", "cannot read request body")
|
|
return
|
|
}
|
|
if !hasModel {
|
|
writeOAIError(w, http.StatusForbidden, "invalid_request_error", "model could not be determined")
|
|
return
|
|
}
|
|
if !g.allowsPassthroughModel(grp, 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
|
|
}
|
|
|
|
if hasTraversalSegment(rest) {
|
|
writeOAIError(w, http.StatusBadRequest, "invalid_request_error", "invalid path")
|
|
return
|
|
}
|
|
|
|
scopes := withTotalScope(buildLimitScopes(u, grp))
|
|
if violation := g.limiter.checkAndCount(scopes); violation != nil {
|
|
writeLimitViolation(w, violation)
|
|
return
|
|
}
|
|
|
|
// providerName is only ever passed here by ServeHTTP's dispatch, which
|
|
// already confirmed it is a key of g.adapters before calling in.
|
|
adapter := g.adapters[providerName]
|
|
|
|
upstreamURL := adapter.base() + "/" + rest
|
|
if r.URL.RawQuery != "" {
|
|
upstreamURL += "?" + r.URL.RawQuery
|
|
}
|
|
|
|
// Feature A (v0.22): provider-level only — unlike runUnified/the media
|
|
// routes, the upstream model here lives in the response body
|
|
// (extractPassthroughUsage, below), read only after this attempt
|
|
// already resolved, so there is no model to attribute it to yet. See
|
|
// recordProviderAttempt's own doc comment (limits.go).
|
|
r = r.WithContext(withAttemptRecorder(r.Context(), func(resp *http.Response, attemptErr error) {
|
|
g.limiter.recordProviderAttempt(providerName, "", resp, attemptErr)
|
|
}))
|
|
|
|
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
|
|
// proxyUpstream); a non-JSON response has nothing more to account
|
|
// than the request itself, already counted by checkAndCount above.
|
|
return
|
|
}
|
|
if result.tee.truncated {
|
|
g.logf("passthrough: response body exceeded %d bytes; skipping usage accounting (provider %q)", maxAccountingTeeBytes, providerName)
|
|
return
|
|
}
|
|
|
|
// respModel: the RESPONSE body's own reported model id (extractPassthroughUsage
|
|
// reads it, when present, from the upstream's reply) — never named
|
|
// "model" here, so it can never be confused with (or shadow) the
|
|
// REQUEST body's "model" field the enforcement block above already
|
|
// consumed via peekPassthroughModel (review fix, 2026-08-22, round 2).
|
|
respUsage, respModel, unmarshalErr := extractPassthroughUsage(adapter.typeName(), providerName, result.tee.buf.Bytes())
|
|
if unmarshalErr != nil {
|
|
g.logf("passthrough: response body did not decode as JSON for usage accounting (provider %q): %v", providerName, unmarshalErr)
|
|
}
|
|
cost := unifiedCostMicros(providerName+"/"+respModel, respModel, respUsage, g.cfg.Pricing)
|
|
g.limiter.account(scopes, respUsage, cost)
|
|
}
|
|
|
|
// proxyResult is what proxyUpstream reports back to its caller once it has
|
|
// streamed a response to the client: enough for a caller that wants
|
|
// best-effort JSON usage accounting (handlePassthrough) to run it, without
|
|
// proxyUpstream itself knowing anything about usage or pricing. tee is
|
|
// nil unless accountJSON was true and the response's Content-Type was
|
|
// application/json.
|
|
type proxyResult struct {
|
|
tee *cappedAccountingBuffer
|
|
isJSON bool
|
|
}
|
|
|
|
// proxyUpstream is the shared reverse-proxy core behind both native
|
|
// provider passthrough (handlePassthrough, above) and the MCP/A2A target
|
|
// proxy (handleTargetProxy, mcp_a2a.go): it builds an upstream request
|
|
// from r's method/body/headers, sends it over client, and streams the
|
|
// response back to w incrementally through a flushWriter — so an SSE or
|
|
// other chunked upstream body reaches the client as each chunk arrives,
|
|
// never buffered until the copy finishes.
|
|
//
|
|
// Every hop-by-hop header (hopByHopHeaders) and the client's own gateway
|
|
// credential (gatewayCredentialHeaders) are stripped from the outgoing
|
|
// request, and Accept-Encoding (clientNegotiationHeaders) besides, so
|
|
// Transport can negotiate and transparently decompress compression
|
|
// 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
|
|
// no injected credential at all.
|
|
//
|
|
// A build failure or a dead upstream writes a 502 envelope to w and
|
|
// returns ok=false; a canceled client context (errors.Is context.Canceled)
|
|
// is logged, not surfaced, and also returns ok=false, writing nothing —
|
|
// the client is already gone. A response-copy failure after headers were
|
|
// already written also returns ok=false, since nothing further can be
|
|
// written to w at that point either way. logPrefix labels every
|
|
// logf/errorf line this call emits, so passthrough and target-proxy
|
|
// failures stay distinguishable in the log.
|
|
//
|
|
// accountJSON gates whether a non-streaming application/json response
|
|
// gets teed off into result.tee for the caller's own best-effort usage
|
|
// parse afterward: handlePassthrough passes true; handleTargetProxy
|
|
// 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), 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
|
|
// whole job is to forward a client-supplied path/query onto the
|
|
// upstream. The host component is never request-derived: every caller
|
|
// builds upstreamURL as an operator-configured base (a provider's
|
|
// adapter.base(), or an MCP-server/agent TargetConfig.URL validated at
|
|
// construction — see validateTargetURLs) plus "/"+rest — string
|
|
// concatenation, not a second url.Parse of rest alone — so no
|
|
// client-supplied value can change the scheme or host url.Parse
|
|
// resolves for the final string. rest is additionally
|
|
// traversal-checked by every caller before this point is reached
|
|
// (hasTraversalSegment rejects any ".." segment in its decoded form)
|
|
// and is forwarded here in its original escaped form — see
|
|
// passthroughRoute's and targetRoute's EscapedPath()-based splits — so
|
|
// a client cannot smuggle an encoded "/" (%2f) past this gateway's own
|
|
// routing only to have a permissive upstream reinterpret it as a real
|
|
// separator.
|
|
upstreamReq, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, bodyReader) //nolint:gosec // operator-fixed host, rest is traversal-checked and forwarded escaped; see comment above
|
|
if err != nil {
|
|
g.errorf("%s: build upstream request: %v", logPrefix, err)
|
|
writeOAIError(w, http.StatusBadGateway, "server_error", "upstream connection error")
|
|
return proxyResult{}, false
|
|
}
|
|
upstreamReq.ContentLength = r.ContentLength
|
|
if r.ContentLength < 0 || r.ContentLength > maxPassthroughBytes {
|
|
// Unknown or over-cap length: let the transport negotiate chunked
|
|
// transfer instead of advertising a Content-Length the capped
|
|
// bodyReader above may not actually deliver.
|
|
upstreamReq.ContentLength = -1
|
|
}
|
|
copyHeadersExcept(upstreamReq.Header, r.Header, hopByHopHeaders, gatewayCredentialHeaders, clientNegotiationHeaders, dangerousClientHeaders, extraStrip)
|
|
stripHeaderPrefixes(upstreamReq.Header, dangerousClientHeaderPrefixes)
|
|
if injectAuth != nil {
|
|
injectAuth(upstreamReq)
|
|
}
|
|
|
|
resp, err := client.Do(upstreamReq) //nolint:gosec // same upstreamReq built above; operator-fixed host, traversal-checked, see its construction comment
|
|
// Feature A (v0.22): proxyUpstream makes exactly one attempt (no
|
|
// retry.go policy wraps this path), so this fires once per call,
|
|
// whichever way it resolves. Only handlePassthrough's own context ever
|
|
// carries a recorder (attemptRecorderFromContext, providers.go) —
|
|
// handleTargetProxy (mcp_a2a.go), this function's other caller, never
|
|
// wraps r's context this way, so an MCP/A2A target proxy attempt is
|
|
// correctly never accounted as provider traffic.
|
|
if rec := attemptRecorderFromContext(r.Context()); rec != nil {
|
|
rec(resp, err)
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) {
|
|
g.logf("%s: client canceled request: %v", logPrefix, err)
|
|
return proxyResult{}, false
|
|
}
|
|
g.errorf("%s: upstream connection error: %v", logPrefix, err)
|
|
writeOAIError(w, http.StatusBadGateway, "server_error", "upstream connection error")
|
|
return proxyResult{}, false
|
|
}
|
|
defer resp.Body.Close() //nolint:errcheck // read-side close; nothing actionable on failure
|
|
|
|
copyHeadersExcept(w.Header(), resp.Header, hopByHopHeaders)
|
|
w.WriteHeader(resp.StatusCode)
|
|
fw := newFlushWriter(w)
|
|
|
|
// Only a non-streaming JSON response gets teed off for best-effort
|
|
// accounting, and only when the caller wants that at all (accountJSON);
|
|
// every other response streams straight through fw with no extra
|
|
// buffering — a large or slow upstream body must never sit waiting for
|
|
// full receipt before the client sees its first byte.
|
|
isJSON := accountJSON && strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "application/json")
|
|
var tee *cappedAccountingBuffer
|
|
var reader io.Reader = resp.Body
|
|
if isJSON {
|
|
tee = &cappedAccountingBuffer{}
|
|
reader = io.TeeReader(resp.Body, tee)
|
|
}
|
|
|
|
if _, err := io.Copy(fw, reader); err != nil {
|
|
g.errorf("%s: stream response body: %v", logPrefix, err)
|
|
return proxyResult{isJSON: isJSON, tee: tee}, false
|
|
}
|
|
return proxyResult{isJSON: isJSON, tee: tee}, true
|
|
}
|