aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md5
-rw-r--r--internal/i18n/el.go1
-rw-r--r--internal/web/handlers_auth.go5
-rw-r--r--internal/web/ratelimit.go87
-rw-r--r--internal/web/server.go7
-rw-r--r--internal/web/web_test.go42
6 files changed, 146 insertions, 1 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 6d7b355..577ab82 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -132,6 +132,11 @@ internal/web/ server.go (host router, middleware, render helpers)
limits for the Files page's multi-upload (`maxUploadFiles` = 10). Errors
from `guardPOST` go through `s.fail`, which answers JSON when the request
has `Accept: application/json` (the upload scripts).
+- **Hardening** (`web/ratelimit.go`): a per-key token bucket throttles the
+ anonymous endpoints worth abusing — `loginLimit` on `POST /webadmin`, keyed
+ by client address *and* by lowercased username (10 at once, then 10 a
+ minute each), answered with 429 by `s.throttle` and a log line. Flood
+ control for everything else stays at the reverse proxy (`limit_req`).
- **Templates**: each page file is parsed together with its layout
(`layouts/dashboard.html` or `layouts/blog.html` for `blog/*`) and all
`partials/*.html`. Page files define `content` (and optionally `title`).
diff --git a/internal/i18n/el.go b/internal/i18n/el.go
index 6a97c83..e2523cb 100644
--- a/internal/i18n/el.go
+++ b/internal/i18n/el.go
@@ -269,6 +269,7 @@ var el = map[string]string{
"New passwords do not match.": "Οι νέοι κωδικοί δεν ταιριάζουν.",
"Password changed.": "Ο κωδικός άλλαξε.",
"Wrong username or password.": "Λάθος όνομα χρήστη ή κωδικός.",
+ "Too many requests. Try again in a minute.": "Πάρα πολλές προσπάθειες. Δοκιμάστε ξανά σε ένα λεπτό.",
// ---- layout ----
"Header": "Κεφαλίδα",
diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go
index 1321b2d..1545a4e 100644
--- a/internal/web/handlers_auth.go
+++ b/internal/web/handlers_auth.go
@@ -52,6 +52,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
s.renderStatus(w, r, http.StatusUnauthorized, "auth/login.html",
map[string]any{"error": s.tr(r, "Wrong username or password."), "username": username, "next": next})
}
+ // Both buckets must have a token: one address guessing many accounts and
+ // many addresses guessing one account are throttled alike.
+ if !s.throttle(w, r, s.loginLimit, "ip:"+clientIP(r)) || !s.throttle(w, r, s.loginLimit, "user:"+strings.ToLower(username)) {
+ return
+ }
u, err := s.st.UserByUsername(r.Context(), username)
if err != nil {
if !errors.Is(err, store.ErrNotFound) {
diff --git a/internal/web/ratelimit.go b/internal/web/ratelimit.go
new file mode 100644
index 0000000..b019a4f
--- /dev/null
+++ b/internal/web/ratelimit.go
@@ -0,0 +1,87 @@
+package web
+
+import (
+ "net"
+ "net/http"
+ "sync"
+ "time"
+)
+
+// limiter is a token bucket per key (a client address, a username) for the
+// few anonymous endpoints worth abusing: login guesses and the search's
+// regex scan. Everything else is left to the reverse proxy's limit_req. It
+// lives in memory — one process, no shared state needed — and forgets a key
+// once its bucket has been full for a while.
+type limiter struct {
+ mu sync.Mutex
+ rate float64 // tokens per second
+ burst float64
+ buckets map[string]*bucket
+ lastSweep time.Time
+ now func() time.Time // tests replace it
+}
+
+type bucket struct {
+ tokens float64
+ at time.Time
+}
+
+const limiterSweep = 10 * time.Minute
+
+func newLimiter(perMinute, burst int) *limiter {
+ return &limiter{rate: float64(perMinute) / 60, burst: float64(burst), buckets: map[string]*bucket{}, now: time.Now}
+}
+
+// allow takes one token for key and says whether there was one.
+func (l *limiter) allow(key string) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ now := l.now()
+ if l.lastSweep.IsZero() {
+ l.lastSweep = now
+ } else if now.Sub(l.lastSweep) > limiterSweep {
+ l.lastSweep = now
+ for k, b := range l.buckets {
+ if l.fill(b, now) >= l.burst {
+ delete(l.buckets, k)
+ }
+ }
+ }
+ b, ok := l.buckets[key]
+ if !ok {
+ b = &bucket{tokens: l.burst, at: now}
+ l.buckets[key] = b
+ }
+ if l.fill(b, now) < 1 {
+ return false
+ }
+ b.tokens--
+ return true
+}
+
+// fill credits the time since the last visit and returns the balance.
+func (l *limiter) fill(b *bucket, now time.Time) float64 {
+ b.tokens = min(l.burst, b.tokens+now.Sub(b.at).Seconds()*l.rate)
+ b.at = now
+ return b.tokens
+}
+
+// clientIP is the address requests are throttled by: the peer's.
+func clientIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
+
+// throttle answers 429 and logs when key has no tokens left; false means the
+// request was refused.
+func (s *Server) throttle(w http.ResponseWriter, r *http.Request, l *limiter, key string) bool {
+ if l.allow(key) {
+ return true
+ }
+ logf("throttled %s %s from %s", r.Method, r.URL.Path, clientIP(r))
+ s.fail(w, r, http.StatusTooManyRequests, s.tr(r, "Too many requests. Try again in a minute."))
+ return false
+}
diff --git a/internal/web/server.go b/internal/web/server.go
index f005b76..12440de 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -25,10 +25,15 @@ type Server struct {
tpl *templates
root http.Handler
blog http.Handler
+ // Anonymous endpoints worth abusing get a token bucket each (ratelimit.go).
+ loginLimit *limiter // per client address and per username: 10 guesses, then 10 a minute
}
+// logf is log.Printf, a variable so tests can silence it.
+var logf = log.Printf
+
func NewServer(cfg *config.Config, st *store.Store) *Server {
- s := &Server{cfg: cfg, st: st, tpl: newTemplates(cfg.Dev)}
+ s := &Server{cfg: cfg, st: st, tpl: newTemplates(cfg.Dev), loginLimit: newLimiter(10, 10)}
s.root = s.rootRoutes()
s.blog = s.subdomainRoutes()
return s
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index 1d4db4c..6324443 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -920,3 +920,45 @@ func TestSingleRangeOnly(t *testing.T) {
}
}
}
+
+func TestLimiter(t *testing.T) {
+ now := time.Unix(0, 0)
+ l := newLimiter(60, 3) // one a second, three at once
+ l.now = func() time.Time { return now }
+ for i := 0; i < 3; i++ {
+ if !l.allow("a") {
+ t.Fatalf("burst request %d refused", i)
+ }
+ }
+ if l.allow("a") {
+ t.Error("fourth request allowed")
+ }
+ if !l.allow("b") {
+ t.Error("another key shares the bucket")
+ }
+ now = now.Add(time.Second)
+ if !l.allow("a") || l.allow("a") {
+ t.Error("refill is not one token per second")
+ }
+ now = now.Add(time.Hour) // idle buckets are forgotten at the sweep
+ l.allow("c")
+ if _, ok := l.buckets["a"]; ok {
+ t.Error("full bucket kept after the sweep")
+ }
+}
+
+// A guessed password is refused with 429 once the address or the account has used its burst.
+func TestLoginThrottled(t *testing.T) {
+ s := NewServer(&config.Config{BaseDomain: "example.com", JWTSecret: []byte("x")}, nil)
+ for i := 0; i < 10; i++ {
+ s.loginLimit.allow("user:admin")
+ }
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("POST", "/webadmin", strings.NewReader("username=Admin&password=x"))
+ req.Host = "example.com"
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ s.ServeHTTP(rec, req)
+ if rec.Code != http.StatusTooManyRequests {
+ t.Errorf("got %d, want 429", rec.Code)
+ }
+}