From a74905ff1ed7a7309a899eddd612fab56246ea02 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 2 Aug 2026 17:13:44 -0400 Subject: [PATCH] fix(auth): align WebSocket token_version checks with HTTP (#1744) * test(auth): cover legacy no-tv JWT rejection on WebSocket upgrades * fix(auth): treat missing tv claim as token_version 1 on WebSocket upgrades --- backend/src/__tests__/exec.test.ts | 29 +++++++++++++++++++ backend/src/__tests__/host-console-ws.test.ts | 15 ++++++++++ backend/src/websocket/generic.ts | 4 ++- backend/src/websocket/upgradeHandler.ts | 4 ++- 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/backend/src/__tests__/exec.test.ts b/backend/src/__tests__/exec.test.ts index 633e0af5..497c1373 100644 --- a/backend/src/__tests__/exec.test.ts +++ b/backend/src/__tests__/exec.test.ts @@ -351,6 +351,35 @@ describe('WebSocket upgrade - exec auth enforcement', () => { }); } + it('rejects legacy no-tv admin JWT after token_version bump (401)', async () => { + // A legacy token without a tv claim is treated as version 1; once the + // account version is bumped it must be rejected on /ws like on HTTP. + const { DatabaseService } = await import('../services/DatabaseService'); + const bcrypt = await import('bcrypt'); + const db = DatabaseService.getInstance(); + const username = `legacy-tv-admin-${Date.now()}`; + const id = db.addUser({ + username, + password_hash: await bcrypt.hash('password123', 1), + role: 'admin', + }); + db.bumpTokenVersion(id); + + const legacyNoTv = jwt.sign( + { username, role: 'admin' }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + const ws = new WebSocket(getWsUrl(), { headers: { Cookie: `sencho_token=${legacyNoTv}` } }); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + // A regression (upgrade accepted) must fail fast with 200, not hang. + ws.on('open', () => { ws.close(); resolve(200); }); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(401); + }); + it('rejects WebSocket upgrade with node_proxy token (403)', async () => { const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); diff --git a/backend/src/__tests__/host-console-ws.test.ts b/backend/src/__tests__/host-console-ws.test.ts index 0c7db4d1..8e0cede2 100644 --- a/backend/src/__tests__/host-console-ws.test.ts +++ b/backend/src/__tests__/host-console-ws.test.ts @@ -85,6 +85,21 @@ describe('WebSocket upgrade - host console auth enforcement', () => { }); } + it('rejects a legacy no-tv admin JWT after token_version bump (401)', async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + const db = DatabaseService.getInstance(); + const username = `hc-legacy-tv-${Date.now()}`; + const id = db.addUser({ + username, + password_hash: await bcrypt.hash('password123', 1), + role: 'admin', + }); + db.bumpTokenVersion(id); + const legacyNoTv = jwt.sign({ username, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${legacyNoTv}` } }); + expect(await expectRejected(ws)).toBe(401); + }); + it('accepts a Community-tier admin', async () => { getTierSpy.mockReturnValueOnce('community'); const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${adminToken()}` } }); diff --git a/backend/src/websocket/generic.ts b/backend/src/websocket/generic.ts index 29c339cd..73466311 100644 --- a/backend/src/websocket/generic.ts +++ b/backend/src/websocket/generic.ts @@ -85,7 +85,9 @@ export function handleGenericWs( console.warn('[Exec] User account not found:', decoded.username); return reject(socket, 401, 'Unauthorized'); } - if (decoded.tv !== undefined && execUser.token_version !== decoded.tv) { + // Missing `tv` is a pre-migration legacy token at version 1, same default + // as `authMiddleware`; a bumped account version must reject it. + if (execUser.token_version !== (decoded.tv ?? 1)) { console.warn('[Exec] Session invalidated (token version mismatch):', decoded.username); return reject(socket, 401, 'Unauthorized'); } diff --git a/backend/src/websocket/upgradeHandler.ts b/backend/src/websocket/upgradeHandler.ts index a10dd489..b8f5ae3c 100644 --- a/backend/src/websocket/upgradeHandler.ts +++ b/backend/src/websocket/upgradeHandler.ts @@ -201,7 +201,9 @@ export function attachUpgrade( if (!decoded.scope && decoded.username) { const dbUser = DatabaseService.getInstance().getUserByUsername(decoded.username); if (!dbUser) return reject(socket, 401, 'Unauthorized'); - if (decoded.tv !== undefined && dbUser.token_version !== decoded.tv) { + // Missing `tv` is a pre-migration legacy token at version 1, same default + // as `authMiddleware`; a bumped account version must reject it. + if (dbUser.token_version !== (decoded.tv ?? 1)) { console.log('[Auth] WS session rejected: token version mismatch for:', decoded.username); return reject(socket, 401, 'Unauthorized'); }