From 89bedc100b38db64ea4f0474c7d08fdc31177a8e Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Thu, 2 Apr 2026 07:43:25 +0000 Subject: [PATCH 1/2] fix(security): invalidate user sessions on password change --- src/api/routers/users.ts | 7 ++++ src/db/repositories/usersRepository.ts | 15 ++++++++ tests/unit/api/routers/users.test.ts | 34 +++++++++++++++++++ .../db/repositories/usersRepository.test.ts | 31 +++++++++++++++++ 4 files changed, 87 insertions(+) diff --git a/src/api/routers/users.ts b/src/api/routers/users.ts index b81ab6af..cb26009c 100644 --- a/src/api/routers/users.ts +++ b/src/api/routers/users.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { createUser, deleteUser, + deleteUserSessions, getUserById, listOrgUsers, updateUser, @@ -122,6 +123,12 @@ export const usersRouter = router({ } await updateUser(input.id, updates); + + // Invalidate all sessions for the target user when their password changes. + // This prevents stale sessions from remaining valid after a password reset. + if (updates.passwordHash !== undefined) { + await deleteUserSessions(input.id); + } }), delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => { diff --git a/src/db/repositories/usersRepository.ts b/src/db/repositories/usersRepository.ts index a7cddeb7..f927ae8f 100644 --- a/src/db/repositories/usersRepository.ts +++ b/src/db/repositories/usersRepository.ts @@ -84,6 +84,21 @@ export async function deleteExpiredSessions(): Promise { await db.delete(sessions).where(lt(sessions.expiresAt, new Date())); } +/** + * Delete all sessions for a given user. Optionally exclude a specific token + * (e.g. to preserve the caller's own session when they change their own password). + */ +export async function deleteUserSessions(userId: string, excludeToken?: string): Promise { + const db = getDb(); + if (excludeToken !== undefined) { + await db + .delete(sessions) + .where(and(eq(sessions.userId, userId), ne(sessions.token, excludeToken))); + } else { + await db.delete(sessions).where(eq(sessions.userId, userId)); + } +} + // ============================================================================ // CRUD for users (org-scoped) // ============================================================================ diff --git a/tests/unit/api/routers/users.test.ts b/tests/unit/api/routers/users.test.ts index 11b6493a..5cd943e3 100644 --- a/tests/unit/api/routers/users.test.ts +++ b/tests/unit/api/routers/users.test.ts @@ -8,6 +8,7 @@ const { mockUpdateUser, mockDeleteUser, mockGetUserById, + mockDeleteUserSessions, mockBcryptHash, } = vi.hoisted(() => ({ mockListOrgUsers: vi.fn(), @@ -15,6 +16,7 @@ const { mockUpdateUser: vi.fn(), mockDeleteUser: vi.fn(), mockGetUserById: vi.fn(), + mockDeleteUserSessions: vi.fn(), mockBcryptHash: vi.fn(), })); @@ -24,6 +26,7 @@ vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({ updateUser: mockUpdateUser, deleteUser: mockDeleteUser, getUserById: mockGetUserById, + deleteUserSessions: mockDeleteUserSessions, })); vi.mock('bcrypt', () => ({ @@ -43,6 +46,7 @@ const mockMember = createMockUser({ id: 'member-1', role: 'member' }); describe('usersRouter', () => { beforeEach(() => { mockBcryptHash.mockResolvedValue('hashed-password'); + mockDeleteUserSessions.mockResolvedValue(undefined); }); describe('list', () => { @@ -406,6 +410,36 @@ describe('usersRouter', () => { code: 'FORBIDDEN', }); }); + + it('invalidates all sessions when password is changed', async () => { + mockGetUserById.mockResolvedValue({ id: 'user-2', orgId: 'org-1', role: 'member' }); + mockUpdateUser.mockResolvedValue(undefined); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await caller.update({ id: 'user-2', password: 'newpassword12' }); + + expect(mockDeleteUserSessions).toHaveBeenCalledWith('user-2'); + }); + + it('does not invalidate sessions when password is not changed', async () => { + mockGetUserById.mockResolvedValue({ id: 'user-2', orgId: 'org-1', role: 'member' }); + mockUpdateUser.mockResolvedValue(undefined); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await caller.update({ id: 'user-2', name: 'New Name' }); + + expect(mockDeleteUserSessions).not.toHaveBeenCalled(); + }); + + it('does not invalidate sessions when only role/email are changed', async () => { + mockGetUserById.mockResolvedValue({ id: 'user-2', orgId: 'org-1', role: 'member' }); + mockUpdateUser.mockResolvedValue(undefined); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await caller.update({ id: 'user-2', email: 'new@example.com' }); + + expect(mockDeleteUserSessions).not.toHaveBeenCalled(); + }); }); describe('delete', () => { diff --git a/tests/unit/db/repositories/usersRepository.test.ts b/tests/unit/db/repositories/usersRepository.test.ts index 61c37f97..ccb1b546 100644 --- a/tests/unit/db/repositories/usersRepository.test.ts +++ b/tests/unit/db/repositories/usersRepository.test.ts @@ -29,6 +29,7 @@ import { deleteExpiredSessions, deleteSession, deleteUser, + deleteUserSessions, getSessionByToken, getUserByEmail, getUserById, @@ -288,4 +289,34 @@ describe('usersRepository', () => { expect(mockDb.db.delete).toHaveBeenCalledTimes(1); }); }); + + describe('deleteUserSessions', () => { + it('deletes all sessions for a user', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await deleteUserSessions('user-1'); + + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + }); + + it('deletes all sessions when excludeToken is not provided', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await deleteUserSessions('user-1'); + + // Without excludeToken the where clause uses a single eq condition (no and/ne) + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + expect(mockDb.chain.where).toHaveBeenCalledTimes(1); + }); + + it('excludes a specific token when provided', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await deleteUserSessions('user-1', 'keep-this-token'); + + // With excludeToken the where clause uses an and(eq, ne) condition + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + expect(mockDb.chain.where).toHaveBeenCalledTimes(1); + }); + }); }); From a0dd5cdef6a9ae1a88017961f6845433dd1f3002 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Thu, 2 Apr 2026 07:51:55 +0000 Subject: [PATCH 2/2] fix(deps): add overrides to resolve high-severity npm audit vulnerabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add npm overrides for lodash@^4.18.1, lodash-es@^4.18.1, and brace-expansion@^2.0.3 to address high-severity CVEs in transitive dependencies (archiver → lodash, @llmist/cli → chevrotain → lodash-es). Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 16 +++++++++------- package.json | 5 +++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 48db4d53..474e631c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4724,7 +4724,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -7803,15 +7805,15 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lodash.camelcase": { diff --git a/package.json b/package.json index a22fdb1c..738e6835 100644 --- a/package.json +++ b/package.json @@ -130,5 +130,10 @@ }, "engines": { "node": ">=22.0.0" + }, + "overrides": { + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "brace-expansion": "^2.0.3" } }