feat: add per-IP rate limiting to login endpoint
All checks were successful
check / check (push) Successful in 59s
All checks were successful
check / check (push) Successful in 59s
Add a token-bucket rate limiter (golang.org/x/time/rate) that limits login attempts per client IP on POST /api/v1/login. Returns 429 Too Many Requests with a Retry-After header when the limit is exceeded. Configurable via LOGIN_RATE_LIMIT (requests/sec, default 1) and LOGIN_RATE_BURST (burst size, default 5). Stale per-IP entries are automatically cleaned up every 10 minutes. Only the login endpoint is rate-limited per sneak's instruction — session creation and registration use hashcash proof-of-work instead.
This commit is contained in:
@@ -2380,6 +2380,121 @@ func TestSessionUsernameDefault(t *testing.T) {
|
||||
_ = token
|
||||
}
|
||||
|
||||
func TestLoginRateLimitExceeded(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
// Exhaust the burst (default: 5 per IP) using
|
||||
// nonexistent users. These fail fast (no bcrypt),
|
||||
// preventing token replenishment between requests.
|
||||
for range 5 {
|
||||
loginBody, mErr := json.Marshal(
|
||||
map[string]string{
|
||||
"nick": "nosuchuser",
|
||||
"password": "doesnotmatter",
|
||||
},
|
||||
)
|
||||
if mErr != nil {
|
||||
t.Fatal(mErr)
|
||||
}
|
||||
|
||||
loginResp, rErr := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/login"),
|
||||
bytes.NewReader(loginBody),
|
||||
)
|
||||
if rErr != nil {
|
||||
t.Fatal(rErr)
|
||||
}
|
||||
|
||||
_ = loginResp.Body.Close()
|
||||
}
|
||||
|
||||
// The next request should be rate-limited.
|
||||
loginBody, err := json.Marshal(map[string]string{
|
||||
"nick": "nosuchuser", "password": "doesnotmatter",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/login"),
|
||||
bytes.NewReader(loginBody),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf(
|
||||
"expected 429, got %d",
|
||||
resp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
retryAfter := resp.Header.Get("Retry-After")
|
||||
if retryAfter == "" {
|
||||
t.Fatal("expected Retry-After header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitAllowsNormalUse(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
// Register a user.
|
||||
regBody, err := json.Marshal(map[string]string{
|
||||
"nick": "normaluser", "password": "password123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/register"),
|
||||
bytes.NewReader(regBody),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// A single login should succeed without rate limiting.
|
||||
loginBody, err := json.Marshal(map[string]string{
|
||||
"nick": "normaluser", "password": "password123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp2, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/login"),
|
||||
bytes.NewReader(loginBody),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp2.Body)
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %s",
|
||||
resp2.StatusCode, respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNickBroadcastToChannels(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
aliceToken := tserver.createSession("nick_a")
|
||||
|
||||
@@ -168,6 +168,21 @@ func (hdlr *Handlers) handleLogin(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
ip := clientIP(request)
|
||||
|
||||
if !hdlr.loginLimiter.Allow(ip) {
|
||||
writer.Header().Set(
|
||||
"Retry-After", "1",
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"too many login attempts, try again later",
|
||||
http.StatusTooManyRequests,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Nick string `json:"nick"`
|
||||
Password string `json:"password"`
|
||||
@@ -198,6 +213,16 @@ func (hdlr *Handlers) handleLogin(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.executeLogin(
|
||||
writer, request, payload.Nick, payload.Password,
|
||||
)
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) executeLogin(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
nick, password string,
|
||||
) {
|
||||
remoteIP := clientIP(request)
|
||||
|
||||
hostname := resolveHostname(
|
||||
@@ -207,8 +232,7 @@ func (hdlr *Handlers) handleLogin(
|
||||
sessionID, clientID, token, err :=
|
||||
hdlr.params.Database.LoginUser(
|
||||
request.Context(),
|
||||
payload.Nick,
|
||||
payload.Password,
|
||||
nick, password,
|
||||
remoteIP, hostname,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -224,18 +248,18 @@ func (hdlr *Handlers) handleLogin(
|
||||
hdlr.stats.IncrConnections()
|
||||
|
||||
hdlr.deliverMOTD(
|
||||
request, clientID, sessionID, payload.Nick,
|
||||
request, clientID, sessionID, nick,
|
||||
)
|
||||
|
||||
// Initialize channel state so the new client knows
|
||||
// which channels the session already belongs to.
|
||||
hdlr.initChannelState(
|
||||
request, clientID, sessionID, payload.Nick,
|
||||
request, clientID, sessionID, nick,
|
||||
)
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"id": sessionID,
|
||||
"nick": payload.Nick,
|
||||
"nick": nick,
|
||||
"token": token,
|
||||
}, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
@@ -49,6 +50,7 @@ type Handlers struct {
|
||||
broker *broker.Broker
|
||||
hashcashVal *hashcash.Validator
|
||||
channelHashcash *hashcash.ChannelValidator
|
||||
loginLimiter *ratelimit.Limiter
|
||||
stats *stats.Tracker
|
||||
cancelCleanup context.CancelFunc
|
||||
}
|
||||
@@ -63,6 +65,16 @@ func New(
|
||||
resource = "neoirc"
|
||||
}
|
||||
|
||||
loginRate := params.Config.LoginRateLimit
|
||||
if loginRate <= 0 {
|
||||
loginRate = ratelimit.DefaultRate
|
||||
}
|
||||
|
||||
loginBurst := params.Config.LoginRateBurst
|
||||
if loginBurst <= 0 {
|
||||
loginBurst = ratelimit.DefaultBurst
|
||||
}
|
||||
|
||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
@@ -70,6 +82,7 @@ func New(
|
||||
broker: broker.New(),
|
||||
hashcashVal: hashcash.NewValidator(resource),
|
||||
channelHashcash: hashcash.NewChannelValidator(),
|
||||
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
||||
stats: params.Stats,
|
||||
}
|
||||
|
||||
@@ -162,6 +175,10 @@ func (hdlr *Handlers) stopCleanup() {
|
||||
if hdlr.cancelCleanup != nil {
|
||||
hdlr.cancelCleanup()
|
||||
}
|
||||
|
||||
if hdlr.loginLimiter != nil {
|
||||
hdlr.loginLimiter.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
|
||||
|
||||
Reference in New Issue
Block a user