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.
9.7 KiB
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 -racegate 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).