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) {
|
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)
|
user, err := addyAliasAPIAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
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) {
|
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)
|
user, err := addyAliasAPIAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ func checkPasswdConstraint(password string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func changePassword(w http.ResponseWriter, r *http.Request) {
|
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" {
|
if r.Method != "POST" {
|
||||||
csrfToken, err := setCSRFToken(w)
|
csrfToken, err := setCSRFToken(w)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
12
login.go
12
login.go
|
|
@ -48,6 +48,11 @@ func tryLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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 {
|
if entries, err := login(r.PostFormValue("login"), r.PostFormValue("password")); err != nil {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]interface{}{"error": err.Error()})
|
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) {
|
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 user, pass, ok := r.BasicAuth(); ok {
|
||||||
if entries, err := login(user, pass); err != nil {
|
if entries, err := login(user, pass); err != nil {
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="nemunai.re restricted"`)
|
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) {
|
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" {
|
if r.Method != "POST" {
|
||||||
csrfToken, err := setCSRFToken(w)
|
csrfToken, err := setCSRFToken(w)
|
||||||
if err != nil {
|
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) {
|
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 {
|
if len(r.URL.Query().Get("l")) == 0 || len(r.URL.Query().Get("t")) == 0 {
|
||||||
http.Redirect(w, r, "lost", http.StatusFound)
|
http.Redirect(w, r, "lost", http.StatusFound)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue