fix(security): add per-IP rate limiting to all authentication endpoints

Implement sliding window rate limiter to prevent brute-force attacks:
- /auth and /login: 20 requests/minute per IP
- /change: 10 POST requests/minute per IP
- /lost: 5 POST requests/minute per IP (prevents email spam and user enumeration)
- /reset: 10 POST requests/minute per IP
- /api/v1/aliases: 30 requests/minute per IP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-03-06 14:46:13 +07:00
commit 2a9eec233a
6 changed files with 101 additions and 0 deletions

10
addy.go
View file

@ -89,6 +89,11 @@ func addyAliasAPIAuth(r *http.Request) (*string, error) {
}
func addyAliasAPI(w http.ResponseWriter, r *http.Request) {
if !aliasLimiter.Allow(remoteIP(r)) {
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
}
user, err := addyAliasAPIAuth(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
@ -162,6 +167,11 @@ func addyAliasAPI(w http.ResponseWriter, r *http.Request) {
}
func addyAliasAPIDelete(w http.ResponseWriter, r *http.Request) {
if !aliasLimiter.Allow(remoteIP(r)) {
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
}
user, err := addyAliasAPIAuth(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)

View file

@ -15,6 +15,12 @@ func checkPasswdConstraint(password string) error {
}
func changePassword(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && !changeLimiter.Allow(remoteIP(r)) {
csrfToken, _ := setCSRFToken(w)
displayTmplError(w, http.StatusTooManyRequests, "change.html", map[string]interface{}{"error": "Too many requests. Please try again later.", "csrf_token": csrfToken})
return
}
if r.Method != "POST" {
csrfToken, err := setCSRFToken(w)
if err != nil {

View file

@ -48,6 +48,11 @@ func tryLogin(w http.ResponseWriter, r *http.Request) {
return
}
if !authLimiter.Allow(remoteIP(r)) {
displayTmplError(w, http.StatusTooManyRequests, "login.html", map[string]interface{}{"error": "Too many login attempts. Please try again later."})
return
}
if entries, err := login(r.PostFormValue("login"), r.PostFormValue("password")); err != nil {
log.Println(err)
displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]interface{}{"error": err.Error()})
@ -78,6 +83,13 @@ func tryLogin(w http.ResponseWriter, r *http.Request) {
}
func httpBasicAuth(w http.ResponseWriter, r *http.Request) {
if !authLimiter.Allow(remoteIP(r)) {
w.Header().Set("WWW-Authenticate", `Basic realm="nemunai.re restricted"`)
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("Too many requests"))
return
}
if user, pass, ok := r.BasicAuth(); ok {
if entries, err := login(user, pass); err != nil {
w.Header().Set("WWW-Authenticate", `Basic realm="nemunai.re restricted"`)

View file

@ -87,6 +87,11 @@ func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) {
}
func lostPassword(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && !lostLimiter.Allow(remoteIP(r)) {
displayTmplError(w, http.StatusTooManyRequests, "lost.html", map[string]interface{}{"error": "Too many requests. Please try again later."})
return
}
if r.Method != "POST" {
csrfToken, err := setCSRFToken(w)
if err != nil {

63
ratelimit.go Normal file
View file

@ -0,0 +1,63 @@
package main
import (
"net"
"net/http"
"sync"
"time"
)
type rateLimiter struct {
mu sync.Mutex
counts map[string][]time.Time
limit int
window time.Duration
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
return &rateLimiter{
counts: make(map[string][]time.Time),
limit: limit,
window: window,
}
}
func (rl *rateLimiter) Allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
windowStart := now.Add(-rl.window)
timestamps := rl.counts[key]
filtered := timestamps[:0]
for _, t := range timestamps {
if t.After(windowStart) {
filtered = append(filtered, t)
}
}
if len(filtered) >= rl.limit {
rl.counts[key] = filtered
return false
}
rl.counts[key] = append(filtered, now)
return true
}
var (
authLimiter = newRateLimiter(20, time.Minute)
changeLimiter = newRateLimiter(10, time.Minute)
lostLimiter = newRateLimiter(5, time.Minute)
resetLimiter = newRateLimiter(10, time.Minute)
aliasLimiter = newRateLimiter(30, time.Minute)
)
func remoteIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}

View file

@ -6,6 +6,11 @@ import (
)
func resetPassword(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && !resetLimiter.Allow(remoteIP(r)) {
http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests)
return
}
if len(r.URL.Query().Get("l")) == 0 || len(r.URL.Query().Get("t")) == 0 {
http.Redirect(w, r, "lost", http.StatusFound)
return