diff --git a/docs/REMEDIATION_PLAN.md b/docs/REMEDIATION_PLAN.md index addc9b3..da4a9b0 100644 --- a/docs/REMEDIATION_PLAN.md +++ b/docs/REMEDIATION_PLAN.md @@ -20,7 +20,7 @@ Key: `S` ≤ half day · `M` ~1–3 days · `L` ~1 week+ · 🔴 launch-blocker - [x] **0.1 🔴 S — Verify amount received ≥ expected before confirming.** ✅ Gate on `status.complete` in `paymentHandlers.js`; underpaid → stay pending + `payment_underpaid` warning (once). `received_amount` now recorded for single-game + credits. Regression test added (`payment_flow.integration.test.js`). _113 tests green._ - [x] **0.2 🔴 M — Make provably-fair real.** ✅ Per-game seeded RNG (`seedToInt`) threaded through `dungeon.js` (Digger reseed + all `Math.random()` → `rng`), `monster.js` (`seededShuffle`), `game.js` (chase + generation). `DungeonGenerator.regenerateFromSeed` + `layoutFingerprint`; `/api/verify` regenerates & returns fingerprint. Determinism test suite added (`provablyFair.test.js`). _Verifier can now reproduce the exact dungeon from the seed._ - [x] **0.3 🔴 M — Fix batch-payout `tx_hash` collision + double-pay.** ✅ Migration 015 drops the payouts `tx_hash` unique index (batches legitimately share a tx_hash) and extends the one-per-game guard to cover `processing`. Batch completion now happens in ONE transaction via `id = ANY` (no row stranded mid-batch); BIGINT stat math (no float). Ambiguous batch failures → `needs_review` (retry service won't re-send) + operator alert. New `batchPayout.test.js` covers both paths. -- [ ] **0.4 🔴 S — Harden identity transport.** Move `anon_token` from query string to handshake `auth` (`src/network/sessionManager.js:24`); call `rotateToken` on resume. +- [x] **0.4 🔴 S — Harden identity transport.** ✅ Client sends `anon_token` in handshake `auth` (not the URL query → no proxy/referer/access-log leakage); server reads `auth` first with a query-string fallback for older clients. Token is now **rotated on every resume** (`rotateToken`), so a leaked token is invalidated when the real owner reconnects; client already persists the returned token. Rotation + old-token-replay-rejection test added to `security.test.js`. - [ ] **0.5 🟠 S — Single-game disconnect recovery.** Extend `recoverPendingPayments` (`src/network/sessionManager.js:158`) to handle `single_game`; stop silently deleting in-progress paid games (`src/network/socketHandlers.js:787`). - [ ] **0.6 🟠 S — Client + admin XSS and CSP.** Escape chat/admin fields (`html/js/network/socketHandlers.js:291,323`; `html/admin.html`); add CSP headers. diff --git a/html/index.html b/html/index.html index 77d0ee9..96d9d03 100644 --- a/html/index.html +++ b/html/index.html @@ -20,7 +20,11 @@ window._localStorageAvailable = true; const existing = localStorage.getItem('wownerogue_token'); if (existing) { - opts.query = { resumeToken: existing }; + // Send the session token in the handshake `auth` payload, NOT the URL + // query string. Query strings leak into proxy/access/referer logs; + // auth is carried in the connection body. (Server keeps a query-string + // fallback for older clients during rollout.) + opts.auth = { resumeToken: existing }; } } catch(_) { window._localStorageAvailable = false; diff --git a/src/network/connectionHandler.js b/src/network/connectionHandler.js index 81027ed..0d2a491 100644 --- a/src/network/connectionHandler.js +++ b/src/network/connectionHandler.js @@ -172,7 +172,11 @@ class ConnectionHandler { _getResumeToken(socket) { try { - return socket.handshake.query?.resumeToken || null; + // Prefer the handshake `auth` payload (not logged by proxies); fall back to the + // query string for backward compatibility with older clients during rollout. + return socket.handshake.auth?.resumeToken + || socket.handshake.query?.resumeToken + || null; } catch (_) { return null; } diff --git a/src/network/sessionManager.js b/src/network/sessionManager.js index 19c4b58..647cb9c 100644 --- a/src/network/sessionManager.js +++ b/src/network/sessionManager.js @@ -53,10 +53,24 @@ class SessionManager { } } + // SECURITY: Rotate the token on every resume. The session token is a bearer + // credential — rotating it limits the window in which a leaked/observed token is + // usable (a leaked token is invalidated the next time the real owner reconnects). + // The client persists the returned token from the `session_resumed` event. + let issuedToken = resumeToken; + try { + issuedToken = await this.rotateToken(user.id); + user.anon_token = issuedToken; + this.sessions.set(socketId, user); + } catch (rotErr) { + // If rotation fails, keep the existing token rather than locking the user out. + console.error('[SessionManager] Token rotation failed, keeping existing token:', rotErr.message); + } + if (this.debugManager.CONSOLE_LOGGING) console.log(`[SessionManager] Resumed session for user ${user.id}`); return { resumed: true, - token: resumeToken, + token: issuedToken, user: { ...user, socket_id: socketId diff --git a/test/security.test.js b/test/security.test.js index d5ddf68..633a736 100644 --- a/test/security.test.js +++ b/test/security.test.js @@ -71,6 +71,9 @@ function createMockDb() { const addrIdx = params.findIndex((_, i) => text.includes(`payout_address = $${i + 1}`)); if (addrIdx >= 0) user.payout_address = params[addrIdx]; } + if (text.includes('anon_token =')) { + user.anon_token = params[0]; + } } return { rows: user ? [user] : [] }; } @@ -239,6 +242,36 @@ describe('SQL Injection Prevention', () => { } }); + test('should rotate the session token on resume (bearer-token replay protection)', async () => { + // Create a session to obtain a real token. + const created = await sessionManager.resumeOrCreate({ + socketId: 'rotate-socket-1', + ipAddress: '127.0.0.1', + resumeToken: null + }); + expect(created.resumed).toBe(false); + const firstToken = created.token; + expect(firstToken).toBeTruthy(); + + // Resume with that token — should succeed and hand back a DIFFERENT token. + const resumed = await sessionManager.resumeOrCreate({ + socketId: 'rotate-socket-2', + ipAddress: '127.0.0.1', + resumeToken: firstToken + }); + expect(resumed.resumed).toBe(true); + expect(resumed.token).toBeTruthy(); + expect(resumed.token).not.toBe(firstToken); // rotated + + // The OLD token must no longer resume (it was invalidated). + const replay = await sessionManager.resumeOrCreate({ + socketId: 'rotate-socket-3', + ipAddress: '127.0.0.1', + resumeToken: firstToken + }); + expect(replay.resumed).toBe(false); + }); + test('should safely handle malicious socket ID', async () => { const maliciousSocketId = "'; DROP TABLE users; --";