NextMyDay/docs/specs/spec-003-core-door.md
Claus Lohmar 92344d9783 docs(spec): revise D-025 to a tunnel-only setup gate and add D-043
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.
2026-10-15 05:40:30 +00:00

12 KiB

SPEC-003 — the core door (internal/core/server + httpx)

Field Value
Status Implemented (2026-10-13) — see §10; build/vet/test/-race green · static binary · live-smoked. Hardened 2026-10-14 — see §11 (REVIEW-001 H1/H2/M1c/M3s/L1s/L4s).
Created 2026-10-13
Author OpenArchitect
Target milestone M0
Governing decisions D-002 · D-003 · D-025 · D-031 · D-039 · PROJECT_PLAN.md §4/§12
Related SPEC-002 (routing) · SPEC-004 (identity)

1. Goal

The single HTTP entry — the door — that turns an inbound request into a dispatched module function, applying identity / session / role and all cross-cutting concerns. The door resolves and gates; a module never sees a request it shouldn't (D-002, D-039).


2. Non-goals

  • The routing model → SPEC-002.
  • Identity/session storage, users, roles, the chain → SPEC-004.
  • The /setup, enroll, login, recovery flows → SPEC-005+.

3. Structural changes

Path Purpose
cmd/myday/main.go assemble config → core → discover modules → serve
internal/core/server/ the door pipeline + dispatch
internal/core/httpx/ session cookie · client IP (trusted proxies) · RedactPath · security headers · CSRF
internal/core/middleware/ correlation id · rate limit · audit

4. The door pipeline

request
  1. correlation id      — generate a UUID; put it on the context and in every log line
  2. client IP           — resolve from `server.trusted_proxies`; walk XFF **right→left** and
                           SET it to the one resolved value (never append)  [A6/H1]
  3. security headers    — X-Content-Type-Options · Referrer-Policy · a strict CSP · HSTS (when https)
                           · Permissions-Policy · COOP · CORP  [L4s]
  4. routing.Dispatch()  — the pure model (SPEC-002)
  5. setup-mode gate     — in setup mode the **TCP peer must be loopback AND no XFF**, else 404 (A13/D-025)
  6. switch on Kind:
       Redirect   → 302
       NotFound   → 404
       Setup      → the setup wizard, ONLY in setup mode; else 404
       Enroll     → the enrollment screen (if already enrolled → redirect)
       Route/Action →  [the identity gate]
            a. load the session cookie → validate:
                 exists · not expired · not revoked · **non-empty session id**  [A2]
            b. session.user_id == the resolved GUID's user   (the identity match)
            c. missing/invalid → KindLogin (login screen)
            d. ROLE gate: does the user's role permit this function?
            e. build the RequestContext → dispatch to the module function
  7. audit               — every auth event + every mutation (A11)
  8. rate limit          — `/{GUID}`, `/access`, `/setup` (A3)
  • Before dispatching, the door attaches audit.Meta{ActorUserID · ClientIP · CorrelationID} to the context.Context, so services can write their audit rows (SPEC-004 §5, ruling R-1).
  • The setup module is pinned at the door: the wizard's functions are reachable only through the reserved setup workflow; any other resolution naming it 404s (M1c/L3s).

The door renders no dynamic HTML — it resolves, gates, and hands off.


5. RequestContext (in-process hand-over)

type RequestContext struct {
    Workflow    string   // PARAM1 classification
    UserID      int64    // the resolved user (0 when anonymous)
    Principal   string   // "user:<id>" — IDENTIFICATION only, never a credential
    Function    string   // the resolved module function / action
    Page        string   // the joined remainder
    Correlation string   // the request's correlation id
    ClientIP    string   // resolved client IP
    Location    string   // IP-derived, AUDIT ONLY (D-031) — no tracking
}
  • Carries identification, never a credential.
  • Principal and the credential (session cookie) are separate concerns (D-001).

  • Cookie myday_session: HttpOnly · Secure · SameSite=Lax · Path=/.
  • Over https the cookie carries the __Host- prefix (__Host-myday_session, L1s): a browser only accepts it with Secure · Path=/ · no Domain, so the cookie is pinned to the exact origin and a sibling subdomain cannot shadow it. On plain-http loopback (the bootstrap / human test) the bare name is used — a browser rejects a __Host- cookie sent without Secure, so the prefix cannot be unconditional.
  • Opaque random id; the server-side row is authoritative (SPEC-004).
  • CSRF: a per-session token on every action (do/action) — hidden field, verified server-side (A8). The action fields are not a security control on their own.
  • The pre-session double-submit token (A15) covers every anonymous form (/setup, /access, enroll/login). In setup mode the CSRF + cookie signing keys are generated at startup when left at a placeholder, so the ceremony is never signed with a shipped, public key (M1s).
  • Logout revokes the session row.

7. Loopback enforcement (D-025, revised 2026-10-15)

  • No bind refusal. The app may bind any address — including 0.0.0.0:8080 behind a reverse proxy (Zoraxy, D-043). (A bind check is incompatible with a proxy-fronted deployment.)
  • Per request, while in setup mode, the gate is strict: serve only a request whose TCP peer is loopback and which carries no X-Forwarded-For — i.e. the direct SSH tunnel. Anything else — including a request relayed by a trusted proxy — → 404, before dispatch. An empty/unparseable peer fails closed.
    • Why both tests: a proxy on the same host presents a loopback peer, so the peer check alone is not enough; the absence of X-Forwarded-For proves the request did not traverse a proxy.
  • Once productive (setup: false), normal service: the trusted proxy's X-Forwarded-Proto drives Secure on the session cookie, the __Host- prefix, and HSTS (§6 · D-043).

