NextMyDay/docs/reviews/review-001-m0.md
Claus Lohmar 04c2394c34 docs: record the M0 review fixes and correct the README
REVIEW-001 triage updated (H1, H2, M1c, M1s, L3s, L1c/L2s, L8c, M2s, M3s,
M2c, L1s, L5s, L4s, L7c marked FIXED; L3c, L4c, L5c, L6c stay open with the
reason), and each fix is tied to its code and test.

- SPEC-003: the door pipeline (XFF right→left, the per-request setup gate,
  the isolated hardening headers, the pinned setup module), §6 the `__Host-`
  cookie and the startup-generated signing keys, §7 loopback enforcement
  per request, new AC-10…AC-12, D-2 amended (session_key removed), and a new
  §11 that maps every REVIEW-001 finding to its fix.
- SPEC-005 / SPEC-006 / SPEC-007: the same three points where they belong
  (the wizard's loopback + module pin, the nudge's configured-origin signal,
  the async /access notification ruled in).
- PROJECT_PLAN §10 register: A13 re-stated as enforced (per request + pin);
  A2/A5/A8 re-stated as enforced where the review verified them; A3/A6/A9/A15
  now enforced by this pass; A11 left open (L4c). §14 change log entries.
- README: the status said "planning / pre-alpha · nothing is built" — it is
  stale. State M0+M0.1, note that /setup PERSISTS config.yaml and a restart
  adopts the generated secrets, and state the per-request loopback rule.
2026-10-14 11:30:17 +00:00

73 lines
8 KiB
Markdown

