Serve behind Traefik and rate limit password guesses

The deployment is a public hostname behind Traefik rather than a LAN-only
box, which changes two things.

TLS is now terminated by the proxy, so Basic credentials are no longer in
cleartext. The container publishes no ports: doing so would leave an
unencrypted copy of the app on the host, bypassing the proxy. The hostname
lives in .env rather than compose.yaml, so no infrastructure detail is
committed and the MIT publication option stays open.

The password is now the only thing between the internet and the app, and a
500 ms sleep is not a defence at that exposure. Wrong guesses are rate
limited per client address: five in a burst, then one per ten seconds,
answered with 429.

Two details that decide whether this works at all:

- a request with no Authorization header is not charged. That is the
  handshake every browser session opens with, and counting it would lock the
  household out for simply opening the app a few times.
- X-Forwarded-For is believed only when the connection arrived from a private
  address, i.e. through the proxy, and then only its last entry, which is the
  one the proxy observed. A direct client could otherwise forge a fresh
  address per attempt and walk past the limiter entirely.

None of this substitutes for a strong password. It removes brute force as a
practical route, nothing more. PRD §3, §9 and §10 are updated: "no external
internet exposure" is no longer true.
This commit is contained in:
Esa Kataja
2026-09-05 20:12:28 +03:00
parent b624712b88
commit eb95bd0a03
9 changed files with 311 additions and 28 deletions
+23 -9
View File
@@ -42,11 +42,6 @@ const (
// companions — lives in one directory, so a deployment mounts a single
// path and a backup copies a single directory.
defaultDB = "./data/foodster.db"
// failDelay throttles password guessing.
// ponytail: a fixed sleep is enough for a LAN-only app; swap in
// golang.org/x/time/rate keyed by IP if this is ever exposed.
failDelay = 500 * time.Millisecond
)
func main() {
@@ -173,21 +168,40 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
// accounts, so the username is ignored (PRD §9).
func auth(password string, next http.Handler) http.Handler {
want := sha256.Sum256([]byte(password))
guesses := newThrottle()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, given, ok := r.BasicAuth()
// A request with no Authorization header is the normal browser
// handshake, not a guess: every session opens with one. Challenge it
// without spending the address's allowance.
if !ok {
challenge(w)
return
}
// Hashing first keeps the comparison a fixed length, so neither the
// password nor its length leaks through timing.
got := sha256.Sum256([]byte(given))
if !ok || subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
time.Sleep(failDelay)
w.Header().Set("WWW-Authenticate", `Basic realm="Foodster", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
if !guesses.allow(clientIP(r)) {
http.Error(w, "Liikaa yrityksiä.", http.StatusTooManyRequests)
return
}
challenge(w)
return
}
next.ServeHTTP(w, r)
})
}
func challenge(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="Foodster", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
// today is the current calendar day in the configured location. Every date in
// this app goes through here rather than time.Local, which would be UTC
// whenever TZ is unset and quietly shift evening entries to the day before.
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"net"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/time/rate"
)
// The app is reachable from the internet, so a shared password needs more
// than a sleep in front of it. These allow a family fumbling the password a
// handful of quick retries, then roughly six a minute — useless for guessing,
// unnoticeable to anyone who knows it.
//
// This buys time; it is not the defence. A strong password is.
const (
guessBurst = 5
guessInterval = 10 * time.Second
// Bounds on the per-IP table, so a spray across many addresses cannot
// grow it without limit.
throttleMaxEntries = 4096
throttleIdle = 15 * time.Minute
)
type visitor struct {
limiter *rate.Limiter
seen time.Time
}
// throttle rate-limits failed password attempts per client address.
//
// ponytail: one mutex over one map. At household traffic this will never be
// contended; shard it if that ever stops being true.
type throttle struct {
mu sync.Mutex
visitors map[string]*visitor
}
func newThrottle() *throttle {
return &throttle{visitors: make(map[string]*visitor)}
}
// allow reports whether another wrong guess from this address is permitted.
func (t *throttle) allow(ip string) bool {
now := time.Now()
t.mu.Lock()
defer t.mu.Unlock()
if len(t.visitors) >= throttleMaxEntries {
t.pruneLocked(now)
}
v := t.visitors[ip]
if v == nil {
v = &visitor{limiter: rate.NewLimiter(rate.Every(guessInterval), guessBurst)}
t.visitors[ip] = v
}
v.seen = now
return v.limiter.Allow()
}
func (t *throttle) pruneLocked(now time.Time) {
for ip, v := range t.visitors {
if now.Sub(v.seen) > throttleIdle {
delete(t.visitors, ip)
}
}
// Still full of live entries: a spray is in progress. Drop the lot rather
// than grow without bound. Everyone gets a fresh allowance, which is the
// safe direction to fail — the password is still required.
if len(t.visitors) >= throttleMaxEntries {
clear(t.visitors)
}
}
// clientIP resolves the address to rate-limit against.
//
// X-Forwarded-For is only believed when the connection itself came from a
// private address, meaning it arrived through the reverse proxy on the
// container network. A client connecting directly could otherwise forge a
// fresh address on every attempt and walk straight past the limiter.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
if ip == nil || !(ip.IsPrivate() || ip.IsLoopback()) {
return host
}
forwarded := r.Header.Get("X-Forwarded-For")
if forwarded == "" {
return host
}
// The nearest proxy appends the address it saw, so the last entry is the
// trustworthy one; anything before it was supplied by the client.
parts := strings.Split(forwarded, ",")
last := strings.TrimSpace(parts[len(parts)-1])
if net.ParseIP(last) == nil {
return host
}
return last
}
+110
View File
@@ -0,0 +1,110 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestThrottleBlocksRepeatedGuesses(t *testing.T) {
th := newThrottle()
for i := 0; i < guessBurst; i++ {
if !th.allow("198.51.100.7") {
t.Fatalf("guess %d refused inside the burst", i+1)
}
}
if th.allow("198.51.100.7") {
t.Error("guess allowed past the burst")
}
// A different address has its own allowance.
if !th.allow("198.51.100.8") {
t.Error("a second address was blocked by the first one's guesses")
}
}
func TestClientIPIgnoresForwardedHeaderFromDirectClients(t *testing.T) {
// Connecting straight from the internet: X-Forwarded-For is attacker
// input, so a forged value must not create a fresh rate-limit bucket.
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "203.0.113.9:44321"
r.Header.Set("X-Forwarded-For", "1.2.3.4")
if got := clientIP(r); got != "203.0.113.9" {
t.Errorf("clientIP = %q, want the real peer 203.0.113.9", got)
}
}
func TestClientIPTakesLastForwardedEntryBehindProxy(t *testing.T) {
// Arriving through Traefik on the container network. The proxy appends
// the address it saw, so the last entry is the trustworthy one and the
// forged entry in front of it must be ignored.
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "172.18.0.4:53000"
r.Header.Set("X-Forwarded-For", "1.2.3.4, 198.51.100.22")
if got := clientIP(r); got != "198.51.100.22" {
t.Errorf("clientIP = %q, want 198.51.100.22", got)
}
}
func TestClientIPFallsBackWhenNoForwardedHeader(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "172.18.0.4:53000"
if got := clientIP(r); got != "172.18.0.4" {
t.Errorf("clientIP = %q, want 172.18.0.4", got)
}
}
func TestAuthRateLimitsWrongPasswords(t *testing.T) {
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
send := func(pass string) int {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "203.0.113.5:40000"
r.SetBasicAuth("", pass)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w.Code
}
for i := 0; i < guessBurst; i++ {
if code := send("wrong"); code != http.StatusUnauthorized {
t.Fatalf("guess %d returned %d, want 401", i+1, code)
}
}
if code := send("wrong"); code != http.StatusTooManyRequests {
t.Errorf("guess past the burst returned %d, want 429", code)
}
}
func TestAuthDoesNotSpendAllowanceOnTheBrowserHandshake(t *testing.T) {
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
// Every session opens with a credential-less request. Charging those
// would lock a family out by simply opening the app a few times.
for i := 0; i < guessBurst*4; i++ {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "203.0.113.6:40000"
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Fatalf("handshake %d returned %d, want 401", i+1, w.Code)
}
}
// The correct password still works afterwards.
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "203.0.113.6:40000"
r.SetBasicAuth("", "hunter2")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusTeapot {
t.Errorf("correct password returned %d, want the wrapped handler", w.Code)
}
}