8. Logging & redaction

  • slog, structured, with the correlation id on every line.
  • The access log records the raw path + PARAMS; the GUID is public (a username) but any credential that rides the path is redacted (RedactPath) — A8/A11.
  • The door logs the resolved (workflow, module, function); a module logs its own steps.

9. Acceptance criteria

# Criterion
AC-1 / → 302 /home; /holy-shit → 404
AC-2 /home renders with no session and no DB lookup
AC-3 /{GUID} with no session → login; with a valid session → the module function
AC-4 A session whose user_id ≠ the GUID's user is rejected
AC-5 An empty / missing session id is rejected on every path (A2)
AC-6 A do/action request without a valid CSRF token is rejected
AC-7 In setup mode, a request relayed by a proxy (a loopback peer with X-Forwarded-For) is 404 — the bootstrap is tunnel-only (D-025 revised)
AC-8 Every response carries a correlation id, echoed in the log; path credentials are redacted
AC-9 The rate limiter returns 429 on the auth-sensitive routes (A3)
AC-10 In setup mode a request whose TCP peer is not loopback, or which carries any X-Forwarded-For, is 404 (D-025/H2/A13)
AC-11 The setup module is reachable only through Workflow == "setup"; any other resolution naming it is 404 (M1c/L3s)
AC-12 X-Forwarded-For is resolved right→left; a forged leftmost entry is ignored (H1/A6)
AC-13 Behind the proxy, X-Forwarded-Proto: https ⇒ the session cookie is Secure + __Host-, and HSTS is set (D-043)

10. Implementation notes (accepted rulings)

Built 2026-10-13. build · vet · test · -race green; static CGO-free binary; live smoke verified (/→302 · /home→200 · /setup→200 · /holy-shit→404 · correlation id in header and log · loopback enforcement · fail-closed secrets). internal/core/routing purity intact.

# Builder choice Ruling
D-1 gopkg.in/yaml.v3 + KnownFields(true) (unknown config key = error) Accepted — pure-Go, static-safe, fail-closed.
D-2 Secret strength: cookie_secret/csrf_key ≥ 32 bytes; totp_encryption_key = 32 bytes; CHANGE_ME rejected in productive mode Accepted — to be documented in the config reference. Amended 2026-10-14: the dead session_key is removed (L1c/L2s).
D-3 CSRF = stateless HMAC-SHA256(csrf_key, "myday.csrf.v1:"+session_id) Accepted — no extra table; dies with the session.
D-4 In-memory fixed-window rate limiter (30/min; classes access·setup·guid) Accepted.
D-5 RoleGate seam, defaulting to allow any authenticated user Accepted — the policy lands in SPEC-007/008.
D-6 Env: config path via MYDAY_CONFIG only Accepted; per-key env override deferred.
D-7 RequestContext = exactly §5 (no Module field) Accepted — module identity stays on Resolution.

Rulings on the flagged items:

# Item Ruling
R-4 auth.Mint/Rotate/Revoke hard-code the owning user as actor. Use the context actor (creator/approver); the affected user is the subject — see SPEC-004 §8.
R-5 Pre-session CSRF — /setup, /access and the enroll/login actions run before a session, so §6's session-bound CSRF doesn't cover them. Must be added — a signed double-submit cookie for anonymous forms. Applies to SPEC-005/006/007; tracked as A15.
— RequestContext.Location always empty (D-031) Accepted — the seam exists; the IP-geo lookup is deferred.
— RedactPath over-redacts a segment after a credential-like name Accepted — log-only; no leak.

11. M0 review hardening (2026-10-14 · REVIEW-001)

The independent M0 review found the identity/session/CSRF core sound but the perimeter weak. The fixes touching this spec:

Finding Fix
H2 — dispatch checked loopback only at bind §7: a per-request gate — in setup mode a non-loopback resolved client IP is 404 before dispatch (AC-10).
H1 — resolveForwarded returned the leftmost untrusted hop ClientIP now walks right→left and returns the first untrusted entry, falling back to the peer (AC-12). An append-style proxy can no longer be forged past.
M1c — the /setup action door was not pinned to the module serveSetupAction rejects Module != "setup" (AC-11).
L3s — /{GUID}?do=setup&… fell through to serveGUID The door rejects any resolution naming the setup module outside Workflow == "setup", in every mode (AC-11).
M1s — placeholder signing keys ran the whole setup cmd/myday generates the CSRF/cookie keys at startup in setup mode, mirroring the TOTP key (R-6/D-1); finish persists the same values.
M3s — the rate limiter's state map was never pruned middleware.RateLimiter sweeps elapsed windows (time-gated, amortised) and caps the map.
L1s — no __Host- cookie prefix §6: session cookie is __Host-myday_session over https.
L4s — missing hardening headers Permissions-Policy · Cross-Origin-Opener-Policy · Cross-Origin-Resource-Policy (§4 step 3).
L1c/L2s — dead secrets.session_key Removed (D-2 amended).
L7c — the passkey nudge trusted the client Host SPEC-006 §8: the configured base_url host is the origin signal, falling back to the request Host.

build · vet · test -race green; the static CGO-free binary is unchanged; internal/core/routing purity intact.