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:
parent
93673510d8
commit
2a9eec233a
6 changed files with 101 additions and 0 deletions
10
addy.go
10
addy.go
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
12
login.go
12
login.go
|
|
@ -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"`)
|
||||
|
|
|
|||
5
lost.go
5
lost.go
|
|
@ -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
63
ratelimit.go
Normal 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
|
||||
}
|
||||
5
reset.go
5
reset.go
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue