Suspended Directus user can continue to use session token to access API
Description
Directus is a real-time API and App dashboard for managing SQL database content. Starting in version 10.10.0 and prior to version 11.5.0, a suspended user can use the token generated in session auth mode to access the API despite their status. This happens because there is a check missing in verifySessionJWT to verify that a user is actually still active and allowed to access the API. One can extract the session token obtained by, e.g. login in to the app while still active and then, after the user has been suspended continue to use that token until it expires. Version 11.5.0 patches the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
directusnpm | >= 10.10.0, < 11.5.0 | 11.5.0 |
@directus/apinpm | >= 18.0.0, < 24.0.1 | 24.0.1 |
@directus/typesnpm | >= 11.0.7, < 13.0.0 | 13.0.0 |
Affected products
1Patches
1ef179931c55bMerge from fork (#24714)
4 files changed · +38 −0
api/src/services/users.test.ts+13 −0 modified@@ -89,6 +89,10 @@ describe('Integration Tests', () => { .spyOn(UsersService.prototype as any, 'checkPasswordPolicy') .mockResolvedValue(() => vi.fn()); + const clearUserSessionsSpy = vi + .spyOn(UsersService.prototype as any, 'clearUserSessions') + .mockResolvedValue(() => vi.fn()); + afterEach(() => { vi.clearAllMocks(); }); @@ -170,6 +174,7 @@ describe('Integration Tests', () => { await service.updateMany([randomUUID()], {}, opts); expect(opts.userIntegrityCheckFlags).toBe(undefined); + expect(clearUserSessionsSpy).not.toBeCalled(); }); it('should request all user integrity checks if role is changed', async () => { @@ -186,6 +191,7 @@ describe('Integration Tests', () => { await service.updateMany([randomUUID()], { status: 'inactive' }, opts); expect(opts.userIntegrityCheckFlags).toBe(UserIntegrityCheckFlag.All); + expect(clearUserSessionsSpy).toBeCalled(); }); it('should request user limit checks if status is changed to "active"', async () => { @@ -194,6 +200,7 @@ describe('Integration Tests', () => { await service.updateMany([randomUUID()], { status: 'active' }, opts); expect(opts.userIntegrityCheckFlags).toBe(UserIntegrityCheckFlag.UserLimits); + expect(clearUserSessionsSpy).not.toBeCalled(); }); it('should clear caches if role is changed', async () => { @@ -214,6 +221,7 @@ describe('Integration Tests', () => { await service.updateMany([randomUUID()], { email: 'test@example.com' }); expect(checkUniqueEmailsSpy).toBeCalledTimes(1); + expect(clearUserSessionsSpy).toBeCalled(); }); it('should disallow updating multiple items to same email', async () => { @@ -227,18 +235,22 @@ describe('Integration Tests', () => { field: 'email', }), ); + + expect(clearUserSessionsSpy).toBeCalled(); }); it('should not checkPasswordPolicy', async () => { await service.updateMany([randomUUID()], {}); expect(checkPasswordPolicySpy).not.toBeCalled(); + expect(clearUserSessionsSpy).not.toBeCalled(); }); it('should checkPasswordPolicy once', async () => { await service.updateMany([randomUUID()], { password: 'testpassword' }); expect(checkPasswordPolicySpy).toBeCalledTimes(1); + expect(clearUserSessionsSpy).toBeCalled(); }); describe('restricted auth fields', () => { @@ -305,6 +317,7 @@ describe('Integration Tests', () => { await service.deleteMany([randomUUID()]); expect(validateRemainingAdminUsers).toHaveBeenCalled(); + expect(clearUserSessionsSpy).toBeCalled(); }); });
api/src/services/users.ts+23 −0 modified@@ -107,6 +107,21 @@ export class UsersService extends ItemsService { } } + /** + * Clear users' sessions to log them out + */ + private async clearUserSessions(userKeys: PrimaryKey[], excludeSession?: string): Promise<void> { + if (excludeSession) { + await this.knex + .from('directus_sessions') + .whereIn('user', userKeys) + .andWhereNot('token', '=', excludeSession) + .delete(); + } else { + await this.knex.from('directus_sessions').whereIn('user', userKeys).delete(); + } + } + /** * Get basic information of user identified by email */ @@ -292,6 +307,12 @@ export class UsersService extends ItemsService { const result = await super.updateMany(keys, data, opts); + if (data['status'] !== undefined && data['status'] !== 'active') { + await this.clearUserSessions(keys); + } else if (data['password'] !== undefined || data['email'] !== undefined) { + await this.clearUserSessions(keys, this.accountability?.session); + } + // Only clear the caches if the role has been updated if ('role' in data) { await this.clearCaches(opts); @@ -320,6 +341,8 @@ export class UsersService extends ItemsService { await this.knex('directus_versions').update({ user_updated: null }).whereIn('user_updated', keys); await super.deleteMany(keys, opts); + await this.clearUserSessions(keys); + return keys; }
api/src/utils/get-accountability-for-token.ts+1 −0 modified@@ -26,6 +26,7 @@ export async function getAccountabilityForToken( if ('session' in payload) { await verifySessionJWT(payload); + accountability.session = payload.session; } if (payload.share) accountability.share = payload.share;
packages/types/src/accountability.ts+1 −0 modified@@ -13,4 +13,5 @@ export type Accountability = { ip: string | null; userAgent?: string; origin?: string; + session?: string; };
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-56p6-qw3c-fq2gghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-30351ghsaADVISORY
- github.com/directus/directus/commit/ef179931c55b50c110feca8404901d5633940771ghsax_refsource_MISCWEB
- github.com/directus/directus/security/advisories/GHSA-56p6-qw3c-fq2gghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.