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
This commit is contained in:
Anso
2026-08-02 17:13:44 -04:00
committed by GitHub
parent ba017ee665
commit a74905ff1e
4 changed files with 50 additions and 2 deletions
+29
View File
@@ -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<number>((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}` } });
@@ -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()}` } });
+3 -1
View File
@@ -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');
}
+3 -1
View File
@@ -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');
}