feat: add per-IP rate limiting to login endpoint (#78)
All checks were successful
check / check (push) Successful in 6s
All checks were successful
check / check (push) Successful in 6s
## Summary Adds per-IP rate limiting to `POST /api/v1/login` to prevent brute-force password attacks. closes #35 ## What Changed ### New package: `internal/ratelimit/` A generic per-key token-bucket rate limiter built on `golang.org/x/time/rate`: - `New(ratePerSec, burst)` creates a limiter with automatic background cleanup of stale entries - `Allow(key)` checks if a request from the given key should be permitted - `Stop()` terminates the background sweep goroutine - Stale entries (unused for 15 minutes) are pruned every 10 minutes ### Login handler integration The login handler (`internal/handlers/auth.go`) now: 1. Extracts the client IP from `X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr` 2. Checks the per-IP rate limiter before processing the login 3. Returns **429 Too Many Requests** with a `Retry-After: 1` header when the limit is exceeded ### Configuration Two new environment variables (via Viper): | Variable | Default | Description | |---|---|---| | `LOGIN_RATE_LIMIT` | `1` | Allowed login attempts per second per IP | | `LOGIN_RATE_BURST` | `5` | Maximum burst of login attempts per IP | ### Scope Per [sneak's instruction](#35), only the login endpoint is rate-limited. Session creation and registration use hashcash proof-of-work instead. ## Tests - 6 unit tests for the `ratelimit` package (constructor, burst, burst exceeded, key isolation, key tracking, stop) - 2 integration tests in `api_test.go`: - `TestLoginRateLimitExceeded`: exhausts burst with rapid requests, verifies 429 response and `Retry-After` header - `TestLoginRateLimitAllowsNormalUse`: verifies normal login still works ## README - Added "Login Rate Limiting" subsection under "Rate Limiting & Abuse Prevention" - Added `LOGIN_RATE_LIMIT` and `LOGIN_RATE_BURST` to the Configuration table Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Reviewed-on: #78 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #78.
This commit is contained in:
@@ -2388,6 +2388,109 @@ 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)
|
||||
|
||||
// Create session and set password via PASS command.
|
||||
token := tserver.createSession("normaluser")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "PASS",
|
||||
bodyKey: []string{"password123"},
|
||||
})
|
||||
|
||||
// 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")
|
||||
|
||||
Reference in New Issue
Block a user