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
+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
// authStore.identify's six call sites resolves it. A failure logs the
// method, path, and remote address the request came from; a success logs
// the resolved user's name alongside the same method and path. Neither
// line ever 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.
// 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.
//
// The failure line is rate-limited via g.auth.shouldLogAuthFailure
// (auth.go), the same technique limiter.logStoreError (limits.go) already
// applies to store-error lines (security audit finding 3, 2026-08-22): an
// unauthenticated caller driving unlimited failed attempts must never be
// able to 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. The success line stays
// unbounded — see shouldLogAuthFailure's own doc comment for why that is
// safe.
// 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 {
if g.auth.shouldLogAuthFailure() {
g.logf("auth failed: %s %q from %s", r.Method, r.URL.Path, r.RemoteAddr)
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.