3.6 KiB
3.6 KiB
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 inrequireAuth.
Sessions / cookies
httpOnly: true,sameSite: 'lax',secure: truewhenNODE_ENV=production.maxAge7 days, rolling. Secret fromSESSION_SECRETenv (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=laxblocks most cross-site POSTs.- Additionally require header
Content-Type: application/jsonon 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. UsetextContent. (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
characterIdfrom 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_atand 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 sorttype).
Secrets & logging
.envis gitignored..env.examplehas placeholders only.- Don't log request bodies on auth routes.
audit_logrecords login ok/fail, register, logout with IP + timestamp.
Checklist (Phase 9.1) — tick each in STATUS Session Log
- No
innerHTMLwith 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_hashin routes) - Leaderboard sort allowlisted
- Production boot fails without SESSION_SECRET