From 77b00bfe3fc51e16107dd1e5da8acff4f4b992a3 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 25 Aug 2026 11:01:49 +0800 Subject: [PATCH] feat(gateway): brute-force protection for /api/login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/gateway/login_guard_test.go | 114 +++++++++++++++++++++++++++ internal/gateway/server.go | 109 ++++++++++++++++++++++++- 2 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 internal/gateway/login_guard_test.go diff --git a/internal/gateway/login_guard_test.go b/internal/gateway/login_guard_test.go new file mode 100644 index 0000000..44a3843 --- /dev/null +++ b/internal/gateway/login_guard_test.go @@ -0,0 +1,114 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestLoginBruteForceLockout(t *testing.T) { + g := newTestGateway(t) + body := `{"key":"wrong-key-XXXX"}` + + // 5 failures allowed (each answered 401), 6th+ locked out + var gotLocked bool + for i := 1; i <= 8; i++ { + req := httptest.NewRequest("POST", "/api/login", strings.NewReader(body)) + rr := httptest.NewRecorder() + g.handleLoginAPI(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: status=%d want 401", i, rr.Code) + } + if i >= 6 { + // after lockout the response must be identical (no info leak) + if !strings.Contains(rr.Body.String(), "invalid gateway api key") { + t.Fatalf("lockout reply changed body: %s", rr.Body.String()) + } + gotLocked = true + } + } + if !gotLocked { + t.Fatal("expected lockout by attempt 6") + } + + // correct key from the SAME ip must also be rejected while locked + okBody := `{"key":"sk-test"}` + req := httptest.NewRequest("POST", "/api/login", strings.NewReader(okBody)) + rr := httptest.NewRecorder() + g.handleLoginAPI(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("locked-out valid key: status=%d want 401", rr.Code) + } + + // a different IP is unaffected + req = httptest.NewRequest("POST", "/api/login", strings.NewReader(okBody)) + req.RemoteAddr = "10.9.9.9:5555" + rr = httptest.NewRecorder() + g.handleLoginAPI(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("other IP valid key: status=%d want 200 body=%s", rr.Code, rr.Body.String()) + } + var resp map[string]interface{} + _ = json.Unmarshal(rr.Body.Bytes(), &resp) + if resp["ok"] != true { + t.Fatalf("login ok flag missing: %s", rr.Body.String()) + } +} + +func TestLoginSuccessResetsFailures(t *testing.T) { + g := newTestGateway(t) + mkBad := func() *http.Request { return httptest.NewRequest("POST", "/api/login", strings.NewReader(`{"key":"nope"}`)) } + for i := 0; i < loginMaxFails-1; i++ { // one below the lockout threshold + rr := httptest.NewRecorder() + g.handleLoginAPI(rr, mkBad()) + if rr.Code != http.StatusUnauthorized { + t.Fatal("setup failure") + } + } + // success clears the counter + rr := httptest.NewRecorder() + g.handleLoginAPI(rr, httptest.NewRequest("POST", "/api/login", strings.NewReader(`{"key":"sk-test"}`))) + if rr.Code != http.StatusOK { + t.Fatalf("valid login failed: %d", rr.Code) + } + // now up to loginMaxFails-1 more failures are still accepted before lock + for i := 0; i < loginMaxFails-1; i++ { + last := httptest.NewRecorder() + g.handleLoginAPI(last, mkBad()) + } + last := httptest.NewRecorder() + g.handleLoginAPI(last, mkBad()) + // count is at loginMaxFails-1 failures since reset; this next one reaches + // the threshold but the attempt itself is still evaluated normally (401), + // and the FOLLOWING one must be locked. + if last.Code != http.StatusUnauthorized { + t.Fatalf("threshold attempt should still answer 401: %d", last.Code) + } + next := httptest.NewRecorder() + g.handleLoginAPI(next, mkBad()) + if next.Code != http.StatusUnauthorized { + t.Fatal("want 401") + } + // verify lock actually engaged via the guard state + g.loginMu.Lock() + f := g.loginFails["192.0.2.1"] + engaged := f != nil && f.count >= loginMaxFails && f.until > 0 + g.loginMu.Unlock() + if !engaged { + t.Fatal("expected engaged lockout state") + } +} + +func TestClientIPExtraction(t *testing.T) { + r := httptest.NewRequest("POST", "/api/login", nil) + r.RemoteAddr = "203.0.113.7:12345" + if got := clientIP(r); got != "203.0.113.7" { + t.Errorf("clientIP=%q want 203.0.113.7", got) + } + r.Header.Set("X-Real-IP", "198.51.100.9") + if got := clientIP(r); got != "198.51.100.9" { + t.Errorf("clientIP with X-Real-IP=%q want proxy value", got) + } +} diff --git a/internal/gateway/server.go b/internal/gateway/server.go index c8f8575..76cf16f 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -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(); ` -// 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")