diff --git a/.env.example b/.env.example index d683b82..153952a 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,14 @@ RRDTOOL=rrdtool RRD_PATH=/var/lib/ts3monolith/linuxiarz.rrd GRAPH_DIR=/var/lib/ts3monolith/graphs STATS_INTERVAL=60s + +# Per-IP rate limiting, off by default (see README "Rate limiting"). When +# RATE_LIMIT_ENABLED=false, none of the RATE_LIMIT_* values below do anything. +RATE_LIMIT_ENABLED=false +RATE_LIMIT_STATS=60 +RATE_LIMIT_STATS_LIVE=60 +RATE_LIMIT_GRAPH=600 +RATE_LIMIT_CHANNEL=40 +RATE_LIMIT_VERIFY=10 +RATE_LIMIT_VERIFY_DONE=10 +RATE_LIMIT_CREATE=5 diff --git a/README.md b/README.md index 73e5aff..c20dae1 100644 --- a/README.md +++ b/README.md @@ -127,3 +127,32 @@ Jeżeli docelowy serwer ma inną architekturę/endianness niż źródłowy x86_6 - build z `-trimpath` i usuniętymi symbolami/debug info. Nie da się zagwarantować, że stos technologiczny będzie całkowicie nierozpoznawalny na podstawie zachowania sieci/TLS. Celem jest brak prostych bannerów, wersji i sygnatur frameworka. + +## Rate limiting + +Domyślnie **wyłączony** (`RATE_LIMIT_ENABLED=false`) — endpointy mają limity +zahardkodowane w kodzie od początku, ale same w sobie nie chronią niczego +krytycznego (ServerQuery i tak ma własny timeout, `channelcreate` i tak +wymaga poprawnego tokenu CSRF), a przy nietypowym ruchu (kilku odwiedzających +za tym samym IP/NAT, kilka kart, lightbox z wykresami) dawały false-positive +`429` bez realnej korzyści. Włącz (`RATE_LIMIT_ENABLED=true`), jeśli chcesz +dodatkową warstwę ochrony przed nadużyciami na konkretnym endpoincie. + +Każdy limit jest per-IP (po rozwiązaniu `TRUSTED_PROXIES`/`X-Forwarded-For`, +patrz `clientIP()` w `internal/web/server.go`) i liczony w oknie czasowym +podanym w nawiasie: + +| Zmienna | Endpoint | Domyślnie | Co chroni | +|---|---|---|---| +| `RATE_LIMIT_STATS` | `GET /stats` (na minutę) | 60 | render strony statystyk | +| `RATE_LIMIT_STATS_LIVE` | `GET /stats/live` (na minutę) | 60 | auto-odświeżanie licznika online co 60 s | +| `RATE_LIMIT_GRAPH` | `GET /graph/*.png` (na minutę) | 600 | wykresy RRD — PNG są cache'owane na dysku ~55 s, więc limit jest wysoki: jeden widok strony sam zużywa 6 zakresów × 2 motywy, a NAT/kilka kart to łatwo pomnoży | +| `RATE_LIMIT_CHANNEL` | `POST /channel/create` (na minutę) | 40 | tworzenie kanału | +| `RATE_LIMIT_VERIFY` | `POST /verify/start` (na minutę) | 10 | start weryfikacji nicku (NAT) | +| `RATE_LIMIT_VERIFY_DONE` | `POST /verify/complete` (na minutę) | 10 | wpisanie 6-cyfrowego kodu | +| `RATE_LIMIT_CREATE` | `POST /channel/create` po weryfikacji (na godzinę) | 5 | utworzenie kanału po weryfikacji nicku przez NAT | + +Przy przekroczeniu limitu endpoint odpowiada `429 Too Many Requests` z +nagłówkiem `Retry-After: 60`. Wartości domyślne są w `.env.example` i +`packaging/ts3monolith.default` — dostosuj je do realnego ruchu na danej +instalacji zanim włączysz `RATE_LIMIT_ENABLED` na produkcji. diff --git a/VERSION b/VERSION index 449d7e7..6678432 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.6 +0.3.8 diff --git a/internal/config/config.go b/internal/config/config.go index 71d490e..049616d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,18 @@ type Config struct { RRDPath string GraphDir string StatsInterval time.Duration + + // Per-IP rate limiting. Off by default -- see README "Rate limiting" + // for what each endpoint's limit protects against and reasonable + // values if you turn it on. + RateLimitEnabled bool + RateLimitStats int + RateLimitStatsLive int + RateLimitGraph int + RateLimitChannel int + RateLimitVerify int + RateLimitVerifyDone int + RateLimitCreate int } func Load() (Config, error) { @@ -86,6 +98,36 @@ func Load() (Config, error) { if c.StatsInterval < 10*time.Second { return c, errors.New("STATS_INTERVAL must be at least 10s") } + + c.RateLimitEnabled = envBool("RATE_LIMIT_ENABLED", false) + c.RateLimitStats, err = envInt("RATE_LIMIT_STATS", 60) + if err != nil { + return c, err + } + c.RateLimitStatsLive, err = envInt("RATE_LIMIT_STATS_LIVE", 60) + if err != nil { + return c, err + } + c.RateLimitGraph, err = envInt("RATE_LIMIT_GRAPH", 600) + if err != nil { + return c, err + } + c.RateLimitChannel, err = envInt("RATE_LIMIT_CHANNEL", 40) + if err != nil { + return c, err + } + c.RateLimitVerify, err = envInt("RATE_LIMIT_VERIFY", 10) + if err != nil { + return c, err + } + c.RateLimitVerifyDone, err = envInt("RATE_LIMIT_VERIFY_DONE", 10) + if err != nil { + return c, err + } + c.RateLimitCreate, err = envInt("RATE_LIMIT_CREATE", 5) + if err != nil { + return c, err + } return c, nil } diff --git a/internal/web/server.go b/internal/web/server.go index cc5575a..6cb65c5 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -144,7 +144,7 @@ func (s *Server) security(next http.Handler) http.Handler { func (s *Server) stats(w http.ResponseWriter, r *http.Request) { ip := s.clientIP(r).String() - if !s.limit.allow("stats|"+ip, 60, time.Minute) { + if s.cfg.RateLimitEnabled && !s.limit.allow("stats|"+ip, s.cfg.RateLimitStats, time.Minute) { s.tooMany(w) return } @@ -173,7 +173,7 @@ func (s *Server) stats(w http.ResponseWriter, r *http.Request) { func (s *Server) statsLive(w http.ResponseWriter, r *http.Request) { ip := s.clientIP(r).String() - if !s.limit.allow("statslive|"+ip, 60, time.Minute) { + if s.cfg.RateLimitEnabled && !s.limit.allow("statslive|"+ip, s.cfg.RateLimitStatsLive, time.Minute) { s.tooMany(w) return } @@ -252,7 +252,14 @@ func (s *Server) asset(w http.ResponseWriter, r *http.Request) { func (s *Server) graph(w http.ResponseWriter, r *http.Request) { ip := s.clientIP(r).String() - if !s.limit.allow("graph|"+ip, 120, time.Minute) { + // Graph PNGs are cheap to serve: rrd.Manager.Graph caches each + // key+theme render on disk for ~55s, so most hits here are a plain + // file read, not an rrdtool invocation. One page view alone can + // legitimately touch this 6 ranges x 2 themes (lightbox open, theme + // toggle) times, before counting multiple tabs or several visitors + // sharing one IP (same household/NAT) -- see README "Rate limiting" + // for tuning RATE_LIMIT_GRAPH if this still trips in practice. + if s.cfg.RateLimitEnabled && !s.limit.allow("graph|"+ip, s.cfg.RateLimitGraph, time.Minute) { s.tooMany(w) return } @@ -288,7 +295,7 @@ func (s *Server) channel(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid client address", http.StatusBadRequest) return } - if !s.limit.allow("channel|"+ip.String(), 40, time.Minute) { + if s.cfg.RateLimitEnabled && !s.limit.allow("channel|"+ip.String(), s.cfg.RateLimitChannel, time.Minute) { s.tooMany(w) return } @@ -316,7 +323,7 @@ func (s *Server) verifyStart(w http.ResponseWriter, r *http.Request) { if !s.requirePost(w, r, ip.String()) { return } - if !s.limit.allow("verify|"+ip.String(), 10, time.Minute) { + if s.cfg.RateLimitEnabled && !s.limit.allow("verify|"+ip.String(), s.cfg.RateLimitVerify, time.Minute) { s.tooMany(w) return } @@ -362,7 +369,7 @@ func (s *Server) verifyComplete(w http.ResponseWriter, r *http.Request) { if !s.requirePost(w, r, ip.String()) { return } - if !s.limit.allow("verifydone|"+ip.String(), 10, time.Minute) { + if s.cfg.RateLimitEnabled && !s.limit.allow("verifydone|"+ip.String(), s.cfg.RateLimitVerifyDone, time.Minute) { s.tooMany(w) return } @@ -391,7 +398,7 @@ func (s *Server) createChannel(w http.ResponseWriter, r *http.Request) { if !s.requirePost(w, r, ip.String()) { return } - if !s.limit.allow("create|"+ip.String(), 5, time.Hour) { + if s.cfg.RateLimitEnabled && !s.limit.allow("create|"+ip.String(), s.cfg.RateLimitCreate, time.Hour) { s.tooMany(w) return } diff --git a/packaging/ts3monolith.default b/packaging/ts3monolith.default index af4fd88..eac49cf 100644 --- a/packaging/ts3monolith.default +++ b/packaging/ts3monolith.default @@ -31,3 +31,16 @@ STATS_INTERVAL=60s # co faktycznie trafilo do RRD). Przydatne tylko przy diagnozowaniu -- zalewa # journald jednym wpisem na STATS_INTERVAL, zostaw wylaczone na co dzien. DEBUG=false + +# === Rate limiting ============================================================ +# Domyslnie wylaczone. Przy wlaczeniu limity sa per-IP (patrz README, +# sekcja "Rate limiting") -- wartosci nizej to sensowne domyslne, dostosuj +# do realnego ruchu zanim wlaczysz na produkcji. +RATE_LIMIT_ENABLED=false +RATE_LIMIT_STATS=60 +RATE_LIMIT_STATS_LIVE=60 +RATE_LIMIT_GRAPH=600 +RATE_LIMIT_CHANNEL=40 +RATE_LIMIT_VERIFY=10 +RATE_LIMIT_VERIFY_DONE=10 +RATE_LIMIT_CREATE=5