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
+19 -6
View File
@@ -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