CVE-2024-34708 (published 2024-05-14T15:39Z)

Directus is a real-time API and App dashboard for managing SQL database content. A user with permission to view any collection using redacted hashed fields can get access the raw stored version using the `alias` functionality on the API. Normally, these redacted fields will return `**********` however  if we change the request to `?alias[workaround]=redacted` we can instead retrieve the plain text value for the field. This can be avoided by removing permission to view the sensitive fields entirely from users or roles that should not be able to see them. This vulnerability is fixed in 10.11.0.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/directus/directus
commit_details: repo_url: https://github.com/directus/directus
author: Brainslug
author_date: 2024-05-02 14:54:16+02:00
msg: Improved values redacting (#22332)

Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
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: purple-shirts-care.md
change_type: ModificationType.ADD
diff: @@ -0,0 +1,5 @@
+---
+"@directus/api": patch
+---
+
+Improved redacting of sensitive values

programming_language: Markdown
filename: run-ast.ts
change_type: ModificationType.MODIFY
diff: @@ -84,7 +84,7 @@ export default async function runAST(
 
 		// Run the items through the special transforms
 		const payloadService = new PayloadService(collection, { knex, schema });
-		let items: null | Item | Item[] = await payloadService.processValues('read', rawItems);
+		let items: null | Item | Item[] = await payloadService.processValues('read', rawItems, query.alias ?? {});
 
 		if (!items || (Array.isArray(items) && items.length === 0)) return items;
 

programming_language: TypeScript
filename: payload.test.ts
change_type: ModificationType.MODIFY
diff: @@ -2,7 +2,7 @@ import type { Knex } from 'knex';
 import knex from 'knex';
 import { MockClient, Tracker, createTracker } from 'knex-mock-client';
 import type { MockedFunction } from 'vitest';
-import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
 import type { Helpers } from '../database/helpers/index.js';
 import { getHelpers } from '../database/helpers/index.js';
 import { PayloadService } from './index.js';
@@ -39,7 +39,7 @@ describe('Integration Tests', () => {
 			});
 
 			describe('csv', () => {
-				it('Returns undefined for illegal values', async () => {
+				test('Returns undefined for illegal values', async () => {
 					const result = await service.transformers['cast-csv']!({
 						value: 123,
 						action: 'read',
@@ -52,7 +52,7 @@ describe('Integration Tests', () => {
 					expect(result).toBe(undefined);
 				});
 
-				it('Returns [] for empty strings', async () => {
+				test('Returns [] for empty strings', async () => {
 					const result = await service.transformers['cast-csv']!({
 						value: '',
 						action: 'read',
@@ -65,7 +65,7 @@ describe('Integration Tests', () => {
 					expect(result).toMatchObject([]);
 				});
 
-				it('Returns array values as is', async () => {
+				test('Returns array values as is', async () => {
 					const result = await service.transformers['cast-csv']!({
 						value: ['test', 'directus'],
 						action: 'read',
@@ -78,7 +78,7 @@ describe('Integration Tests', () => {
 					expect(result).toEqual(['test', 'directus']);
 				});
 
-				it('Splits the CSV string', async () => {
+				test('Splits the CSV string', async () => {
 					const result = await service.transformers['cast-csv']!({
 						value: 'test,directus',
 						action: 'read',
@@ -91,7 +91,7 @@ describe('Integration Tests', () => {
 					expect(result).toMatchObject(['test', 'directus']);
 				});
 
-				it('Saves array values as joined string', async () => {
+				test('Saves array values as joined string', async () => {
 					const result = await service.transformers['cast-csv']!({
 						value: ['test', 'directus'],
 						action: 'create',
@@ -104,7 +104,7 @@ describe('Integration Tests', () => {
 					expect(result).toBe('test,directus');
 				});
 
-				it('Saves string values as is', async () => {
+				test('Saves string values as is', async () => {
 					const result = await service.transformers['cast-csv']!({
 						value: 'test,directus',
 						action: 'create',
@@ -190,7 +190,7 @@ describe('Integration Tests', () => {
 			});
 
 			describe('processes dates', () => {
-				it('with zero values', () => {
+				test('with zero values', () => {
 					const result = service.processDates(
 						[
 							{
@@ -211,7 +211,7 @@ describe('Integration Tests', () => {
 					]);
 				});
 
-				it('with typical values', () => {
+				test('with typical values', () => {
 					const result = service.processDates(
 						[
 							{
@@ -232,7 +232,7 @@ describe('Integration Tests', () => {
 					]);
 				});
 
-				it('with date object values', () => {
+				test('with date object values', () => {
 					const result = service.processDates(
 						[
 							{
@@ -254,6 +254,85 @@ describe('Integration Tests', () => {
 				});
 			});
 		});
+
+		describe('processValues', () => {
+			let service: PayloadService;
+
+			const concealedField = 'hidden';
+			const stringField = 'string';
+			const REDACT_STR = '**********';
+
+			beforeEach(() => {
+				service = new PayloadService('test', {
+					knex: db,
+					schema: {
+						collections: {
+							test: {
+								collection: 'test',
+								primary: 'id',
+								singleton: false,
+								sortField: null,
+								note: null,
+								accountability: null,
+								fields: {
+									[concealedField]: {
+										field: concealedField,
+										defaultValue: null,
+										nullable: true,
+										generated: false,
+										type: 'hash',
+										dbType: 'nvarchar',
+										precision: null,
+										scale: null,
+										special: ['hash', 'conceal'],
+										note: null,
+										validation: null,
+										alias: false,
+									},
+									[stringField]: {
+										field: stringField,
+										defaultValue: null,
+										nullable: true,
+										generated: false,
+										type: 'string',
+										dbType: 'nvarchar',
+										precision: null,
+										scale: null,
+										special: [],
+										note: null,
+										validation: null,
+										alias: false,
+									},
+								},
+							},
+						},
+						relations: [],
+					},
+				});
+			});
+
+			test('processing special fields', async () => {
+				const result = await service.processValues('read', {
+					string: 'not-redacted',
+					hidden: 'secret',
+				});
+
+				expect(result).toMatchObject({ string: 'not-redacted', hidden: REDACT_STR });
+			});
+
+			test('processing aliassed special fields', async () => {
+				const result = await service.processValues(
+					'read',
+					{
+						other_string: 'not-redacted',
+						other_hidden: 'secret',
+					},
+					{ other_string: 'string', other_hidden: 'hidden' },
+				);
+
+				expect(result).toMatchObject({ other_string: 'not-redacted', other_hidden: REDACT_STR });
+			});
+		});
 	});
 });
 

programming_language: TypeScript
filename: payload.ts
change_type: ModificationType.MODIFY
diff: @@ -1,5 +1,13 @@
 import { ForbiddenError, InvalidPayloadError } from '@directus/errors';
-import type { Accountability, Alterations, Item, PrimaryKey, Query, SchemaOverview } from '@directus/types';
+import type {
+	Accountability,
+	Alterations,
+	Item,
+	PrimaryKey,
+	FieldOverview,
+	Query,
+	SchemaOverview,
+} from '@directus/types';
 import { parseJSON, toArray } from '@directus/utils';
 import { format, isValid, parseISO } from 'date-fns';
 import { unflatten } from 'flat';
@@ -141,22 +149,37 @@ export class PayloadService {
 
 	processValues(action: Action, payloads: Partial<Item>[]): Promise<Partial<Item>[]>;
 	processValues(action: Action, payload: Partial<Item>): Promise<Partial<Item>>;
+	processValues(action: Action, payloads: Partial<Item>[], aliasMap: Record<string, string>): Promise<Partial<Item>[]>;
+	processValues(action: Action, payload: Partial<Item>, aliasMap: Record<string, string>): Promise<Partial<Item>>;
 	async processValues(
 		action: Action,
 		payload: Partial<Item> | Partial<Item>[],
+		aliasMap: Record<string, string> = {},
 	): Promise<Partial<Item> | Partial<Item>[]> {
 		const processedPayload = toArray(payload);
 
 		if (processedPayload.length === 0) return [];
 
 		const fieldsInPayload = Object.keys(processedPayload[0]!);
+		const fieldEntries = Object.entries(this.schema.collections[this.collection]!.fields);
+		const aliasEntries = Object.entries(aliasMap);
 
-		let specialFieldsInCollection = Object.entries(this.schema.collections[this.collection]!.fields).filter(
-			([_name, field]) => field.special && field.special.length > 0,
-		);
+		let specialFields: [string, FieldOverview][] = [];
+
+		for (const [name, field] of fieldEntries) {
+			if (field.special && field.special.length > 0) {
+				specialFields.push([name, field]);
+
+				for (const [aliasName, fieldName] of aliasEntries) {
+					if (fieldName === name) {
+						specialFields.push([aliasName, { ...field, field: aliasName }]);
+					}
+				}
+			}
+		}
 
 		if (action === 'read') {
-			specialFieldsInCollection = specialFieldsInCollection.filter(([name]) => {
+			specialFields = specialFields.filter(([name]) => {
 				return fieldsInPayload.includes(name);
 			});
 		}
@@ -164,7 +187,7 @@ export class PayloadService {
 		await Promise.all(
 			processedPayload.map(async (record: any) => {
 				await Promise.all(
-					specialFieldsInCollection.map(async ([name, field]) => {
+					specialFields.map(async ([name, field]) => {
 						const newValue = await this.processField(field, record, action, this.accountability);
 						if (newValue !== undefined) record[name] = newValue;
 					}),

programming_language: TypeScript