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).
This commit is contained in:
2026-08-22 15:26:35 +01:00
parent d2a0c4860f
commit 3536c32667
4 changed files with 480 additions and 186 deletions
+254 -119
View File
@@ -189,38 +189,162 @@ type authEntry struct {
} }
// authFailureWindow and authFailureLimit bound how many failed identify // authFailureWindow and authFailureLimit bound how many failed identify
// attempts one source IP may make before further attempts from it are // attempts one source IP may make within a window before this package
// throttled outright (security audit finding 3, 2026-08-22): identify // treats it as "throttled" for OBSERVABILITY purposes only (security
// previously had no counter, no backoff, and no lockout at all — an // audit finding 3, 2026-08-22; revised by security review round 2,
// unauthenticated caller got unlimited digest-lookup attempts at line // 2026-08-22, critical finding 1 — see identify's own doc comment for
// rate. 20 failures per minute is generous headroom for a legitimate // why this is observability, not enforcement, post-revision). 20
// client with a stale or mistyped key retried a few times (a human // failures per minute is generous headroom for a legitimate client with
// pasting the wrong key, a misconfigured script cycling through a couple // a stale or mistyped key retried a few times, while still flagging a
// of candidates) while still meaningfully bounding a scripted guesser's // source failing fast enough to be worth an operator's attention.
// throughput per source IP. This does not, and cannot, defend the
// underlying key space on its own (a SHA-256 digest lookup already makes
// brute force infeasible) — its real job is capping the synchronous cost
// an unauthenticated caller can impose on the shared Traefik ingress per
// unit time: identify's own digest lookup, and logAuthEvent's stderr
// write (logger.go), both of which this finding's own framing calls out.
const ( const (
authFailureWindow = time.Minute authFailureWindow = time.Minute
authFailureLimit = 20 authFailureLimit = 20
// authFailureLogEvery rate-limits logAuthEvent's own failed-auth log // authFailureLogEvery rate-limits BOTH logAuthEvent's routine
// line (logger.go) the identical way limiter.logStoreError (limits.go) // failed-auth line and its distinct throttle-engaged line (logger.go)
// already rate-limits store-error lines — sharing that function's own // — the identical technique limiter.logStoreError (limits.go) already
// storeErrorLogEvery interval keeps this codebase's "how often do we // applies to store-error lines, sharing that function's own
// log a sustained failure condition" cadence consistent across both // storeErrorLogEvery interval for cadence consistency.
// call sites, rather than picking a new, unrelated number here.
authFailureLogEvery = storeErrorLogEvery authFailureLogEvery = storeErrorLogEvery
) )
// authFailureKeyPrefix namespaces authStore's failure-tracking keys in its // authFailureMapCap bounds authFailureTracker's total tracked-source-IP
// own memoryStore instance (failures, below) — a plain string key, not // count across both its generations (security review round 2,
// windowKey's (kind, id, metric, window) tuple (limits.go), since auth // 2026-08-22, important finding 1): a distributed attacker sending
// failures are tracked per SOURCE IP, a concept the rest of this // failed attempts from many distinct IPs could otherwise grow the
// package's counter keying was never built to express. // tracker's memory without any limit, and a naive full-map sweep to
const authFailureKeyPrefix = "authfail:" // expire old entries would hold its lock for a duration that scales with
// tracked-IP count (measured, on the general-purpose memoryStore this
// field used before this revision: ~83ms mutex-held at 1M tracked IPs,
// blocking every identify call for that duration). authFailureTracker's
// own two-generation rotation (its doc comment below) bounds total memory
// to at most 2×authFailureMapCap entries with an O(1) rotation, never a
// scan.
const authFailureMapCap = 50_000
// authFailureEntry is one source IP's failure count within
// authFailureTracker, expiring authFailureWindow after its first failure
// in the current generation.
type authFailureEntry struct {
expiry time.Time
count int64
}
// authFailureTracker is a bounded, two-generation per-source-IP failed-
// auth-attempt counter (security review round 2, 2026-08-22, important
// finding 1) — deliberately NOT the general-purpose memoryStore
// (limits.go): memoryStore's map has no size cap at all and its
// sweepLocked does a full, mutex-held O(n) scan, which is fine for the
// bounded-by-configuration scope space checkAndCount/account use (one
// entry per configured user/group) but wrong for a value with no
// configured upper bound like source IP.
//
// current is the active generation every increment/read goes to first;
// previous is the immediately-prior generation, consulted as a read-only
// fallback so an IP's count does not spuriously read as zero the instant
// after a rotation. Rotation — current becomes previous, a fresh empty
// map becomes current — is a two-pointer swap (O(1), never a scan),
// triggered on inserting a brand-new IP once current is already at
// authFailureMapCap; total memory is bounded to at most
// 2×authFailureMapCap entries. An IP whose entry is displaced by a
// rotation simply starts a fresh count on its next failure — the same
// outcome a normal authFailureWindow rollover already produces for any
// tracked IP; this is accepted, lossy-under-extreme-load behavior, the
// same trade-off limiter.spawnTokens' own doc comment (limits.go)
// accepts for a different bounded resource.
type authFailureTracker struct {
current map[string]*authFailureEntry
previous map[string]*authFailureEntry
nowFn func() time.Time
mu sync.Mutex
}
// newAuthFailureTracker returns an empty tracker using nowFn for its
// clock — a func value, not a copied time.Time, so a caller that later
// swaps what nowFn points to (authStore's own fakeClock-in-tests
// convention) is honored on every subsequent call.
func newAuthFailureTracker(nowFn func() time.Time) *authFailureTracker {
return &authFailureTracker{current: make(map[string]*authFailureEntry), nowFn: nowFn}
}
// get returns ip's current failure count, or 0 if it is untracked or its
// window has expired. Checks current first, then previous.
func (t *authFailureTracker) get(ip string) int64 {
now := t.nowFn()
t.mu.Lock()
defer t.mu.Unlock()
if e, ok := t.current[ip]; ok && now.Before(e.expiry) {
return e.count
}
if e, ok := t.previous[ip]; ok && now.Before(e.expiry) {
return e.count
}
return 0
}
// increment records one failure for ip and returns its new count for the
// current window, creating a fresh entry in current if ip is untracked in
// current or its own entry there already expired — a plain fixed-window
// counter, matching memoryStore.incrBy's own contract (limits.go) in
// spirit. Rotates current -> previous before inserting a brand-new ip
// once current is already at authFailureMapCap — see this type's own doc
// comment for why that bounds total memory without ever scanning.
func (t *authFailureTracker) increment(ip string) int64 {
now := t.nowFn()
t.mu.Lock()
defer t.mu.Unlock()
if e, ok := t.current[ip]; ok {
if now.Before(e.expiry) {
e.count++
return e.count
}
e.count = 1
e.expiry = now.Add(authFailureWindow)
return 1
}
if len(t.current) >= authFailureMapCap {
t.previous = t.current
t.current = make(map[string]*authFailureEntry, authFailureMapCap)
}
t.current[ip] = &authFailureEntry{count: 1, expiry: now.Add(authFailureWindow)}
return 1
}
// logGate rate-limits a single log line to once per authFailureLogEvery,
// counting how many calls were suppressed in between so the next actually
// -written line can report them (security review round 2, 2026-08-22,
// important finding 6): a single global rate-limit gate would otherwise
// make several DISTINCT attacker IPs failing within one interval look
// identical to a single noisy one — three separate IPs each failing once
// in a 30s window previously produced only ONE log line total, with no
// sign two more existed. Reporting the suppressed count in the next line
// restores that visibility without tracking a per-IP gate (an LRU of
// recently-logged IPs was the documented alternative; a suppressed count
// is simpler and gives an operator the same "something else happened"
// signal). Two independent zero-value-usable instances back authStore's
// routine-failure and throttle-engaged lines (below) so a burst of one
// can never suppress, or be suppressed by, the other. The very first call
// on a fresh gate always logs (lastLogAt's zero value is more than
// authFailureLogEvery before any real now).
type logGate struct {
lastLogAt time.Time
suppressed int64
mu sync.Mutex
}
func (g *logGate) shouldLog(now time.Time) (log bool, suppressed int64) {
g.mu.Lock()
defer g.mu.Unlock()
if !g.lastLogAt.IsZero() && now.Sub(g.lastLogAt) < authFailureLogEvery {
g.suppressed++
return false, 0
}
suppressed = g.suppressed
g.suppressed = 0
g.lastLogAt = now
return true, suppressed
}
// authStore identifies requests by API key and resolves them to a user and // authStore identifies requests by API key and resolves them to a user and
// their group. Raw API keys are never retained after construction — only // their group. Raw API keys are never retained after construction — only
@@ -235,33 +359,31 @@ const authFailureKeyPrefix = "authfail:"
// serializes maybeReload's throttle check and the reload it may trigger. // serializes maybeReload's throttle check and the reload it may trigger.
// //
// failures is identify's own per-source-IP failed-attempt counter // failures is identify's own per-source-IP failed-attempt counter
// (security audit finding 3, 2026-08-22) — the SAME in-process memoryStore // (security audit finding 3, 2026-08-22; failureLog/throttleLog added by
// type limiter's fallback store already uses (limits.go), a deliberately // security review round 2, 2026-08-22) — see identify's own doc comment
// reused type rather than a new dependency, given its own TTL-bucketed // for why this is pure observability, never enforcement, and
// counter semantics already fit this need exactly. It is authStore's own // authFailureTracker's own doc comment for why it is not the
// dedicated instance, not limiter.fallback: auth failures are a distinct // general-purpose memoryStore (limits.go). It is authStore's own
// concept (throttling an unauthenticated caller) from LLM traffic limits // dedicated instance: auth failures are a distinct concept from LLM
// (throttling an authenticated one's usage), and authStore must work // traffic limits, and authStore must work identically whether or not a
// identically whether or not a Gateway even has a limiter wired yet. // Gateway even has a limiter wired yet. failureLog/throttleLog back
// authFailLogMu/authFailLastLogAt back shouldLogAuthFailure, the identical // logAuthEvent's two distinct rate-limited lines (logger.go).
// "guard a timestamp with a small mutex, rate-limit a log line" shape
// limiter.logMu/lastLogAt already use.
type authStore struct { type authStore struct {
groups map[string]*group lastModTime time.Time
inline map[[32]byte]*authEntry // built once by newAuthStore; never mutated afterward lastCheck time.Time
byDigest map[[32]byte]*authEntry // inline entries plus the current file-sourced set; guarded by mu log gatewayLogger
usersFile *usersFile // nil when Users.File is not configured; maybeReload no-ops failures *authFailureTracker
log gatewayLogger // set alongside usersFile; unused when usersFile is nil usersFile *usersFile
nowFn func() time.Time // injected for tests; defaults to time.Now nowFn func() time.Time
failures *memoryStore // per-source-IP failed-identify counters; see doc comment above groups map[string]*group
lastCheck time.Time // guarded by reloadMu byDigest map[[32]byte]*authEntry
lastModTime time.Time // guarded by reloadMu inline map[[32]byte]*authEntry
authFailLastLogAt time.Time // guarded by authFailLogMu; last time a failed-auth line was logged failureLog logGate
fileUserCount int // guarded by mu; size of the current file-sourced user set throttleLog logGate
mu sync.RWMutex fileUserCount int
buildMu sync.Mutex mu sync.RWMutex
reloadMu sync.Mutex buildMu sync.Mutex
authFailLogMu sync.Mutex reloadMu sync.Mutex
} }
// newAuthStore builds an authStore from cfg's groups and inline users. It // newAuthStore builds an authStore from cfg's groups and inline users. It
@@ -274,13 +396,12 @@ func newAuthStore(cfg *Config) (*authStore, error) {
byDigest: make(map[[32]byte]*authEntry), byDigest: make(map[[32]byte]*authEntry),
nowFn: time.Now, nowFn: time.Now,
} }
a.failures = newMemoryStore()
// Delegate through a.nowFn rather than copying it: a test that // Delegate through a.nowFn rather than copying it: a test that
// overrides a.nowFn AFTER construction (the fakeClock convention // overrides a.nowFn AFTER construction (the fakeClock convention
// newReloadableAuthStore already uses, users_file_test.go) must still // newReloadableAuthStore already uses, users_file_test.go) must still
// control failures' own clock — this closure reads a.nowFn fresh on // control failures' own clock — this closure reads a.nowFn fresh on
// every call, so it always sees whichever func is currently assigned. // every call, so it always sees whichever func is currently assigned.
a.failures.nowFn = func() time.Time { return a.nowFn() } a.failures = newAuthFailureTracker(func() time.Time { return a.nowFn() })
for name, gc := range cfg.Groups { for name, gc := range cfg.Groups {
if gc == nil { if gc == nil {
return nil, fmt.Errorf("llmgateway: group %q: config must not be nil", name) return nil, fmt.Errorf("llmgateway: group %q: config must not be nil", name)
@@ -487,47 +608,51 @@ func clientIP(r *http.Request) string {
return host return host
} }
// authThrottled reports whether ip has already reached authFailureLimit // authThrottled reports whether ip has reached authFailureLimit failed
// failed identify attempts within the current authFailureWindow — a // identify attempts within the current authFailureWindow — a read-only
// read-only check, so a throttled IP's later attempts cost nothing beyond // observability signal for logAuthEvent's distinct throttle-engaged line
// one in-process map read, never a digest computation or byDigest lookup. // (logger.go). identify itself never consults this (security review
// round 2, 2026-08-22, critical finding 1 — see identify's own doc
// comment for why).
func (a *authStore) authThrottled(ip string) bool { func (a *authStore) authThrottled(ip string) bool {
count, _ := a.failures.get(authFailureKeyPrefix + ip) // memoryStore.get never errors return a.failures.get(ip) >= authFailureLimit
return count >= authFailureLimit
} }
// recordAuthFailure increments ip's failure count for the current // recordAuthFailure increments ip's failure count for the current
// authFailureWindow, creating a fresh window if the previous one already // authFailureWindow (authFailureTracker.increment's own fixed-window
// expired (memoryStore.incrBy's own fixed-window contract, limits.go). // contract). Pure observability post security review round 2's critical
// finding 1: it never influences identify's own return value (see
// identify's doc comment) — only authThrottled's read, for
// logAuthEvent's distinct throttle-engaged line.
func (a *authStore) recordAuthFailure(ip string) { func (a *authStore) recordAuthFailure(ip string) {
_, _ = a.failures.incrBy(authFailureKeyPrefix+ip, 1, authFailureWindow) // memoryStore.incrBy never errors a.failures.increment(ip)
} }
// shouldLogAuthFailure reports whether the next failed-auth log line // shouldLogAuthFailure reports whether the next ROUTINE failed-auth log
// (logAuthEvent, logger.go) should actually be written, rate-limited to // line (logAuthEvent, logger.go) should actually be written, and how many
// once per authFailureLogEvery regardless of how many attempts fail in // prior failure events were suppressed since the last line actually
// between — the identical technique limiter.logStoreError (limits.go) // wrote (logGate's own doc comment covers the suppressed-count
// already applies to store errors, applied here so an attacker driving // reasoning) — the same rate-limiting technique limiter.logStoreError
// unlimited failed-auth attempts can never drive unlimited synchronous // (limits.go) already applies to store-error lines, applied here so an
// stderr writes on the shared Traefik ingress (security audit finding 3, // attacker driving unlimited failed-auth attempts can never drive
// 2026-08-22) even before authThrottled's own per-IP cap engages (a // unlimited synchronous stderr writes on the shared Traefik ingress
// distributed attempt spread across many source IPs never trips any // (security audit finding 3, 2026-08-22). Successful-auth logging is
// single IP's own threshold, but must still not flood the log). // deliberately left unbounded: it requires an already-valid API key, so
// Successful-auth logging is deliberately left unbounded: it requires an // its volume is bounded by legitimate authenticated traffic — itself
// already-valid API key, so its volume is bounded by legitimate // already governed by checkAndCount's requests-per-minute/day limits
// authenticated traffic — itself already governed by checkAndCount's // elsewhere — not by an unauthenticated caller's attempt rate, which is
// requests-per-minute/day limits elsewhere — not by an unauthenticated // exactly what this finding is about bounding.
// caller's attempt rate, which is exactly what this finding is about func (a *authStore) shouldLogAuthFailure() (log bool, suppressed int64) {
// bounding. return a.failureLog.shouldLog(a.nowFn())
func (a *authStore) shouldLogAuthFailure() bool { }
now := a.nowFn()
a.authFailLogMu.Lock() // shouldLogThrottleEngaged is shouldLogAuthFailure's counterpart for the
defer a.authFailLogMu.Unlock() // distinct "this source IP is currently throttled" line (security review
if now.Sub(a.authFailLastLogAt) < authFailureLogEvery { // round 2, 2026-08-22, important finding 5) — its own separate rate-limit
return false // gate (logGate's own doc comment), so a burst of routine failure lines
} // can never suppress, or be suppressed by, this higher-signal line.
a.authFailLastLogAt = now func (a *authStore) shouldLogThrottleEngaged() (log bool, suppressed int64) {
return true return a.throttleLog.shouldLog(a.nowFn())
} }
// identify resolves r's presented API key to a user and their group. It // identify resolves r's presented API key to a user and their group. It
@@ -536,40 +661,50 @@ func (a *authStore) shouldLogAuthFailure() bool {
// is looked up by SHA-256 digest, then verified with a constant-time // is looked up by SHA-256 digest, then verified with a constant-time
// compare — the key itself is never retained. // compare — the key itself is never retained.
// //
// Per-source-IP throttling (security audit finding 3, 2026-08-22): a // The presented key is verified FIRST, UNCONDITIONALLY (security review
// request from an IP already over authThrottled's own budget is rejected // round 2, 2026-08-22, critical finding 1): a request carrying a VALID
// immediately, before any digest work at all. Every OTHER failure path // key always succeeds, before any per-source-IP throttle state is even
// below — no key presented, key not found, or a (practically unreachable, // read. The prior revision of this function checked authThrottled BEFORE
// see subtle.ConstantTimeCompare's own call site) digest mismatch — // verifying the key at all — but this plugin runs as a Traefik middleware
// records one failure for the caller's IP via recordAuthFailure. A // behind a Kubernetes Service, and with externalTrafficPolicy: Cluster
// successful identify never records a failure and never resets a prior // (this operator's own live deployment), r.RemoteAddr is the NODE's own
// count: this is a rate limit on failed attempts, not a lockout a // SNAT address, shared by every external caller reaching that node, not
// legitimate request can clear early. // a per-tenant address. Confirmed against this operator's own 10 live
// users: 20 failed requests from anywhere on the internet, sharing that
// node, locked out every one of them, including the admin dashboard's own
// poll. Verifying the key first makes that impossible — a legitimate,
// already-valid request can never be rejected by another caller's failed
// attempts, from the same address or not.
//
// Every failure path below — no key presented, key not found, or a
// (practically unreachable, see subtle.ConstantTimeCompare's own call
// site) digest mismatch — records one failure for the caller's source IP
// via recordAuthFailure; logAuthEvent (logger.go) separately decides, per
// call, whether to emit a routine failure line or a distinct throttle-
// engaged line by consulting authThrottled itself. No branch in this
// function ever changes its own return value based on throttle state —
// throttling is pure observability now (bounding the log's write volume
// and the tracker's own memory; authFailureTracker's doc comment,
// above), never enforcement: an attacker with no valid key gains nothing
// from this change (every failure path here already returned false
// either way, throttled or not), and the raw per-attempt cost this
// function pays (one SHA-256 digest, one map lookup, one
// ConstantTimeCompare) is already cheap enough — nanoseconds — that
// gating it early bought negligible protection against the cost itself,
// only the false-positive risk the paragraph above describes.
func (a *authStore) identify(r *http.Request) (*user, *group, bool) { func (a *authStore) identify(r *http.Request) (*user, *group, bool) {
ip := clientIP(r)
if a.authThrottled(ip) {
return nil, nil, false
}
key, ok := presentedKey(r) key, ok := presentedKey(r)
if !ok { if ok {
a.recordAuthFailure(ip) digest := sha256.Sum256([]byte(key))
return nil, nil, false a.mu.RLock()
entry, found := a.byDigest[digest]
a.mu.RUnlock()
if found && subtle.ConstantTimeCompare(digest[:], entry.digest[:]) == 1 {
return entry.user, entry.group, true
}
} }
digest := sha256.Sum256([]byte(key)) a.recordAuthFailure(clientIP(r))
return nil, nil, false
a.mu.RLock()
entry, ok := a.byDigest[digest]
a.mu.RUnlock()
if !ok {
a.recordAuthFailure(ip)
return nil, nil, false
}
if subtle.ConstantTimeCompare(digest[:], entry.digest[:]) != 1 {
a.recordAuthFailure(ip)
return nil, nil, false
}
return entry.user, entry.group, true
} }
// presentedKey extracts the API key from r's Authorization Bearer header or // presentedKey extracts the API key from r's Authorization Bearer header or
+124 -50
View File
@@ -597,83 +597,157 @@ func TestClientIP_MalformedRemoteAddr_UsedAsIs(t *testing.T) {
} }
} }
// TestAuthStore_Identify_ThrottlesAfterRepeatedFailures_EvenValidKeyRejected // TestAuthStore_Identify_ValidKeyAlwaysSucceeds_EvenWhenIPThrottled is the
// is the core regression for finding 3: identify previously had no // core regression for security review round 2's critical finding 1: this
// counter, no backoff, and no lockout at all. After authFailureLimit // plugin runs as a Traefik middleware behind a Kubernetes Service — with
// failed attempts from one source IP, even a subsequently CORRECT key // externalTrafficPolicy: Cluster, r.RemoteAddr is the NODE's own SNAT
// from that same IP must be rejected — proving the throttle actually // address, shared by every external caller reaching that node, not a
// short-circuits real auth once tripped, not merely that repeated wrong // per-tenant one. The prior revision of identify checked the per-IP
// keys keep failing (which they would anyway). A different source IP must // throttle BEFORE verifying the key, so 20 failed requests from anywhere
// be entirely unaffected, proving the throttle is per-IP, not global. // sharing that node locked out every legitimate tenant too. After well
func TestAuthStore_Identify_ThrottlesAfterRepeatedFailures_EvenValidKeyRejected(t *testing.T) { // past authFailureLimit failed attempts from one source IP, a SUBSEQUENT
a, err := newAuthStore(testAuthCfg()) // inline user "a" / sk-secret // VALID key from that EXACT same IP must still succeed immediately.
func TestAuthStore_Identify_ValidKeyAlwaysSucceeds_EvenWhenIPThrottled(t *testing.T) {
a, err := newAuthStore(testAuthCfg()) // inline user "a" / sk-secret, group eng
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
const attackerIP = "203.0.113.9:54321" const sharedIP = "203.0.113.9:54321"
for i := 0; i < authFailureLimit; i++ { for i := 0; i < authFailureLimit*2; i++ { // well past the threshold
r := httptest.NewRequest("POST", "/v1/chat/completions", nil) r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.RemoteAddr = attackerIP r.RemoteAddr = sharedIP
r.Header.Set("x-api-key", "wrong-key") r.Header.Set("x-api-key", "wrong-key")
if _, _, ok := a.identify(r); ok { a.identify(r)
t.Fatalf("attempt %d: want failure for a wrong key, got success", i) }
} // Sanity: the tracker really did cross the threshold — otherwise this
// test would not actually exercise the throttled-IP path at all.
if !a.authThrottled("203.0.113.9") {
t.Fatal("test setup: expected the tracker to be over authFailureLimit for the shared IP")
} }
// Throttle should now be engaged for attackerIP — even the CORRECT
// key must be rejected.
valid := httptest.NewRequest("POST", "/v1/chat/completions", nil) valid := httptest.NewRequest("POST", "/v1/chat/completions", nil)
valid.RemoteAddr = attackerIP valid.RemoteAddr = sharedIP
valid.Header.Set("Authorization", "Bearer sk-secret") valid.Header.Set("Authorization", "Bearer sk-secret")
if _, _, ok := a.identify(valid); ok { u, grp, ok := a.identify(valid)
t.Fatal("want a throttled IP rejected even with a valid key") if !ok || u.name != "a" || grp.name != "eng" {
} t.Fatalf("want a valid key to succeed even from a throttled source IP, got user=%v group=%v ok=%v", u, grp, ok)
// A different source IP must be unaffected by attackerIP's throttle.
other := httptest.NewRequest("POST", "/v1/chat/completions", nil)
other.RemoteAddr = "198.51.100.5:1111"
other.Header.Set("Authorization", "Bearer sk-secret")
if _, _, ok := a.identify(other); !ok {
t.Fatal("want a different source IP unaffected by another IP's throttle")
} }
} }
// TestAuthStore_Identify_ThrottleResetsAfterWindowExpires proves the // TestAuthStore_AuthThrottled_TracksPerIPIndependently proves
// throttle is a fixed-window rate limit, not a permanent lockout: once // authFailureTracker's own counting (used only by logAuthEvent's distinct
// authFailureWindow has actually elapsed, the same IP's valid key works // throttle-engaged line now, logger.go — never by identify's return
// again. // value, see identify's own doc comment) is still scoped per source IP:
func TestAuthStore_Identify_ThrottleResetsAfterWindowExpires(t *testing.T) { // one IP crossing the threshold must never affect a different IP's own
a, err := newAuthStore(testAuthCfg()) // inline user "a" / sk-secret // count.
func TestAuthStore_AuthThrottled_TracksPerIPIndependently(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
for i := 0; i < authFailureLimit; i++ {
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.RemoteAddr = "203.0.113.9:1"
r.Header.Set("x-api-key", "wrong-key")
a.identify(r)
}
if !a.authThrottled("203.0.113.9") {
t.Error("want 203.0.113.9 throttled after authFailureLimit failures")
}
if a.authThrottled("198.51.100.5") {
t.Error("want a different, never-failing IP to remain unthrottled")
}
}
// TestAuthStore_AuthThrottled_ResetsAfterWindowExpires proves
// authFailureTracker is a fixed-window counter, not a permanent record:
// once authFailureWindow has elapsed, a source IP's earlier failures no
// longer count toward authThrottled.
func TestAuthStore_AuthThrottled_ResetsAfterWindowExpires(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
clock := &fakeClock{now: time.Unix(1_700_000_000, 0)} clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
a.nowFn = clock.Now a.nowFn = clock.Now
const attackerIP = "203.0.113.9:54321"
for i := 0; i < authFailureLimit; i++ { for i := 0; i < authFailureLimit; i++ {
r := httptest.NewRequest("POST", "/v1/chat/completions", nil) r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.RemoteAddr = attackerIP r.RemoteAddr = "203.0.113.9:1"
r.Header.Set("x-api-key", "wrong-key") r.Header.Set("x-api-key", "wrong-key")
if _, _, ok := a.identify(r); ok { a.identify(r)
t.Fatalf("attempt %d: want failure for a wrong key, got success", i)
}
} }
blocked := httptest.NewRequest("POST", "/v1/chat/completions", nil) if !a.authThrottled("203.0.113.9") {
blocked.RemoteAddr = attackerIP
blocked.Header.Set("Authorization", "Bearer sk-secret")
if _, _, ok := a.identify(blocked); ok {
t.Fatal("want throttled before the window elapses") t.Fatal("want throttled before the window elapses")
} }
clock.Advance(authFailureWindow + time.Second) clock.Advance(authFailureWindow + time.Second)
recovered := httptest.NewRequest("POST", "/v1/chat/completions", nil) if a.authThrottled("203.0.113.9") {
recovered.RemoteAddr = attackerIP t.Error("want the throttle cleared once authFailureWindow has elapsed")
recovered.Header.Set("Authorization", "Bearer sk-secret") }
if _, _, ok := a.identify(recovered); !ok { }
t.Fatal("want the same IP's valid key to work again once authFailureWindow has elapsed")
// --- authFailureTracker: direct unit tests ---
func TestAuthFailureTracker_IncrementAndGet(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
tr := newAuthFailureTracker(func() time.Time { return now })
if got := tr.get("1.2.3.4"); got != 0 {
t.Errorf("get on untracked ip = %d, want 0", got)
}
for i := int64(1); i <= 5; i++ {
if got := tr.increment("1.2.3.4"); got != i {
t.Errorf("increment #%d = %d, want %d", i, got, i)
}
}
if got := tr.get("1.2.3.4"); got != 5 {
t.Errorf("get after 5 increments = %d, want 5", got)
}
}
func TestAuthFailureTracker_WindowExpiry(t *testing.T) {
clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
tr := newAuthFailureTracker(clock.Now)
tr.increment("1.2.3.4")
tr.increment("1.2.3.4")
if got := tr.get("1.2.3.4"); got != 2 {
t.Fatalf("get = %d, want 2", got)
}
clock.Advance(authFailureWindow + time.Second)
if got := tr.get("1.2.3.4"); got != 0 {
t.Errorf("get after window expiry = %d, want 0", got)
}
if got := tr.increment("1.2.3.4"); got != 1 {
t.Errorf("increment after window expiry = %d, want 1 (fresh window)", got)
}
}
// TestAuthFailureTracker_BoundsMemoryViaRotation is the regression for
// security review round 2's important finding 1: the tracker must never
// grow past 2×authFailureMapCap total entries — filling it past
// authFailureMapCap distinct IPs must trigger an O(1) generation
// rotation, not unbounded growth.
func TestAuthFailureTracker_BoundsMemoryViaRotation(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
tr := newAuthFailureTracker(func() time.Time { return now })
for i := 0; i < authFailureMapCap+10; i++ {
tr.increment(fmt.Sprintf("10.%d.%d.%d", i/65536, (i/256)%256, i%256))
}
tr.mu.Lock()
total := len(tr.current) + len(tr.previous)
currentLen := len(tr.current)
tr.mu.Unlock()
if total > 2*authFailureMapCap {
t.Errorf("tracked entries = %d, want <= %d (2x cap)", total, 2*authFailureMapCap)
}
if currentLen > authFailureMapCap {
t.Errorf("current generation = %d entries, want <= %d", currentLen, authFailureMapCap)
} }
} }
+44 -17
View File
@@ -19,32 +19,59 @@ func (g *Gateway) errorf(format string, args ...any) {
} }
// logAuthEvent logs one authentication attempt, at the point one of // logAuthEvent logs one authentication attempt, at the point one of
// authStore.identify's six call sites resolves it. A failure logs the // authStore.identify's six call sites resolves it. A success logs the
// method, path, and remote address the request came from; a success logs // resolved user's name alongside the request's method and path. Neither
// the resolved user's name alongside the same method and path. Neither // line this function ever writes includes the presented API key itself —
// line ever includes the presented API key itself — identify resolves a // identify resolves a key to a *user and hands this function only that
// key to a *user and hands this function only that user's name, never the // user's name, never the key it authenticated.
// key it authenticated.
// //
// The failure line is rate-limited via g.auth.shouldLogAuthFailure // A failure logs one of two distinct lines (security review round 2,
// (auth.go), the same technique limiter.logStoreError (limits.go) already // 2026-08-22, important finding 5): if the caller's source IP
// applies to store-error lines (security audit finding 3, 2026-08-22): an // (clientIP) is currently authThrottled, a distinct "throttle engaged"
// unauthenticated caller driving unlimited failed attempts must never be // line naming the IP and the limit it exceeded — an operator seeing this
// able to drive unlimited synchronous stderr writes on the shared Traefik // knows immediately that a SOURCE, not just an individual request, needs
// ingress — a slow log pipe would otherwise stall request handling for // attention, which the routine per-attempt line alone does not convey.
// every tenant, not just the one failing auth. The success line stays // Otherwise the routine failure line (method, path, remote address).
// unbounded — see shouldLogAuthFailure's own doc comment for why that is // Both lines are independently rate-limited (authStore.shouldLog
// safe. // 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) { func (g *Gateway) logAuthEvent(ok bool, userName string, r *http.Request) {
if !ok { if !ok {
if g.auth.shouldLogAuthFailure() { ip := clientIP(r)
g.logf("auth failed: %s %q from %s", r.Method, r.URL.Path, r.RemoteAddr) 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 return
} }
g.logf("auth ok: user %q %s %q", userName, r.Method, r.URL.Path) 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 // authEventUserName extracts u's name for logAuthEvent's userName
// argument, or "" when u is nil — identify's failure path, which never // argument, or "" when u is nil — identify's failure path, which never
// resolves a user. // resolves a user.
+58
View File
@@ -122,3 +122,61 @@ func TestLogAuthEvent_Success_NeverRateLimited(t *testing.T) {
t.Fatalf("auth-ok lines = %d, want 5 (success logging must never be rate-limited), output=%q", n, out) t.Fatalf("auth-ok lines = %d, want 5 (success logging must never be rate-limited), output=%q", n, out)
} }
} }
// TestLogAuthEvent_ThrottleEngaged_DistinctLine proves logAuthEvent emits
// a distinct "auth throttle:" line, not the routine "auth failed:" one,
// once a source IP has crossed authFailureLimit (security review round
// 2, 2026-08-22, important finding 5) — an operator seeing this line
// knows a SOURCE, not just one request, needs attention.
func TestLogAuthEvent_ThrottleEngaged_DistinctLine(t *testing.T) {
gw := newTestGatewayForLogger(t)
clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
gw.auth.nowFn = clock.Now
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.RemoteAddr = "203.0.113.9:1234"
for i := 0; i < authFailureLimit; i++ {
gw.auth.recordAuthFailure(clientIP(r))
}
out := captureStderr(t, func() {
gw.logAuthEvent(false, "", r)
})
if !strings.Contains(out, "auth throttle:") {
t.Errorf("want an 'auth throttle:' line once the IP has crossed the limit, got %q", out)
}
if strings.Contains(out, "auth failed:") {
t.Errorf("want the routine 'auth failed:' line suppressed in favor of the distinct throttle line, got %q", out)
}
}
// TestLogAuthEvent_Failure_SuppressedCountReported proves the routine
// failure line's rate-limit gate reports how many events it suppressed
// (security review round 2, 2026-08-22, important finding 6): a single
// global gate would otherwise make several rapid failures within one
// window indistinguishable from a single one — the next line actually
// written must name how many were collapsed into it.
func TestLogAuthEvent_Failure_SuppressedCountReported(t *testing.T) {
gw := newTestGatewayForLogger(t)
clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
gw.auth.nowFn = clock.Now
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.RemoteAddr = "203.0.113.9:1234"
captureStderr(t, func() {
gw.logAuthEvent(false, "", r) // logged (first call on a fresh gate)
gw.logAuthEvent(false, "", r) // suppressed #1
gw.logAuthEvent(false, "", r) // suppressed #2
})
clock.Advance(authFailureLogEvery + time.Second)
out := captureStderr(t, func() {
gw.logAuthEvent(false, "", r)
})
if !strings.Contains(out, "2 more suppressed since last log") {
t.Errorf("want the next line to report 2 suppressed events, got %q", out)
}
}