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:
45
README.md
45
README.md
@@ -2208,6 +2208,8 @@ directory is also loaded automatically via
|
||||
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
||||
| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. |
|
||||
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
|
||||
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
|
||||
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
|
||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||
|
||||
### Example `.env` file
|
||||
@@ -2601,6 +2603,49 @@ creating one session pays once and keeps their session.
|
||||
- **Language-agnostic**: SHA-256 is available in every programming language.
|
||||
The proof computation is trivially implementable in any client.
|
||||
|
||||
### Login Rate Limiting
|
||||
|
||||
The login endpoint (`POST /api/v1/login`) has per-IP rate limiting to prevent
|
||||
brute-force password attacks. This uses a token-bucket algorithm
|
||||
(`golang.org/x/time/rate`) with configurable rate and burst.
|
||||
|
||||
| Environment 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 |
|
||||
|
||||
When the limit is exceeded, the server returns **429 Too Many Requests** with a
|
||||
`Retry-After: 1` header. Stale per-IP entries are automatically cleaned up
|
||||
every 10 minutes.
|
||||
|
||||
> **⚠️ Security: Reverse Proxy Required for Production Use**
|
||||
>
|
||||
> The rate limiter extracts the client IP by checking the `X-Forwarded-For`
|
||||
> header first, then `X-Real-IP`, and finally falling back to the TCP
|
||||
> `RemoteAddr`. Both `X-Forwarded-For` and `X-Real-IP` are **client-controlled
|
||||
> request headers** — any client can set them to arbitrary values.
|
||||
>
|
||||
> Without a properly configured reverse proxy in front of this server:
|
||||
>
|
||||
> - An attacker can **bypass rate limiting entirely** by rotating
|
||||
> `X-Forwarded-For` values on each request (each value is treated as a
|
||||
> distinct IP).
|
||||
> - An attacker can **deny service to a specific user** by spoofing that user's
|
||||
> IP in the `X-Forwarded-For` header, exhausting their rate limit bucket.
|
||||
>
|
||||
> **Recommendation:** Always deploy behind a reverse proxy (e.g. nginx, Caddy,
|
||||
> Traefik) that strips or overwrites incoming `X-Forwarded-For` and `X-Real-IP`
|
||||
> headers with the actual client IP. If running without a reverse proxy, be
|
||||
> aware that the rate limiting provides no meaningful protection against a
|
||||
> targeted attack.
|
||||
|
||||
**Why rate limits here but not on session creation?** Session creation is
|
||||
protected by hashcash proof-of-work (stateless, no IP tracking needed). Login
|
||||
involves bcrypt password verification against a registered account — a
|
||||
fundamentally different threat model where an attacker targets a specific
|
||||
account. Per-IP rate limiting is appropriate here because the cost of a wrong
|
||||
guess is borne by the server (bcrypt), not the client.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
Reference in New Issue
Block a user