mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
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:
114
internal/gateway/login_guard_test.go
Normal file
114
internal/gateway/login_guard_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
Reference in New Issue
Block a user