# REVIEW-001 — M0 (correctness + adversarial security)
| Field | Value |
|---|---|
| **Date** | 2026-10-14 |
| **Scope** | M0 (SPEC-002…008) + M0.1 (SPEC-009), at commit `f5c6b95` |
| **Reviewers** | two independent passes — *correctness & spec-conformance* · *adversarial security* |
| **Outcome** | **not safe to expose** until **H1** and **H2** are fixed; **sound enough for a loopback human test** after that |
| **Fixes** | **2026-10-14** — H1 · H2 · M1c · M1s · L3s · L1c/L2s · L8c · M2s · M3s · M2c · L1s · L5s · L4s · L7c landed (see **FIXED** below). **L3c · L4c · L5c · L6c** remain open. |
> The reviewers had no shell in their sandbox, so the `build`/`vet`/`test -race` gate is taken from
> the builders' runs and re-verified by the fix slice. Everything below is a **source** finding.
---
## Findings
| # | Sev | Where | Defect / attack | Triage |
|---|---|---|---|---|
| **H2** | 🔴 HIGH | `server.go` `dispatch` (setup branch) | **Loopback is enforced only at *bind*, never per request.** With a reverse proxy on `127.0.0.1` relaying remote clients, an attacker reaches `/setup`, creates the top admin — **the exact "first-visitor-wins" D-025 claims to close "in code."** | **✅ FIXED** — per-request gate: a non-loopback *resolved* client IP is 404 before dispatch (SPEC-003 §7/AC-10; `TestSpec003SetupIsLoopbackOnlyPerRequest`). |
| **H1** | 🔴 HIGH | `httpx/clientip.go` `resolveForwarded` | **XFF resolved leftmost-untrusted** → an append-style proxy (nginx default) lets the client forge the leftmost entry ⇒ a fresh rate-limit bucket per request (**A3 bypass**), plus forged `client_ip`/`location` in the audit trail (**A11/D-031**). Walk **right→left**. | **✅ FIXED** — right→left, first untrusted entry wins, fall back to the peer (AC-12; `TestClientIPTrustedPeerResolvesRightmostUntrusted`). |
| **M1c** | 🟠 MED | `server.go` `serveSetupAction` | The action door is **not pinned to the `setup` module** — in setup mode `POST /setup?do=core&action=modules.toggle` reaches a core action, bypassing the admin-only `RoleGate`. Inert today (no optional modules); a real primitive at M1. | **✅ FIXED** — `serveSetupAction` rejects `Module != setupModule` (AC-11). |
| **M1s** | 🟠 MED | `main.go` + `config.go` | **Placeholder signing keys run the whole setup:** `csrf_key`/`cookie_secret` stay `CHANGE_ME…` until restart ⇒ the double-submit token and the ceremony cookie are signed with a **publicly-known key** (A9/A15). Generate at startup, like the TOTP key. | **✅ FIXED** — `prepareSigningKeys` at startup in setup mode; `finish` persists the same values. |
| **L3s** | 🟡 LOW | `server.go` (GUID action door) | `/{GUID}?do=setup&action=…` falls through to `serveGUID`; only the handler's own guards make it inert. Enforce `Workflow=="setup"` at the door. | **✅ FIXED** — any resolution naming the `setup` module outside `Workflow=="setup"` is 404 in every mode (AC-11). |
| **L1c/L2s** | 🟡 LOW | `config.go` · `setup.go` | `secrets.session_key` is **dead config** — validated and generated, never read. Remove it (KISS) or document it as reserved. | **✅ FIXED** — removed from `config.go`, `setup.go`, `config.example.yaml` (SPEC-003 D-2 amended). |
| **L8c** | 🟡 LOW | `README.md` | Says "planning / pre-alpha · nothing is built" — stale; also omits that `/setup` **persists `config.yaml`** and a **restart** adopts the generated secrets. | **✅ FIXED** — status + a `/setup` / restart-secrets note. |
| **M2s** | 🟠 MED | `recovery/flow.go` | `/access` **timing oracle**: a hit does two **synchronous** SMTP sends, a miss one `SELECT` ⇒ "is this email on record?" leaks (A3). Notify **asynchronously**. | **✅ FIXED** — notifications run off the request path via `Deps.Dispatch` (`go fn()` by default), detached from the request context; `TestAccessNotificationIsAsynchronous`. |
| **M3s** | 🟠 MED | `middleware/ratelimit.go` | The limiter's `state` map is **never pruned** ⇒ unbounded growth (memory DoS). Prune elapsed windows. | **✅ FIXED** — time-gated sweep of elapsed windows + a hard cap (`TestRateLimiterPrunesElapsedWindows` · `TestRateLimiterMapIsCapped`). |
| **M2c** | 🟠 MED | `cmd/myday` (no test) | The **D-1 TOTP-key-at-startup** invariant is only covered by a *manual* smoke — no automated restart-survival test. A regression silently destroys future TOTP secrets. | **✅ FIXED** — `TestTOTPKeySurvivesSetupAndRestart` generates the key, persists via the wizard, reopens the DB and asserts the secret still unseals. |
| **L1s** | 🟡 LOW | `httpx/cookie.go` | Session cookie lacks the **`__Host-`** prefix (sibling-subdomain cookie-shadow DoS). | **✅ FIXED** — `__Host-myday_session` over https (a `__Host-` cookie is invalid without `Secure`, so it is conditional). |
| **L5s** | 🟡 LOW | `auth/totp/totp.go` | **TOTP replay** not prevented (skew ±90s) — track the last accepted counter per identity. | **✅ FIXED** — the highest accepted counter per identity is tracked; a replayed or older step is `ErrInvalidCode` (`TestVerifyRejectsAReplay`). |
| **L4s** | 🟡 LOW | `httpx/headers.go` | Add `Permissions-Policy` · `Cross-Origin-Opener-Policy` · `Cross-Origin-Resource-Policy`. | **✅ FIXED** — all three on every response. |
| **L3c** | 🟡 LOW | `identities.go` `Mint` | Check-then-insert is **not atomic**; two concurrent Mints → two `pending_enrollment` rows. Add a `UNIQUE(user_id) WHERE status <> 'revoked'` index / wrap in a tx. | fix next |
| **L4c** | 🟡 LOW | `Sessions.GC` · `UpdateWebAuthnSignCount` | Mutate without an audit row; AC-7's test enumerates the audited methods and **omits** these. | fix next |
| **L5c** | 🟡 LOW | `spec009_web_pwa_test.go` | The no-inline-script assertion covers a fixed event list; the CSP header per screen is never asserted. | fix next |
| **L6c** | 🟡 LOW | `middleware/ratelimit.go` | All `/{GUID}/…` traffic shares one bucket; `RetryAfter` uses wall-clock while `Allow` uses the injected clock. | fix next |
| **L7c** | 🟡 LOW | `auth/flow/flow.go` | The nudge trusts the client `Host`; behind a proxy that rewrites `Host`, the nudge is wrongly suppressed. | **✅ FIXED** — the configured `base_url` host is the origin signal (fall back to the request `Host`); `TestSpec006_NudgeIgnoresAReWrittenHost`. |
## Verified sound (no action)
Routing cascade & empty-segment handling · **two doors, one destination** · identification ≠
authorisation (session↔GUID match) · identity rotation revokes all sessions & factors · the
enrolment window · deterministic migration/discovery order · module↔core boundary · **A2** empty
session rejected · **A15/A8** CSRF on every action (session-bound + pre-session double-submit) ·
session fixation (rotation) · **A9** AES-GCM with a fresh nonce, never logged · **A1/A10** bounded
window + GUID tombstoning · **A5** the service worker never caches authed/`/{GUID}` · **A4/A7/R-7**
chain-authority approval · SQL parameterised · `static` traversal-safe · **D-041** the nudge cannot
downgrade auth.
---
## Verdict & gate
**Not safe to expose.** No auth bypass, no session forgery, no CSRF gap, no SQLi, no traversal — the
core is solid. The holes are in the **perimeter** the register calls "enforced": **fix H2 then H1**
before the first client; **M1c/M1s/L3s/L1c** ride the same pass.
A **private, loopback, no-proxy** human test is acceptable **once H1 + H2 land**.
---
## Fix pass (2026-10-14)
**H1 · H2 · M1c · M1s · L3s · L1c/L2s · L8c · M2s · M3s · M2c · L1s · L5s · L4s · L7c are fixed**
(the Triage column above records the code/spec/test for each). The register rows **A3 · A6 · A9 ·
A11 · A13 · A15** were re-checked in `PROJECT_PLAN.md` §10.
Still open (deferred, low): **L3c** · **L4c** · **L5c** · **L6c** — none blocks a loopback human test.
Gate after the fix: `go build` · `go vet` · `go test -race` green; static CGO-free binary;
`internal/core/routing` purity intact. Each headline fix has a test that **fails without it** (H2
verified by reverting the guard — the rejection test fails on `/setup` and on the trusted-proxy
relay).