Files
traefik-llmgateway/logger.go
T
lukaszraczylo d0d29b8c52 fix(security): bound fan-out and method surface and scrub relayed text
- Add a process-wide gate on MCP tools/list backend connections. The
  per-request limit alone let N callers open N times as many.
- Reject CONNECT and TRACE on native passthrough and on MCP and A2A
  targets with 405.
- Scrub URL query strings and the target URL from a backend JSON-RPC
  error message before it reaches the caller.
- Replace the value of redis.address in GET /admin/api/config. The
  users file path stays visible by design.
- Escape line breaks in every log line so an upstream string cannot
  forge log entries.
- Add stripClientAnthropicBeta. When true, POST /v1/messages does not
  forward the client anthropic-beta header. The default stays false.
2026-10-15 22:20:07 +01:00

187 lines
8.2 KiB
Go

package traefikllmgateway
import (
"fmt"
"net/http"
"os"
"strings"
)
// logSafe escapes the line breaks in msg. A value an upstream controls, such
// as a panic or error string, must not forge extra log lines.
func logSafe(msg string) string {
if !strings.ContainsAny(msg, "\r\n") {
return msg
}
return strings.NewReplacer("\r", "\\r", "\n", "\\n").Replace(msg)
}
// logf writes an info-level log line to stderr. No timestamps — Traefik
// adds its own. Never log key material.
func (g *Gateway) logf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "llmgw[%s] INFO %s\n", g.name, logSafe(fmt.Sprintf(format, args...)))
}
// warnf writes a warning-level log line to stderr. No timestamps —
// Traefik adds its own. Never log key material.
//
// Use this for a condition an operator may want to know about but that
// the gateway resolved by itself, deterministically, with no request
// affected — a model id served by two providers, for instance. Those
// lines were ERROR until 2026-08-22, which put 144 of them in a healthy
// pod's boot log on a cluster with overlapping catalogs and made real
// errors harder to find. Reserve errorf for something actually broken.
//
// Also calls noteConfigWarning (F10, v0.3 dashboard task): while
// newGateway is still constructing g (collectingWarnings true), the
// formatted message is additionally collected into g.configWarnings —
// GET /admin/api/overview's Warnings field. A warnf call from
// request-handling code, after construction finished, is NOT collected —
// see noteConfigWarning's own doc comment for why.
func (g *Gateway) warnf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, "llmgw[%s] WARN %s\n", g.name, logSafe(msg))
g.noteConfigWarning(msg)
}
// configWarningsCap bounds how many construction-time warnings
// g.configWarnings ever holds (F10, v0.3 dashboard task) — a
// misconfigured deployment logging hundreds of warnings during
// construction must not make GET /admin/api/overview's response itself
// unbounded. configWarningsDropped counts what did not fit, so the
// dashboard can say "and N more" instead of silently truncating.
const configWarningsCap = 50
// noteConfigWarning appends msg to g.configWarnings while newGateway is
// still collecting them — collectingWarnings is set true right after g is
// constructed and false just before newGateway returns (llmgateway.go),
// so every warnf call in that window is a CONSTRUCTION-time warning,
// exactly what GET /admin/api/overview's Warnings field surfaces. Once
// collectingWarnings is false (every warnf call from request-handling
// code, for the rest of the process lifetime), this is a no-op: such a
// warning already reached the operator via the stderr line warnf itself
// wrote, and an unbounded stream of runtime warnings must never keep
// growing this slice for the life of the process.
func (g *Gateway) noteConfigWarning(msg string) {
g.warnMu.Lock()
defer g.warnMu.Unlock()
if !g.collectingWarnings {
return
}
if len(g.configWarnings) >= configWarningsCap {
g.configWarningsDropped++
return
}
g.configWarnings = append(g.configWarnings, msg)
}
// configWarningsSnapshot returns a fresh copy of g.configWarnings — never
// nil, matching GET /admin/api/overview's own "never nil" contract for
// its Warnings field — and the dropped count, safe for concurrent use
// alongside noteConfigWarning.
func (g *Gateway) configWarningsSnapshot() (warnings []string, dropped int) {
g.warnMu.Lock()
defer g.warnMu.Unlock()
out := make([]string, len(g.configWarnings))
copy(out, g.configWarnings)
return out, g.configWarningsDropped
}
// errorf writes an error-level log line to stderr. No timestamps — Traefik
// adds its own. Never log key material.
func (g *Gateway) errorf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "llmgw[%s] ERROR %s\n", g.name, logSafe(fmt.Sprintf(format, args...)))
}
// pricingWarn is g's own per-instance pricing-warning sink, passed to
// costMicrosKnownFor (pricing.go) so an unpriced-model warning always logs
// under this instance's `llmgw[name]` prefix (finding F11 / review-auth
// F8, 2026-09 review: a package-level warn func replaced on every New()
// call made the most recently constructed instance log every other
// instance's warnings).
//
// A plain method, not a stored closure field: it reads g.name fresh on
// every call, exactly like logf/warnf/errorf above, so there is nothing
// new to keep in sync across a config reload that rebuilds g.
func (g *Gateway) pricingWarn(msg string) {
if msg == warnCapMessage {
g.logf("%s", msg)
return
}
g.logf("pricing: no price configured for model %q; cost will be recorded as 0", msg)
// F3 hook 4 (v0.3 dashboard task): msg is the bare unpriced model id
// here (warnUnknownModelFor's own "warn(model)" call, pricing.go) —
// the cap-message branch above is a process-wide meta-warning, not
// about any one model, so it is deliberately excluded from the event
// feed. Route is the fixed "pricing" label: this fires deep inside
// cost accounting, with no request-scoped route name available.
g.recordEvent(gatewayEvent{
Model: msg, Route: routePricing, Kind: eventKindUnpriced,
Message: fmt.Sprintf("no price configured for model %q; cost recorded as 0", msg),
})
}
// logAuthEvent logs one authentication attempt, at the point one of
// authStore.identify's six call sites resolves it. A success logs the
// resolved user's name alongside the request's method and path. Neither
// line this function ever writes includes the presented API key itself —
// identify resolves a key to a *user and hands this function only that
// user's name, never the key it authenticated.
//
// A failure logs one of two distinct lines (security review round 2,
// 2026-08-22, important finding 5): if the caller's source IP
// (clientIP) is currently authThrottled, a distinct "throttle engaged"
// line naming the IP and the limit it exceeded — an operator seeing this
// knows immediately that a SOURCE, not just an individual request, needs
// attention, which the routine per-attempt line alone does not convey.
// Otherwise the routine failure line (method, path, remote address).
// Both lines are independently rate-limited (authStore.shouldLog
// ThrottleEngaged / shouldLogAuthFailure, auth.go — the identical
// technique limiter.logStoreError, limits.go, already applies to
// store-error lines) so an unauthenticated caller driving unlimited
// failed attempts can never drive unlimited synchronous stderr writes on
// the shared Traefik ingress — a slow log pipe would otherwise stall
// request handling for every tenant, not just the one failing auth. Each
// gate reports how many events it suppressed since its last written line
// (security review round 2, important finding 6): a single global gate
// would otherwise make several DISTINCT attacker IPs failing within one
// interval indistinguishable from one noisy IP — suppressedSuffix
// appends that count to the line when non-zero. The success line stays
// completely unbounded — see shouldLogAuthFailure's own doc comment for
// why that is safe.
func (g *Gateway) logAuthEvent(ok bool, userName string, r *http.Request) {
if !ok {
ip := clientIP(r)
if g.auth.authThrottled(ip) {
if log, suppressed := g.auth.shouldLogThrottleEngaged(); log {
g.logf("auth throttle: source %s exceeded %d failed attempts in the last %s%s", ip, authFailureLimit, authFailureWindow, suppressedSuffix(suppressed))
}
return
}
if log, suppressed := g.auth.shouldLogAuthFailure(); log {
g.logf("auth failed: %s %q from %s%s", r.Method, r.URL.Path, r.RemoteAddr, suppressedSuffix(suppressed))
}
return
}
g.logf("auth ok: user %q %s %q", userName, r.Method, r.URL.Path)
}
// suppressedSuffix formats n (a logGate's own suppressed-event count,
// auth.go) as a log-line suffix, or "" when there is nothing to report.
func suppressedSuffix(n int64) string {
if n <= 0 {
return ""
}
return fmt.Sprintf(" (%d more suppressed since last log)", n)
}
// authEventUserName extracts u's name for logAuthEvent's userName
// argument, or "" when u is nil — identify's failure path, which never
// resolves a user.
func authEventUserName(u *user) string {
if u == nil {
return ""
}
return u.name
}