Files
traefik-llmgateway/logger.go
T
lukaszraczylo 3536c32667 fix(auth): let a valid key always win over IP throttle
Fixes critical finding 1 from security review round 2, 2026-08-22:
identify checked the per-source-IP throttle BEFORE verifying the
presented key. This plugin runs behind a Kubernetes Service, and with
externalTrafficPolicy: Cluster (this operator's own deployment),
r.RemoteAddr is the node's own SNAT address shared by every external
caller reaching that node — not a per-tenant address. 20 failed requests
from anywhere on the internet sharing that node locked out all 10 live
users, including the admin dashboard's own poll.

- identify now verifies the key FIRST, unconditionally: a valid key
  always succeeds before any throttle state is read. Every failure path
  still records against the caller's IP, but throttling is now pure
  observability (bounding log volume and tracker memory), never
  enforcement — an attacker with no valid key already got 'false'
  either way, so nothing is lost against brute force.
- Replaces the per-source-IP tracker's backing store: the general-
  purpose memoryStore (limits.go) has no size cap and a full mutex-held
  sweep that scales with tracked-IP count (~83ms at 1M IPs). authFailureTracker
  is a bounded, two-generation, O(1)-rotation structure capped at
  2x50,000 entries (important finding 1).
- logAuthEvent now emits a distinct, separately rate-limited
  'auth throttle:' line the first time a source crosses the limit
  (important finding 5), and both the routine and throttle-engaged
  lines report how many events they suppressed since the last line
  actually written, so several distinct attacker IPs in one window no
  longer collapse into a single indistinguishable line (important
  finding 6).
- The success path no longer touches the throttle tracker's mutex at
  all (important finding 2).
2026-08-22 15:26:35 +01:00

84 lines
3.5 KiB
Go

package traefikllmgateway
import (
"fmt"
"net/http"
"os"
)
// 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, fmt.Sprintf(format, args...))
}
// 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, fmt.Sprintf(format, args...))
}
// 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
}