The app is deployed behind Zoraxy (myday.sechpoint.app) with TLS terminated at the proxy, so it must listen on 0.0.0.0:8080 over HTTP — a bind refusal is impossible. - SPEC-003 §4/§6/§7/§9: the bind refusal is gone; in setup mode the door serves only a request whose TCP peer is loopback AND which carries no X-Forwarded-For (the direct SSH tunnel), else 404 (AC-7/AC-10); the proxy's X-Forwarded-Proto drives Secure + __Host- cookies and HSTS (AC-13). - SPEC-005 §3/§5/§6: AC-2 becomes the per-request tunnel-only gate; any bind is allowed. - review-001: record the Architect's rulings and the adopted strict gate.
94 lines
9.7 KiB
Markdown
94 lines
9.7 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**.
|
|
|
|
---
|
|
|
|
## Rulings (Architect, 2026-10-14)
|
|
|
|
The fix pass landed (`03a9db0..04c2394`), independently verified: **gate green** · static binary ·
|
|
`routing` pure · tree clean · `main` == `origin/main`.
|
|
|
|
| Item | Ruling |
|
|
|---|---|
|
|
| **Dev. 1** — `__Host-` prefix applied **only over https** | **Accepted.** A `__Host-` cookie *must* carry `Secure`; forcing it would break the documented **http loopback bootstrap**. The prefix applies in the real (https) deployment. |
|
|
| **Dev. 2** — the TOTP replay counter is **in-process**, not a DB column | **Accepted** for now (±90 s window bounds the exposure; no migration). A durable `totp_secrets.last_counter` is a later option. |
|
|
| **Dev. 3** — the `/access` notify is async; the insert stays synchronous | **Accepted** — the dominant SMTP timing signal is gone; a fully uniform response is deferred. |
|
|
| **Dev. 5** — the M2c test drives `setup.Actions.Finish` with a stub, not the whole binary | **Accepted** — it pins the invariant; the live smoke covers the true end-to-end. |
|
|
| **A6 residual** (builder, Class B) — a proxy forwarding a **client-supplied** XFF *verbatim* could present a loopback claim during setup | **Adopt the strict setup gate:** in setup mode require **a loopback peer AND no `X-Forwarded-For`** header — the bootstrap is a **direct** SSH tunnel, so a proxied setup is refused. *Fold into the next slice.* |
|
|
|
|
**Still open (scheduled, non-blocking):** **L3c** (`Mint` check-then-insert atomicity) · **L4c**
|
|
(`Sessions.GC` / `UpdateWebAuthnSignCount` unaudited — the reason **A11** stays open) · **L5c**
|
|
(per-screen CSP assertion) · **L6c** (per-GUID rate buckets + the `RetryAfter` clock).
|
|
|
|
**Status: M0 is safe for a private, loopback human test.**
|
|
|
|
---
|
|
|
|
## 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).
|