security: move session token to handshake auth and rotate on resume

The anonymous session token is a bearer credential that was sent in the
Socket.IO handshake query string, where it leaks into proxy/access/referer
logs and is easily observed. Anyone holding it becomes that user (credits,
payout address, pending payments).

- Client now sends the token in the handshake `auth` payload instead of the
  query string. Server reads `auth` first and falls back to the query string
  for backward compatibility during rollout.
- The token is rotated on every successful resume, so a leaked/observed
  token is invalidated the next time the legitimate owner reconnects. The
  client already persists the token returned in `session_resumed`.

Adds a security test asserting resume returns a new token and the old token
no longer resumes. Full suite: 12 suites / 116 tests.
This commit is contained in:
jwinterm
2026-05-31 09:29:20 -04:00
parent d07967c3d1
commit 223aa75871
5 changed files with 59 additions and 4 deletions
+1 -1
View File
@@ -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.
+5 -1
View File
@@ -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;
+5 -1
View File
@@ -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;
}
+15 -1
View File
@@ -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
+33
View File
@@ -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; --";