diff --git a/addy.go b/addy.go index d1573d7..3d3ab19 100644 --- a/addy.go +++ b/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) diff --git a/change.go b/change.go index 35d3877..0fd8d07 100644 --- a/change.go +++ b/change.go @@ -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 { diff --git a/login.go b/login.go index a93df7c..14b9fef 100644 --- a/login.go +++ b/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"`) diff --git a/lost.go b/lost.go index 6481d62..250ac42 100644 --- a/lost.go +++ b/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 { diff --git a/ratelimit.go b/ratelimit.go new file mode 100644 index 0000000..28ad50f --- /dev/null +++ b/ratelimit.go @@ -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 +} diff --git a/reset.go b/reset.go index 225d22d..c2172b0 100644 --- a/reset.go +++ b/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