# security.md · Cybersecurity review

Reviewed: api/src/*.php, api/public/.htaccess, web/public/.htaccess, web/src (React 19, no dangerouslySetInnerHTML), npm audit (0 vulnerabilities). Findings only.

- **TOTP code replay inside its 30-second window** — severity: high → FIXED
  - Where: `Auth.php` login, `Totp::check`.
  - Exploit: a code shoulder-surfed or leaked from a phishing page could be reused once within its window.
  - Fix: `Totp::match` returns the counter; `users.totp_last` stores it and an equal counter is refused.
- **Client IP taken from proxy headers unconditionally** — severity: medium → FIXED
  - Where: `Kernel::ip()`; used for rate limits and the access log.
  - Exploit: a caller not behind a proxy could set `CF-Connecting-IP` to dodge per-IP rate limits on /auth/login and /verify.
  - Fix: headers are honoured only when `KEEL_TRUST_PROXY=1`.
- **Seat access is a bearer link in e-mail; the link does not rotate** — severity: medium (carried)
  - Where: `POST /seat/open/{token}`, `seats.token`.
  - Exploit: anyone who reads the seat holder's mailbox can open the seat until it is revoked.
  - Fix later: rotate the token on each open and re-mail, or add an OTP to the seat; today the mitigations are a 64-hex token, 12-hour sessions, an access log the firm sees, and one-click revocation that also kills sessions.
- **Session token kept in sessionStorage** — severity: medium (carried)
  - Where: `web/src/lib.jsx`.
  - Exploit: only reachable through an XSS in the app itself; React escapes all output and the CSP allows scripts from self only.
  - Fix later: move to an httpOnly cookie with a CSRF token once the app is served from one origin in production.
- **`/setup` is open until the first firm exists when KEEL_SETUP_TOKEN is empty** — severity: low (carried)
  - Where: `Auth.php` setup route.
  - Exploit: on a fresh deploy a stranger who finds the host first could create the firm.
  - Fix: `.env.example` ships with a token placeholder; the README tells the operator to set it before uploading.
- **Personal data in logs** — severity: low (carried, disclosed)
  - Where: `firm_log` keeps the IP that recorded each entry and every sign-in; the `log` mail driver writes full e-mails (with seat links) under `KEEL_STORAGE/mail`.
  - Fix: storage sits outside the web root; switch to `smtp`/`mail` in production and clear `storage/mail`. Disclosed in the privacy notice.

Checked and clean: parameterised SQL everywhere (`Kernel::one/rows/exec`, column names never from input); bcrypt via `password_hash`; 12-hour sliding sessions, reset tokens single-use and 30 minutes; login lockout 10 fails/15 min plus per-IP limits on login, reset, invite, setup, verify, ask; server-side validation on every route (dates, CIN, DIN, SRN regexes, integer clamps, string caps); uploads restricted to xlsx/csv/pdf/png/jpg with magic-byte checks, 25 MB, random names outside the web root, served with `nosniff` and a sandboxing CSP; no secrets in the bundle; `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, CSP and HSTS on the SPA; same-origin only (Origin check on writes, no CORS headers); 500s return a fixed message and log the exception server-side; no admin or debug route without the `keel` role.
