# 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) ```go type RequestContext struct { Workflow string // PARAM1 classification UserID int64 // the resolved user (0 when anonymous) Principal string // "user:" — 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). --- ## 6. Session cookie & CSRF - 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.