diff --git a/.env.example b/.env.example index 45be481..4ac06c0 100644 --- a/.env.example +++ b/.env.example @@ -8,8 +8,9 @@ FOODSTER_TAG=latest # Shared household password. The app will not start without it. FOODSTER_PASSWORD=changeme -# Host port to publish on. -FOODSTER_PORT=8080 +# Hostname Traefik routes to. Kept here rather than in compose.yaml so no +# infrastructure detail is committed. +FOODSTER_HOST=foodster.example.com # The database lives in ./data, bind-mounted into the container. These must # match whoever owns that directory on the host, or the container cannot diff --git a/PRD.md b/PRD.md index 5590b51..b53f1e6 100644 --- a/PRD.md +++ b/PRD.md @@ -29,7 +29,8 @@ polished, it may be released as FOSS under MIT. password gates the whole app (§9). - No nutrition tracking, calorie counting, or dietary-goal optimization. - No mobile-native apps. Web only (mobile-friendly responsive is enough). -- No external internet exposure. Runs on the home LAN. +- No per-user accounts or sessions. The app *is* reachable from the internet + (§9, §10), gated by a single shared password over TLS. ## 4. Delivery stages @@ -335,11 +336,18 @@ build and no asset bundler. - **Auth**: HTTP Basic with one shared household password read from `FOODSTER_PASSWORD`; the username is ignored. Compared using `subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor - its length leaks through timing. A failed attempt sleeps 500 ms, which is - throttle enough for a LAN-only app. Note that Basic credentials travel in - cleartext over plain HTTP — acceptable on a private LAN, and the reason to - add TLS if this is ever reachable from anywhere else. `/healthz` is the - only route outside auth. + its length leaks through timing. `/healthz` is the only route outside auth. +- **Exposure**: the app is served on a public hostname behind Traefik, which + terminates TLS, so Basic credentials are encrypted in transit. A shared + password is therefore the only thing between the internet and the app, and + it is guarded by a per-address rate limiter: five wrong guesses, then one + per ten seconds, answered with `429`. Only requests that actually present + a wrong password spend the allowance — a request with no `Authorization` + header is the normal browser handshake that opens every session. + `X-Forwarded-For` is trusted only when the connection arrived from a + private address, so a direct client cannot forge a new identity per + attempt. None of this substitutes for a strong password; it only removes + brute force as a practical route. - **Containers**: built with Podman in development, run under Docker Compose in production. Images are OCI, so one image works with both engines. @@ -380,6 +388,11 @@ on the server and run with Docker Compose. - `TZ` — default `Europe/Helsinki`. - The registry hostname exists only in `.env`, which is gitignored, because §11 leaves open the possibility of publishing this repository. +- **Routing**: Traefik on an external `traefik` network, matching on + `FOODSTER_HOST` and terminating TLS. The container publishes no ports — + doing so would put 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. - **Health**: `GET /healthz` returns the build version and is exempt from auth. There is no Docker `HEALTHCHECK` directive, because a `scratch` image has no shell to run one and `restart: unless-stopped` already covers a dead diff --git a/README.md b/README.md index d3b8f3c..850b292 100644 --- a/README.md +++ b/README.md @@ -184,9 +184,23 @@ that is. Get them from `id -u` and `id -g`. ## Security Access is a single shared password over HTTP Basic — no accounts, no -sessions. Credentials are compared in constant time, but Basic auth sends -them in cleartext, so this belongs on a private LAN. Put TLS in front of it -before exposing it anywhere else. +sessions. Credentials are compared in constant time over SHA-256 digests, so +neither the password nor its length leaks through timing. + +The app is served on a public hostname behind Traefik, which terminates TLS, +so the credentials are encrypted in transit. That leaves the password as the +only thing between the internet and the app, so wrong guesses are rate +limited per client address: five in a burst, then one per ten seconds, +answered with `429`. Requests carrying no `Authorization` header are not +charged — that is the handshake every browser session begins with, and +counting it would lock the household out for simply opening the app. + +`X-Forwarded-For` is trusted only when the connection came from a private +address, meaning it arrived through the proxy. A client connecting directly +could otherwise forge a new address per attempt and skip the limiter. + +**None of this replaces a strong `FOODSTER_PASSWORD`.** Rate limiting removes +brute force as a practical route; it does not make a guessable password safe. ## Mockups diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go index e38b864..59f47e5 100644 --- a/cmd/foodster/main.go +++ b/cmd/foodster/main.go @@ -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. diff --git a/cmd/foodster/throttle.go b/cmd/foodster/throttle.go new file mode 100644 index 0000000..2425ff3 --- /dev/null +++ b/cmd/foodster/throttle.go @@ -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 +} diff --git a/cmd/foodster/throttle_test.go b/cmd/foodster/throttle_test.go new file mode 100644 index 0000000..d8896a5 --- /dev/null +++ b/cmd/foodster/throttle_test.go @@ -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) + } +} diff --git a/compose.yaml b/compose.yaml index a5604d4..272117e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,16 +2,33 @@ services: app: image: ${FOODSTER_REPO:?set FOODSTER_REPO in .env}:${FOODSTER_TAG:-latest} restart: unless-stopped - # A bind mount rather than a named volume: the database sits in ./data on - # the host, where it can be listed, copied and backed up without going - # through the container engine. The image runs as UID 65534, so the - # container has to be told which host user owns that directory. + + # The database is a bind mount, not a named volume: it sits in ./data on + # the host where it can be listed, copied and opened with any sqlite + # client. The image runs as UID 65534, so the container has to be told + # which host user owns that directory. user: "${FOODSTER_UID:-1000}:${FOODSTER_GID:-1000}" - ports: - - "${FOODSTER_PORT:-8080}:8080" + volumes: + - ./data:/data + environment: FOODSTER_PASSWORD: ${FOODSTER_PASSWORD:?set FOODSTER_PASSWORD in .env} FOODSTER_DB: /data/foodster.db TZ: ${TZ:-Europe/Helsinki} - volumes: - - ./data:/data + + # No published ports: Traefik reaches the container over the shared + # network. Publishing 8080 as well would put an unencrypted copy of the + # app on the host, bypassing TLS. + labels: + - traefik.enable=true + - traefik.http.routers.foodster.entrypoints=websecure + - traefik.http.routers.foodster.rule=Host(`${FOODSTER_HOST:?set FOODSTER_HOST in .env}`) + - traefik.http.routers.foodster.tls=true + - traefik.http.services.foodster.loadbalancer.server.port=8080 + - traefik.docker.network=traefik + networks: + - traefik + +networks: + traefik: + external: true diff --git a/go.mod b/go.mod index 9a59216..afe52eb 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ tool github.com/a-h/templ/cmd/templ require ( github.com/a-h/templ v0.3.1020 + golang.org/x/time v0.15.0 modernc.org/sqlite v1.58.0 ) diff --git a/go.sum b/go.sum index 819b998..3fddf9d 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=