SPEC v0.21: D-120 Nginx Proxy Manager instead of Caddy and cert-sync; D-121 Android host property
SPEC §10.5 rewritten (10.5.1 Nginx Proxy Manager, 10.5.2 Custom Nginx Configuration, 10.5.3 Fallback: NPM down); Caddy and cert-sync text replaced in §1, §6.1, §7, §8.5, §8.7, §8.9, §10 intro, §10.1 to §10.4, §10.7, §10.10, §11.2 to §11.4, OQ-3, OQ-6, E-7 and §14.7; the Kvitt host name is KVITT_PUBLIC_HOST. D-121: kvitt.publicHost, RELEASE_SERVER_URL and @ReleaseServerUrl (AC-2.6, AC-2.7, §10.6, §13.3 S-1). Decisions D-120 and D-121 with pointers in D-023, D-103 to D-106 and D-117; HANDOFF; prompts/README; a superseded-in-part note on PLAN-M2c-pi; dated D-120 notes under M-2, L-1, L-2, L-8 and I-5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -32,6 +32,8 @@ Location: `infra/cert-sync/sync.sh:36-42`; SPEC §10.5.1 ("variant B: added as a
|
||||
Issue: cert-sync holds `/secrets/ha_ssh_key`. With variant B there is no `restrict,command=` on the HA side, so whoever holds the key (a compromised cert-sync container, or the key copied around on the Mac and the Pi) gets an unrestricted root shell on the Home Assistant Pi, which also owns ports 80/443 and the house's automation. The script's own fixed `tar` command does not restrict the server side.
|
||||
Smallest fix (infra + owner): make variant A the only accepted variant (`restrict,command="tar -C <dir> -cf - fullchain.pem privkey.pem"`, plus `from="<Kvitt Pi LAN address>"`), and if the add-on cannot do that, use a dedicated non-root user that can read `/ssl` only. Record the variant in `infra/README.md` as §10.5.1 already asks.
|
||||
|
||||
> **Note 2026-10-19 (D-120, SPEC v0.21):** moot. Caddy and cert-sync are removed; TLS terminates in the owner's Nginx Proxy Manager on the Home Assistant Pi, and no SSH key for the Home Assistant Pi exists in the stack any more (`install.sh` deletes `ha_ssh_key` and `ha_known_hosts` left on the Kvitt Pi). Closed.
|
||||
|
||||
**M-3. Picture decoding can exceed the server's memory limit (8000 × 8000 px vs 160 MiB).**
|
||||
Location: `server/crates/kvitt-server/src/api/pictures.rs:39` (`MAX_DIMENSION`), `:66-92` (`process`), `infra/pi/compose.yaml` (`mem_limit: 160m`); limit from SPEC AC-4.4.
|
||||
Issue: the dimension check happens before decoding (good), but an 8000 × 8000 image is allowed: decoding to RGBA is 256 MB, plus `to_rgba8`/`to_rgb8` copies, on a container limited to 160 MiB. A 5 MiB PNG or WebP easily encodes that size (a flat colour compresses to a few KB). Only an authenticated device can call `PUT /me/picture`, so in this threat model the attacker is a lost phone with a live token (until revoked) or a bug in the app, not a stranger; the effect is a crash and restart of the server (and of the sync for both phones) on each upload.
|
||||
@@ -44,11 +46,15 @@ Location: `infra/pi/Caddyfile:11-14`; SPEC §8.7 (the Rust server logs the route
|
||||
Issue: Caddy logs `request.uri` in full: `/i/<CODE>?from=<name>` and the `?from=` value, together with the client IP and User-Agent, into the json-file logs on the Pi (10 MB × 3). Codes live 72 h and are single-use, so the risk is low (an attacker with Pi log access is out of scope), but it is personal data and a secret in a log that §8.7 says must not hold either.
|
||||
Fix (infra): add `log_skip` (Caddy ≥ 2.8) for the `/i/*` matcher, or `log { format filter { request>uri delete } ... }` — one line in the Caddyfile; or drop the access log entirely (the server already logs every request).
|
||||
|
||||
> **Note 2026-10-19 (D-120, SPEC v0.21):** Caddy is gone; the redaction moved to NPM's Custom Nginx Configuration (SPEC §10.5.2): `location ^~ /i/` has `access_log off` and `error_log /dev/null crit`, so neither the code nor the `?from=` name reaches NPM's logs, a 502 included (rehearsed on the Mac). Other paths are logged with their query strings, which hold no personal content; NPM logs no request headers.
|
||||
|
||||
**L-2. The Caddy container is not hardened like the other three.**
|
||||
Location: `infra/pi/compose.yaml` (`caddy` service: no `user`, no `cap_drop`, no `read_only`); SPEC §8.9 hardens only the server.
|
||||
Issue: Caddy runs as root with every default capability and a writable root filesystem, and its admin API is bound to `0.0.0.0:2019` for every container on the `kvitt` network (only cert-sync needs it). This is the process that terminates TLS from the internet.
|
||||
Fix (infra): `cap_drop: [ALL]`, `cap_add: [NET_BIND_SERVICE]` (or none: 8443 is unprivileged), `read_only: true` with `tmpfs: [/tmp]` and `caddy_data`/a `caddy_config` volume writable; keep `no-new-privileges`. Admin on `0.0.0.0` is required for cert-sync; acceptable on the internal network.
|
||||
|
||||
> **Note 2026-10-19 (D-120, SPEC v0.21):** moot. There is no Caddy container; the TLS-terminating process is the owner's existing NPM install on the Home Assistant Pi, outside this repository. On the Kvitt Pi only `server` publishes a port (8080 on the LAN), with the hardening of SPEC §8.9.
|
||||
|
||||
**L-3. APK download is unauthenticated and 11 MB per request at 60 requests per minute per IP.**
|
||||
Location: `server/crates/kvitt-server/src/api/apk.rs:76-101`, `src/api/rate_limit.rs:56-63`; SPEC §8.6, §10.6.
|
||||
Issue: the only large unauthenticated response. One IP may pull about 660 MB/min, several IPs more, saturating the home uplink (and the Pi's SD card) for as long as they like. The file is public by design (the landing page links it).
|
||||
@@ -79,6 +85,8 @@ Location: `.gitea/workflows/build-images.yaml` ("Log in to Gitea Container Regis
|
||||
Issue: `${{ }}` is substituted into the script text before the shell runs it: a token containing a quote or `$` breaks the line (and could inject), and the value appears in the step's process arguments on the runner. The token also needs `write:repository` (for the tag push) on top of `write:package`.
|
||||
Fix (infra): pass it as `env: { REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }} }` and use `printf '%s' "$REGISTRY_TOKEN" | docker login …`; keep the scopes minimal (the tag push can use the checkout token if Gitea provides one).
|
||||
|
||||
> **Note 2026-10-19 (D-120, SPEC v0.21):** the fix of `e70fe0d` is reversed (`4f69ae0`). The env-based form came through empty on act_runner 0.6.1 with a user-level secret ("password is empty" on the first run), so the login step interpolates the secret in the `run` line again, as `printf '%s' "${{ secrets.kvitt_cicd }}" | docker login … --password-stdin` (the owner's proven form); act_runner masks the value in the log. The quoting risk remains for a token containing `"` or `$`; Gitea tokens are hex. The scope stays `write:package` only.
|
||||
|
||||
**L-9. Images are pulled by tag over plain HTTP from the LAN registry.**
|
||||
Location: `infra/pi/compose.yaml` (`${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG}`), `infra/pi/install.sh` (`insecure-registries`), `infra/pi/deploy.sh`; SPEC §10.3, D-001/D-104 (LAN is trusted).
|
||||
Issue: anyone on the home Wi-Fi who can answer for `gitea.lan` (or the Gitea Pi itself, if compromised) can hand the Kvitt Pi a tampered server image that sees every token hash and all data. Accepted by the spec's trust in the LAN; noted because the server is the one component that holds everything.
|
||||
@@ -101,6 +109,8 @@ Fix (server): return 404 in both cases, or leave as is.
|
||||
|
||||
**I-5.** Client-IP correctness to verify after deploy: the rate limiter trusts the last `X-Forwarded-For` entry from Caddy (`rate_limit.rs:203-227`). If Docker's userland proxy ever fronts port 8443 (IPv6 without `ip6tables`, or `userland-proxy` quirks), Caddy sees the bridge gateway for every client and all internet clients share one 60/min bucket and one 10-failed-accepts lockout. Check once in Caddy's log that `request.remote_ip` is a public address.
|
||||
|
||||
> **Note 2026-10-19 (D-120, SPEC v0.21):** the trusted proxy is now NPM (`KVITT_TRUSTED_PROXY` = `HA_HOST`, its LAN IPv4 address) and the server publishes 8080 on the LAN; the one-off check is a `conntrack -L -p tcp --orig-port-dst 8080` on the Kvitt Pi showing `HA_HOST` as the source and no `172.*` address in the reply half (SPEC §10.10), instead of Caddy's `remote_ip`.
|
||||
|
||||
**I-6.** `bootstrap` and `pair-device` print the invite code to stdout (`src/cli/bootstrap.rs:41-62`). Through `docker compose run --rm` the line also sits in that one-off container's json log until the container is removed. Expected; the owner reads it and it expires in 72 h.
|
||||
|
||||
**I-7.** The Ktor/OkHttp client keeps default redirect handling (`data/api/KvittApi.kt:246-248`). Neither the server nor Caddy redirects, and TLS excludes a third party, so the bearer cannot be bounced to another host. Fine.
|
||||
|
||||
Reference in New Issue
Block a user