Directus has a Blind SSRF On File Import
Description
Directus is a real-time API and App dashboard for managing SQL database content. There was already a reported SSRF vulnerability via file import. It was fixed by resolving all DNS names and checking if the requested IP is an internal IP address. However it is possible to bypass this security measure and execute a SSRF using redirects. Directus allows redirects when importing file from the URL and does not check the result URL. Thus, it is possible to execute a request to an internal IP, for example to 127.0.0.1. However, it is blind SSRF, because Directus also uses response interception technique to get the information about the connect from the socket directly and it does not show a response if the IP address is internal. This vulnerability is fixed in 10.9.3.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@directus/apinpm | < 17.1.0 | 17.1.0 |
Affected products
1Patches
2fd878f5dea48d577b44231c0Add validation for IP Access in Role settings (#21444)
3 files changed · +108 −12
api/src/services/roles.test.ts+38 −2 modified@@ -2,7 +2,7 @@ import { ForbiddenError, UnprocessableContentError } from '@directus/errors'; import type { SchemaOverview } from '@directus/types'; import type { Knex } from 'knex'; import knex from 'knex'; -import { createTracker, MockClient, Tracker, type RawQuery } from 'knex-mock-client'; +import { MockClient, Tracker, createTracker, type RawQuery } from 'knex-mock-client'; import { afterEach, beforeAll, @@ -11,8 +11,8 @@ import { expect, it, vi, - type MockedFunction, type MockInstance, + type MockedFunction, } from 'vitest'; import { ItemsService, PermissionsService, PresetsService, RolesService, UsersService } from './index.js'; @@ -697,13 +697,25 @@ describe('Integration Tests', () => { await service.createOne({}); expect(checkForOtherAdminRolesSpy).not.toBeCalled(); }); + + it('should throw due to invalid ip_access', async () => { + await expect(service.createOne({ ip_access: ['invalid_ip'] })).rejects.toThrow( + 'IP Access contains an incorrect value. Valid values are: IP addresses, IP ranges and CIDR blocks', + ); + }); }); describe('createMany', () => { it('should not checkForOtherAdminRoles', async () => { await service.createMany([{}]); expect(checkForOtherAdminRolesSpy).not.toBeCalled(); }); + + it('should throw due to invalid ip_access', async () => { + await expect(service.createMany([{ ip_access: ['invalid_ip'] }])).rejects.toThrow( + 'IP Access contains an incorrect value. Valid values are: IP addresses, IP ranges and CIDR blocks', + ); + }); }); describe('updateOne', () => { @@ -723,6 +735,12 @@ describe('Integration Tests', () => { expect(checkForOtherAdminRolesSpy).toBeCalledTimes(1); expect(checkForOtherAdminUsersSpy).toBeCalledTimes(1); }); + + it('should throw due to invalid ip_access', async () => { + await expect(service.updateOne(1, { ip_access: ['invalid_ip'] })).rejects.toThrow( + 'IP Access contains an incorrect value. Valid values are: IP addresses, IP ranges and CIDR blocks', + ); + }); }); describe('updateMany', () => { @@ -735,6 +753,12 @@ describe('Integration Tests', () => { await service.updateMany([1], { admin_access: false }); expect(checkForOtherAdminRolesSpy).toBeCalledTimes(1); }); + + it('should throw due to invalid ip_access', async () => { + await expect(service.updateMany([1], { ip_access: ['invalid_ip'] })).rejects.toThrow( + 'IP Access contains an incorrect value. Valid values are: IP addresses, IP ranges and CIDR blocks', + ); + }); }); describe('updateBatch', () => { @@ -747,6 +771,12 @@ describe('Integration Tests', () => { await service.updateBatch([{ id: 1, admin_access: false }]); expect(checkForOtherAdminRolesSpy).toBeCalledTimes(1); }); + + it('should throw due to invalid ip_access', async () => { + await expect(service.updateBatch([{ id: 1, ip_access: ['invalid_ip'] }])).rejects.toThrow( + 'IP Access contains an incorrect value. Valid values are: IP addresses, IP ranges and CIDR blocks', + ); + }); }); describe('updateByQuery', () => { @@ -763,6 +793,12 @@ describe('Integration Tests', () => { await service.updateByQuery({}, { admin_access: false }); expect(checkForOtherAdminRolesSpy).toBeCalledTimes(1); }); + + it('should throw due to invalid ip_access', async () => { + await expect(service.updateByQuery({}, { ip_access: ['invalid_ip'] })).rejects.toThrow( + 'IP Access contains an incorrect value. Valid values are: IP addresses, IP ranges and CIDR blocks', + ); + }); }); describe('deleteOne', () => {
api/src/services/roles.ts+65 −10 modified@@ -1,6 +1,7 @@ -import { ForbiddenError, UnprocessableContentError } from '@directus/errors'; +import { ForbiddenError, InvalidPayloadError, UnprocessableContentError } from '@directus/errors'; import type { Query, User } from '@directus/types'; -import type { AbstractServiceOptions, Alterations, MutationOptions, PrimaryKey } from '../types/index.js'; +import { getMatch } from 'ip-matching'; +import type { AbstractServiceOptions, Alterations, Item, MutationOptions, PrimaryKey } from '../types/index.js'; import { ItemsService } from './items.js'; import { PermissionsService } from './permissions.js'; import { PresetsService } from './presets.js'; @@ -149,7 +150,50 @@ export class RolesService extends ItemsService { return; } - override async updateOne(key: PrimaryKey, data: Record<string, any>, opts?: MutationOptions): Promise<PrimaryKey> { + private isIpAccessValid(value?: any[] | null): boolean { + if (value === undefined) return false; + if (value === null) return true; + if (Array.isArray(value) && value.length === 0) return true; + + for (const ip of value) { + if (typeof ip !== 'string' || ip.includes('*')) return false; + + try { + const match = getMatch(ip); + if (match.type == 'IPMask') return false; + } catch { + return false; + } + } + + return true; + } + + private assertValidIpAccess(partialItem: Partial<Item>): void { + if ('ip_access' in partialItem && !this.isIpAccessValid(partialItem['ip_access'])) { + throw new InvalidPayloadError({ + reason: 'IP Access contains an incorrect value. Valid values are: IP addresses, IP ranges and CIDR blocks', + }); + } + } + + override async createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> { + this.assertValidIpAccess(data); + + return super.createOne(data, opts); + } + + override async createMany(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]> { + for (const partialItem of data) { + this.assertValidIpAccess(partialItem); + } + + return super.createMany(data, opts); + } + + override async updateOne(key: PrimaryKey, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> { + this.assertValidIpAccess(data); + try { if ('users' in data) { await this.checkForOtherAdminUsers(key, data['users']); @@ -161,9 +205,12 @@ export class RolesService extends ItemsService { return super.updateOne(key, data, opts); } - override async updateBatch(data: Record<string, any>[], opts?: MutationOptions): Promise<PrimaryKey[]> { - const primaryKeyField = this.schema.collections[this.collection]!.primary; + override async updateBatch(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]> { + for (const partialItem of data) { + this.assertValidIpAccess(partialItem); + } + const primaryKeyField = this.schema.collections[this.collection]!.primary; const keys = data.map((item) => item[primaryKeyField]); const setsToNoAdmin = data.some((item) => item['admin_access'] === false); @@ -178,11 +225,9 @@ export class RolesService extends ItemsService { return super.updateBatch(data, opts); } - override async updateMany( - keys: PrimaryKey[], - data: Record<string, any>, - opts?: MutationOptions, - ): Promise<PrimaryKey[]> { + override async updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]> { + this.assertValidIpAccess(data); + try { if ('admin_access' in data && data['admin_access'] === false) { await this.checkForOtherAdminRoles(keys); @@ -194,6 +239,16 @@ export class RolesService extends ItemsService { return super.updateMany(keys, data, opts); } + override async updateByQuery( + query: Query, + data: Partial<Item>, + opts?: MutationOptions | undefined, + ): Promise<PrimaryKey[]> { + this.assertValidIpAccess(data); + + return super.updateByQuery(query, data, opts); + } + override async deleteOne(key: PrimaryKey): Promise<PrimaryKey> { await this.deleteMany([key]); return key;
.changeset/dull-comics-camp.md+5 −0 added@@ -0,0 +1,5 @@ +--- +'@directus/api': patch +--- + +Added validation for IP Access in role settings
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-8p72-rcq4-h6pwghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-39699ghsaADVISORY
- github.com/directus/directus/commit/d577b44231c0923aca99cac5770fd853801caee1ghsax_refsource_MISCWEB
- github.com/directus/directus/security/advisories/GHSA-8p72-rcq4-h6pwghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.