mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +02:00
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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; --";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user