# SECURITY.md PBBGs are cheat targets. Assume every request is hostile. ## Authentication - Hash with `bcryptjs`, cost 10–12. Never store or log plaintext. - Case-insensitive unique usernames (DB index uses `COLLATE NOCASE`). - Login errors are generic: `INVALID_CREDENTIALS`. Do a dummy hash compare when the user doesn't exist (timing). - Regenerate the session ID on login (`req.session.regenerate`) to prevent fixation. - Logout destroys the session server-side and clears the cookie. - Banned users (`is_banned=1`) are rejected at login and in `requireAuth`. ## Sessions / cookies - `httpOnly: true`, `sameSite: 'lax'`, `secure: true` when `NODE_ENV=production`. - `maxAge` 7 days, rolling. Secret from `SESSION_SECRET` env (refuse to boot in production without it). - Store sessions in SQLite, not memory (so restarts don't log everyone out). ## CSRF Cookie-based sessions + state-changing POSTs ⇒ CSRF matters. - `sameSite=lax` blocks most cross-site POSTs. - Additionally require header `Content-Type: application/json` on all POSTs and reject others (browsers can't send cross-site JSON without CORS preflight). **Do not enable CORS.** ## Input validation - Validate every body/query/param with zod. Reject unknown keys (`.strict()`). - Clamp numerics (quantities 1–99). Reject NaN/negative/float where ints are expected. - Never render user-supplied text with `innerHTML`. Use `textContent`. (Usernames and hero names are user-supplied!) ## Anti-cheat / game integrity - Server decides all outcomes; client sends only intent. - Every multi-step state change is one DB transaction (spend + grant). - Ownership: derive the character from the session — never accept `characterId` from the client. - Check `inventory.character_id = ?` on every inventory operation. - Spam: rate-limit actions (e.g., 30 game actions / minute / user) — energy is the real limit, this is a safety net. - Expedition claim: set `claimed_at` and grant rewards in one transaction; the partial unique index prevents duplicates. - Never trust client clocks. All timing from `utils/time.now()`. ## Rate limiting (`express-rate-limit`) | Scope | Limit | |---|---| | `POST /auth/login` | 10 / 15 min / IP | | `POST /auth/register` | 5 / hour / IP | | other `/api` | 120 / min / IP | ## Headers & transport - `helmet()` defaults. Provide a CSP: `default-src 'self'` (no inline scripts — put JS in files). - Serve behind HTTPS in production (note in README; reverse proxy is out of scope). - `app.set('trust proxy', 1)` only if running behind a proxy. ## Data exposure - Public endpoints (`/leaderboard`, `/heroes/:name`) return only: name, class, level, kills, gold(optional). Never user ids, usernames, or emails. - Hero name ≠ login username (so leaderboard doesn't reveal login names). - Error handler returns generic message for unexpected errors; stack traces go to the server log only. ## SQL - Prepared statements with `?` / named params only. No string-built SQL, ever. Table/column names are never user-controlled (use allowlist for leaderboard sort `type`). ## Secrets & logging - `.env` is gitignored. `.env.example` has placeholders only. - Don't log request bodies on auth routes. - `audit_log` records login ok/fail, register, logout with IP + timestamp. ## Checklist (Phase 9.1) — tick each in STATUS Session Log - [ ] No `innerHTML` with dynamic data - [ ] All routes validated by zod - [ ] All writes in transactions - [ ] Ownership checks on every inventory/expedition op - [ ] Rate limiters mounted - [ ] Cookie flags verified in browser dev tools - [ ] Password hash never in any response (grep for `password_hash` in routes) - [ ] Leaderboard sort allowlisted - [ ] Production boot fails without SESSION_SECRET