97 lines
2.3 KiB
Go
97 lines
2.3 KiB
Go
// Keywarden - Centralized SSH Key Management and Deployment
|
||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||
|
||
package security
|
||
|
||
import (
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// rateLimiter implements a fixed-window IP-based rate limiter.
|
||
type rateLimiter struct {
|
||
mu sync.Mutex
|
||
clients map[string]*window
|
||
limit int
|
||
period time.Duration
|
||
}
|
||
|
||
type window struct {
|
||
count int
|
||
resetAt time.Time
|
||
}
|
||
|
||
func newRateLimiter(limit int, period time.Duration) *rateLimiter {
|
||
rl := &rateLimiter{
|
||
clients: make(map[string]*window),
|
||
limit: limit,
|
||
period: period,
|
||
}
|
||
// Background goroutine to evict expired entries (prevent memory leak)
|
||
go func() {
|
||
ticker := time.NewTicker(5 * time.Minute)
|
||
defer ticker.Stop()
|
||
for range ticker.C {
|
||
now := time.Now()
|
||
rl.mu.Lock()
|
||
for ip, w := range rl.clients {
|
||
if now.After(w.resetAt) {
|
||
delete(rl.clients, ip)
|
||
}
|
||
}
|
||
rl.mu.Unlock()
|
||
}
|
||
}()
|
||
return rl
|
||
}
|
||
|
||
func (rl *rateLimiter) allow(ip string) bool {
|
||
rl.mu.Lock()
|
||
defer rl.mu.Unlock()
|
||
|
||
now := time.Now()
|
||
w, ok := rl.clients[ip]
|
||
if !ok || now.After(w.resetAt) {
|
||
rl.clients[ip] = &window{count: 1, resetAt: now.Add(rl.period)}
|
||
return true
|
||
}
|
||
w.count++
|
||
return w.count <= rl.limit
|
||
}
|
||
|
||
// RateLimitMiddleware returns middleware that rate-limits requests to the
|
||
// login endpoint by client IP address.
|
||
//
|
||
// loginLimit is the maximum number of login attempts per IP per minute.
|
||
// A value of 0 disables rate limiting entirely.
|
||
func RateLimitMiddleware(loginLimit int) func(http.Handler) http.Handler {
|
||
if loginLimit <= 0 {
|
||
// Disabled – pass through
|
||
return func(next http.Handler) http.Handler { return next }
|
||
}
|
||
|
||
loginRL := newRateLimiter(loginLimit, 1*time.Minute)
|
||
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
// Only rate-limit POST to login endpoints
|
||
if r.Method == http.MethodPost && isLoginPath(r.URL.Path) {
|
||
ip := ClientIP(r)
|
||
if !loginRL.allow(ip) {
|
||
http.Error(w, "Too Many Requests – please try again later", http.StatusTooManyRequests)
|
||
return
|
||
}
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
}
|
||
|
||
// isLoginPath returns true for login-related POST paths.
|
||
func isLoginPath(path string) bool {
|
||
return path == "/login" || strings.HasPrefix(path, "/login/")
|
||
}
|