feat(gateway): brute-force protection for /api/login

Prerequisite for removing the nginx global-auth layer in front of the
gateway: llmsproxy must defend its own login endpoint.

Design:
- per-IP failure counter: 5 consecutive failures trigger an exponential
  lockout (30s base, doubling per extra burst, capped at 30min); 15min of
  quiet forgives the counter
- global budget: max 100 failures/minute across all IPs so a distributed
  spray cannot outrun per-IP windows
- locked-out and over-budget attempts get the SAME 'invalid gateway api
  key' 401 as normal failures — no oracle to probe lockout state, no info
  leak on key validity timing
- successful login clears the IP's counter entirely
- clientIP(): prefers X-Real-IP (trusted nginx proxy), falls back to
  RemoteAddr host

Tests: lockout engages at threshold with identical replies, valid keys
rejected while locked, other IPs unaffected, success resets counters,
X-Real-IP extraction.
This commit is contained in:
dev
2026-08-25 11:01:49 +08:00
parent 21ec8f59d8
commit 77b00bfe3f
2 changed files with 222 additions and 1 deletions

View File

@ -11,6 +11,7 @@ import (
"fmt"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"strings"
@ -31,6 +32,87 @@ type Gateway struct {
stats *Stats
probeMu sync.Mutex
lastProbe time.Time
// loginGuard throttles /api/login brute-force attempts: per-IP failure
// counters with exponential backoff, plus a global cap so a distributed
// spray cannot outrun the per-IP window. Guarded by loginMu.
loginMu sync.Mutex
loginFails map[string]*loginFail
loginWin int64 // unix sec of current global window start
loginGlob int // failures in the global window
}
// loginFail tracks consecutive failed logins from one source IP.
type loginFail struct {
count int
until int64 // locked-out until this unix second (0 = not locked)
lastAt int64
}
const (
loginMaxFails = 5 // failures before lockout kicks in
loginLockBase = 30 // first lockout: 30s
loginLockCap = 30 * 60 // repeated lockouts cap at 30min
loginDecay = 15 * 60 // counters decay after 15min of quiet
loginGlobalMax = 100 // global failures per minute across all IPs
loginGlobalWindow = 60 // global window length (sec)
)
// loginAllow checks (and records) a login attempt for ip. It returns false
// when the attempt must be rejected: the IP is in exponential lockout or the
// global failure budget for the current window is exhausted.
func (g *Gateway) loginAllow(ip string, now int64) bool {
g.loginMu.Lock()
defer g.loginMu.Unlock()
if g.loginFails == nil {
g.loginFails = map[string]*loginFail{}
}
// global window rollover
if now-g.loginWin >= loginGlobalWindow {
g.loginWin = now
g.loginGlob = 0
}
f := g.loginFails[ip]
if f != nil && f.count > 0 && now-f.lastAt > loginDecay {
delete(g.loginFails, ip) // quiet long enough: forgive and forget
f = nil
}
if f != nil && f.until > now {
return false // still locked out
}
if g.loginGlob >= loginGlobalMax {
return false // global spray budget exhausted for this window
}
g.loginGlob++
return true
}
// loginRecord notes the outcome of one attempt: failures escalate toward an
// exponential lockout; a success clears the IP's counter entirely.
func (g *Gateway) loginRecord(ip string, ok bool, now int64) {
g.loginMu.Lock()
defer g.loginMu.Unlock()
if ok {
if g.loginFails != nil {
delete(g.loginFails, ip)
}
return
}
f := g.loginFails[ip]
if f == nil {
f = &loginFail{}
g.loginFails[ip] = f
}
f.count++
f.lastAt = now
if f.count >= loginMaxFails {
// lock duration doubles with each extra burst of failures, capped
epoch := int64(f.count/loginMaxFails) - 1
dur := int64(loginLockBase) << min(epoch, 6)
if dur > loginLockCap {
dur = loginLockCap
}
f.until = now + dur
}
}
func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
@ -336,23 +418,34 @@ document.getElementById('key').addEventListener('keydown',function(e){if(e.key==
apply();
</script></body></html>`
// handleLoginAPI validates the gateway key and issues a session cookie.
// handleLoginAPI validates the gateway key and issues a session cookie,
// guarded by per-IP + global brute-force throttling (see loginGuard).
func (g *Gateway) handleLoginAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST")
return
}
now := time.Now().Unix()
if !g.loginAllow(clientIP(r), now) {
// Don't reveal whether the key was right: answer the same 401 the
// failure path uses, but skip the expensive key lookup.
writeError(w, http.StatusUnauthorized, "invalid_api_key", "invalid gateway api key")
return
}
var body struct {
Key string `json:"key"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
g.loginRecord(clientIP(r), false, now)
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json")
return
}
if _, ok := g.core.FindKey(body.Key); !ok {
g.loginRecord(clientIP(r), false, now)
writeError(w, http.StatusUnauthorized, "invalid_api_key", "invalid gateway api key")
return
}
g.loginRecord(clientIP(r), true, now)
http.SetCookie(w, &http.Cookie{
Name: "gw_key",
Value: body.Key,
@ -364,6 +457,20 @@ func (g *Gateway) handleLoginAPI(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}
// clientIP returns the caller's IP for rate limiting. Behind a trusted
// reverse proxy (nginx) the X-Real-IP header is preferred; otherwise the
// remote address is used with the port stripped.
func clientIP(r *http.Request) string {
if x := r.Header.Get("X-Real-IP"); x != "" {
return x
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func (g *Gateway) handleModels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")