diff --git a/.env.example b/.env.example index 4c4f00c..8f53f5a 100644 --- a/.env.example +++ b/.env.example @@ -5,13 +5,18 @@ REPO=registry.example.com/you/foodster TAG=latest -# Shared household password. The app will not start without it. -PASSWORD=changeme - # Hostname Traefik routes to. Kept here rather than in compose.yaml so no # infrastructure detail is committed. HOST=foodster.example.com +# The Traefik middleware that authenticates the app. The app itself has no +# login, so this is the whole of its access control — an unset or misspelt +# name takes the router out of service, which is the right way to fail. +AUTH=authelia@docker + +# Traefik certificate resolver issuing the TLS certificate for HOST. +CERTRESOLVER=letsencrypt + # Anything other than prod is written into the browser tab title, so a dev # instance open beside the real one can be told apart. ENV=prod diff --git a/Makefile b/Makefile index a6cda68..91dec4f 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ COMPOSE ?= podman compose BIN := foodster PKG := ./cmd/foodster -# Shared password and TZ live here. Gitignored. +# Registry coordinates, hostname, TZ. Gitignored. ifneq (,$(wildcard .env)) include .env export @@ -28,7 +28,7 @@ build: generate ## Build ./foodster -ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG) run: generate ## Run locally on :8080 (database in ./data) - PASSWORD=$${PASSWORD:-dev} ENV=dev go run $(PKG) + ENV=dev go run $(PKG) test: generate ## Run unit tests go test ./... diff --git a/PRD.md b/PRD.md index 3439bb0..835f619 100644 --- a/PRD.md +++ b/PRD.md @@ -25,12 +25,12 @@ polished, it may be released as FOSS under MIT. - No grocery list generation (possible future add-on). - No per-recipe ingredient tracking — meals are just names. - No calendar/scheduling with times, reminders, or calendar exports. -- No user accounts, per-person profiles, or permissions. A single shared - password gates the whole app (§9). +- No user accounts, per-person profiles, or permissions *in the app*. + Authentication is the reverse proxy's job (§9). - No nutrition tracking, calorie counting, or dietary-goal optimization. - No mobile-native apps. Web only (mobile-friendly responsive is enough). -- No per-user accounts or sessions. The app *is* reachable from the internet - (§9, §10), gated by a single shared password over TLS. +- No per-user accounts or sessions in the app. It *is* reachable from the + internet (§9, §10), behind Authelia at the proxy. ## 4. Delivery stages @@ -72,9 +72,9 @@ weighting to be meaningful (a few weeks of logged meals). ## 5. Users -A single household. One shared instance, no per-person accounts. Anyone on the -home network who knows the shared password can open the app and interact with -it. +A single household. One shared instance, no per-person accounts. Everyone who +gets past Authelia sees and edits the same log; the app draws no distinction +between them. The interface is written in **Finnish** — every user of this instance is a Finnish speaker, so there is no i18n layer and no language switcher. Strings @@ -350,21 +350,21 @@ build and no asset bundler. to UTC would shift logged dinners to the wrong calendar day. `time/tzdata` is imported because the runtime image carries no zoneinfo. All date logic uses that location explicitly and never `time.Local`. -- **Auth**: HTTP Basic with one shared household password read from - `PASSWORD`; the username is ignored. Compared using - `subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor - 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. +- **Auth**: none in the app. Every route is served unauthenticated, because + the only client that can reach the app is Traefik, which forwards each + request to **Authelia** first. Sessions, brute-force protection and + multi-factor are configured there once for every service on the host. + Deliberately not reimplemented per app: the earlier in-app HTTP Basic layer + meant two prompts for one door, and the weaker of the two was the one + holding a shared password. +- **Exposure**: served on a public hostname behind Traefik, which terminates + TLS. Two invariants carry the whole security model, and both are asserted + in `compose.yaml`. The router names the Authelia middleware through `AUTH` + — unset or misspelt, Traefik takes the router out of service, so a typo + fails shut. And the container publishes no ports, so it is reachable only + over the shared proxy network; publishing `8080` would expose an + unauthenticated plaintext copy on the host. `/healthz` returns only the + version and is safe to bypass in Authelia for monitoring. - **Containers**: built with Podman in development, run under Docker Compose in production. Images are OCI, so one image works with both engines. @@ -406,8 +406,10 @@ the server and run with Docker Compose. `.env.example`): Names carry no application prefix: the container namespaces them already. - `REPO` and `TAG` — image coordinates. - - `PASSWORD` — the shared password. Required; the app refuses to start - without it. + - `AUTH` — the Traefik middleware that authenticates the app, e.g. + `authelia@docker`. Required; it is the app's only access control. + - `HOST` and `CERTRESOLVER` — the hostname Traefik matches on and the + resolver that issues its certificate. - `DB` — database file path, default `./data/foodster.db`. The directory is created on startup if missing. - `ENV` — anything but `prod` is prefixed to the browser tab title, so a @@ -422,8 +424,9 @@ the server and run with Docker Compose. 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 +- **Health**: `GET /healthz` returns the build version and nothing else, so it + is safe to exempt in Authelia. There is no Docker `HEALTHCHECK` directive, + because a `scratch` image has no shell to run one and `restart: unless-stopped` already covers a dead process. Adding one would mean giving the binary a `-healthcheck` flag that calls its own endpoint. diff --git a/README.md b/README.md index 646ca1e..1b49426 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ One static Go binary. No Node.js, no bundler, no separate database server. | Interactivity | [Datastar](https://data-star.dev) — signals and DOM patching in one ~11 kB script | | Styling | hand-written CSS, `light-dark()` for themes | | Database | SQLite via `modernc.org/sqlite` (pure Go) | -| Auth | HTTP Basic, one shared household password | +| Auth | none in-app — Authelia, via a Traefik forward-auth middleware | | Runtime image | `FROM scratch` | Working on it: [CONTRIBUTING.md](CONTRIBUTING.md) — branches, commit messages, @@ -196,7 +196,6 @@ Everything is environment variables. `.env` is gitignored; start from | Variable | Default | Purpose | |---|---|---| -| `PASSWORD` | *required* | Shared password. The app will not start without it. | | `DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. | | `ENV` | `prod` | Anything else is prefixed to the tab title (`dev · Foodster`). | | `ADDR` | `:8080` | Listen address. Only useful for a second local instance. | @@ -205,6 +204,8 @@ Everything is environment variables. `.env` is gitignored; start from | `REPO` | *required to run* | Image repository, no tag. Used by `compose.yaml`. | | `TAG` | `latest` | Tag to run under compose. | | `HOST` | *required to run* | Hostname Traefik routes to. | +| `AUTH` | *required to run* | Traefik middleware that authenticates the app, e.g. `authelia@docker`. | +| `CERTRESOLVER` | *required to run* | Traefik certificate resolver for `HOST`. | Names carry no prefix: the container gives them their own namespace already. `PUID`/`PGID` are the exception — `UID` is read-only in bash, so a value set @@ -254,24 +255,27 @@ docker compose restart ## Security -Access is a single shared password over HTTP Basic — no accounts, no -sessions. Credentials are compared in constant time over SHA-256 digests, so -neither the password nor its length leaks through timing. +**The app has no authentication of its own.** It trusts every request it +receives, because the only thing that can reach it is Traefik, and Traefik +hands each request to Authelia first. Access control, sessions, brute-force +protection and multi-factor all live there, where they are configured once +for every service on the host instead of reimplemented per app. -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. +Two things make that safe, and both must hold: -`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. +- **`AUTH` names the Authelia middleware** on the router. It is the whole of + the app's access control. Traefik takes a router out of service when its + middleware does not resolve, so a typo fails shut rather than open. +- **The container publishes no ports.** It is reachable only over the shared + `traefik` network. Publishing `8080` would put an unauthenticated, + unencrypted copy of the app on the host and defeat both of the above. -**None of this replaces a strong `PASSWORD`.** Rate limiting removes -brute force as a practical route; it does not make a guessable password safe. +`/healthz` returns nothing but the version, so it is safe to bypass in +Authelia if a monitor needs to poll it from outside. + +Earlier versions carried HTTP Basic auth and a per-IP guess limiter. Both +were removed once Authelia was in front: two prompts for one door, and the +weaker of the two was the one holding a shared password. ## Mockups diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go index 6a74302..afcad14 100644 --- a/cmd/foodster/main.go +++ b/cmd/foodster/main.go @@ -7,8 +7,6 @@ package main import ( "cmp" "context" - "crypto/sha256" - "crypto/subtle" "database/sql" "embed" "errors" @@ -76,16 +74,11 @@ func run() error { } defer db.Close() - // Importing is an offline chore: no password needed, no server started. + // Importing is an offline chore: no server started, nothing to serve. if *importPath != "" { return runImport(db, *importPath) } - password := os.Getenv("PASSWORD") - if password == "" { - return errors.New("PASSWORD is not set") - } - // Fail rather than fall back to UTC: a silently wrong zone shifts logged // dinners onto the wrong calendar day, which is invisible until the // history is already corrupt. @@ -105,7 +98,7 @@ func run() error { // response blocks every other request behind it. srv := &http.Server{ Addr: addr, - Handler: routes(db, loc, password), + Handler: routes(db, loc), ReadHeaderTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, @@ -168,7 +161,11 @@ func openDB(path string) (*sql.DB, error) { return db, nil } -func routes(db *sql.DB, loc *time.Location, password string) http.Handler { +// routes serves the app unauthenticated. Access control is the reverse proxy's +// job: Traefik forwards every request to Authelia before it reaches here, so a +// second password in front of it only ever meant two prompts for one door. The +// container publishes no ports, so nothing but the proxy can reach it. +func routes(db *sql.DB, loc *time.Location) http.Handler { // Go's mime table has no entry for .webmanifest, and a manifest served as // octet-stream is ignored by the browser. _ = mime.AddExtensionType(".webmanifest", "application/manifest+json") @@ -191,51 +188,13 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler { mux.HandleFunc("POST /ruuat/poista", a.deleteDish) mux.HandleFunc("POST /ruuat/tuonti", a.importDishes) - // /healthz stays outside auth so a monitor or reverse proxy can reach it. - root := http.NewServeMux() - root.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + // /healthz is an ordinary route now that the app has no auth of its own. + // It reveals only the version, so an Authelia bypass rule for it is safe if + // a monitor needs to poll from outside the container network. + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, version) }) - root.Handle("/", auth(password, mux)) - return root -} - -// auth gates everything behind one shared household password. There are no -// 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 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) + return mux } // today is the current calendar day in the configured location, truncated to diff --git a/cmd/foodster/main_test.go b/cmd/foodster/main_test.go index 0049626..48b2dc3 100644 --- a/cmd/foodster/main_test.go +++ b/cmd/foodster/main_test.go @@ -306,63 +306,23 @@ func TestDuplicateNamesAreCaseInsensitive(t *testing.T) { } } -func TestAuth(t *testing.T) { - handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusTeapot) // proves we reached the wrapped handler - })) - - cases := []struct { - name string - user string - pass string - withAuth bool - want int - }{ - {"correct password", "", "hunter2", true, http.StatusTeapot}, - {"username is ignored", "anyone", "hunter2", true, http.StatusTeapot}, - {"wrong password", "", "wrong", true, http.StatusUnauthorized}, - {"empty password", "", "", true, http.StatusUnauthorized}, - {"no credentials", "", "", false, http.StatusUnauthorized}, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - r := httptest.NewRequest(http.MethodGet, "/", nil) - if c.withAuth { - r.SetBasicAuth(c.user, c.pass) - } - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - - if w.Code != c.want { - t.Errorf("status = %d, want %d", w.Code, c.want) - } - if c.want == http.StatusUnauthorized && w.Header().Get("WWW-Authenticate") == "" { - t.Error("401 without a WWW-Authenticate header; the browser will not prompt") - } - }) - } -} - -func TestHealthzSkipsAuth(t *testing.T) { +// The app carries no authentication of its own — Authelia in front of Traefik +// does that — so the only thing left to assert is that every route answers +// without credentials. A 401 from here would mean auth crept back in. +func TestRoutesNeedNoCredentials(t *testing.T) { db, err := openDB(t.TempDir() + "/test.db") if err != nil { t.Fatalf("openDB: %v", err) } defer db.Close() - h := routes(db, time.UTC, "hunter2") + h := routes(db, time.UTC) - w := httptest.NewRecorder() - h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) - if w.Code != http.StatusOK { - t.Errorf("/healthz without credentials = %d, want 200", w.Code) - } - - // Everything else must still be gated. - w = httptest.NewRecorder() - h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) - if w.Code != http.StatusUnauthorized { - t.Errorf("/ without credentials = %d, want 401", w.Code) + for _, path := range []string{"/healthz", "/", "/ruuat"} { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != http.StatusOK { + t.Errorf("GET %s = %d, want 200", path, w.Code) + } } } diff --git a/cmd/foodster/throttle.go b/cmd/foodster/throttle.go deleted file mode 100644 index 2425ff3..0000000 --- a/cmd/foodster/throttle.go +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index d8896a5..0000000 --- a/cmd/foodster/throttle_test.go +++ /dev/null @@ -1,110 +0,0 @@ -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/cmd/foodster/views.templ b/cmd/foodster/views.templ index 49f4dbd..85cbca8 100644 --- a/cmd/foodster/views.templ +++ b/cmd/foodster/views.templ @@ -143,8 +143,9 @@ templ page(title, current string) {