CVE-2024-39699 (published 2024-07-08T16:15Z)

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.

CVSS v3 base score: 5.0, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N)

Weakness information:
cwe_id: CWE-918
cwe_name: Server-Side Request Forgery (SSRF)
description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
extended_description: By providing URLs to unexpected hosts or ports, attackers can make it appear that the server is sending the request, possibly bypassing access controls such as firewalls that prevent the attackers from accessing the URLs directly. The server can be used as a proxy to conduct port scanning of hosts in internal networks, use other URLs such as that can access documents on the system (using file://), or use other protocols such as gopher:// or tftp://, which may provide greater control over the contents of requests.
         

Fix information:
repo_url: https://github.com/directus/directus
commit_details: repo_url: https://github.com/directus/directus
author: Daniel Biegler
author_date: 2024-02-19 23:19:55+01:00
msg: Add validation for IP Access in Role settings (#21444)
repository: repo_url: https://github.com/directus/directus
repo_name: directus/directus
description: The Modern Data Stack 🐰 — Directus is an instant REST+GraphQL API and intuitive no-code data collaboration app for any SQL database.
repo_language: TypeScript
owner: directus
file_changes: filename: dull-comics-camp.md
change_type: ModificationType.ADD
diff: @@ -0,0 +1,5 @@
+---
+'@directus/api': patch
+---
+
+Added validation for IP Access in role settings

programming_language: SQL
filename: roles.test.ts
change_type: ModificationType.MODIFY
diff: @@ -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,6 +697,12 @@ 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', () => {
@@ -704,6 +710,12 @@ describe('Integration Tests', () => {
 				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', () => {

programming_language: TypeScript
filename: roles.ts
change_type: ModificationType.MODIFY
diff: @@ -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;

programming_language: TypeScript