3 Commits
Author SHA1 Message Date
lukaszraczylo 0346c3ed39 fix(registry): log model-id collisions at WARN, not ERROR
A model id served by two configured providers is resolved
deterministically by the registry, the winner is named in the message,
and no request is affected. Logging it at ERROR put 144 ERROR lines in a
healthy pod's boot log on a cluster with overlapping openai,
openai-audio, copilot and macstudio catalogs, which is exactly the noise
that hides a real failure.

Adds Gateway.warnf beside logf and errorf, and a warn field on
modelRegistry carrying it. The field is set by the production caller
after newModelRegistry returns rather than added as a fourth constructor
parameter: 45 call sites pass a logger today, almost all of them tests
passing a no-op, and threading a parameter through all of them would
bury a one-line severity change in unrelated churn. A nil warn falls
back to log, so a registry built without one keeps its current behaviour
instead of panicking or dropping the line.

registry.warnf takes an already-formatted string so both call shapes
stay single-variadic-argument. Yaegi v0.16.1's CFG builder panics on a
call to a struct FIELD of variadic func type with two or more variadic
arguments, which is the restriction the log field beside it already
documents.

Verified under the real interpreter, not only compiled: yaegi-check
drives a genuine two-provider collision and now prints WARN. The
severity test fails if the call site reverts to log, so the level cannot
drift back unnoticed.

Only this message moves. Genuine failures — discovery errors, metadata
capture panics, encoding failures — stay at ERROR.
2026-08-22 16:04:51 +01:00
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
lukaszraczylo 87ebee9003 fix(auth): throttle repeated auth failures, rate-limit failure log
Fixes finding 3 from the 2026-08-22 security audit: authStore.identify
had no counter, no backoff, and no lockout on failed attempts, and
logger.go wrote one unbuffered stderr line per attempt including the
caller's RemoteAddr. An unauthenticated attacker got unlimited digest
guesses at line rate and could drive synchronous stderr writes on the
shared Traefik ingress.

- Add per-source-IP failure throttling to identify, reusing the existing
  in-process memoryStore (limits.go) as authStore's own dedicated
  instance rather than a new dependency: authFailureLimit (20) failed
  attempts per authFailureWindow (1 minute) throttles further attempts
  from that IP outright, including a subsequently valid key, and resets
  once the window elapses.
- clientIP reads r.RemoteAddr only, never a client-supplied header,
  matching the same trust boundary the rest of this package already
  assumes (routes_passthrough.go strips X-Forwarded-*/X-Auth-* before any
  outbound request).
- Rate-limit logAuthEvent's failure line via authStore.shouldLogAuthFailure,
  the identical technique limiter.logStoreError already applies to store
  errors. Successful-auth logging stays unbounded (bounded by legitimate,
  already-authenticated traffic elsewhere).
2026-08-22 15:26:25 +01:00