CVE-2024-34708: Insufficient Information (NVD-CWE-noinfo)
value: 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.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-05-14T15:39Z
Repository: https://github.com/directus/directus
Language: TypeScript
Files changed: .changeset/purple-shirts-care.md, api/src/database/run-ast.ts, api/src/services/payload.test.ts, api/src/services/payload.ts
Commit date: 2024-05-02 14:54:16 +0200
Commit message: Improved values redacting (#22332)

Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
Diff: diff --git a/.changeset/purple-shirts-care.md b/.changeset/purple-shirts-care.md
new file mode 100644
index 0000000000..250f5e6728
--- /dev/null
+++ b/.changeset/purple-shirts-care.md
@@ -0,0 +1,5 @@
+---
+"@directus/api": patch
+---
+
+Improved redacting of sensitive values
diff --git a/api/src/database/run-ast.ts b/api/src/database/run-ast.ts
index 25d97d8504..9e1aaba76c 100644
--- a/api/src/database/run-ast.ts
+++ b/api/src/database/run-ast.ts
@@ -82,11 +82,11 @@ export default async function runAST(
 
 		if (!rawItems) return null;
 
 		// 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;
 
 		// Apply the `_in` filters to the nested collection batches
 		const nestedNodes = applyParentFilters(schema, nestedCollectionNodes, items);
diff --git a/api/src/services/payload.test.ts b/api/src/services/payload.test.ts
index 25b5de66d4..51e7905c6f 100644
--- a/api/src/services/payload.test.ts
+++ b/api/src/services/payload.test.ts
@@ -1,10 +1,10 @@
 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';
 
 vi.mock('../../src/database/index', () => ({
@@ -37,11 +37,11 @@ describe('Integration Tests', () => {
 
 				helpers = getHelpers(db);
 			});
 
 			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',
 						payload: {},
 						accountability: { role: null },
@@ -50,11 +50,11 @@ 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',
 						payload: {},
 						accountability: { role: null },
@@ -63,11 +63,11 @@ 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',
 						payload: {},
 						accountability: { role: null },
@@ -76,11 +76,11 @@ 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',
 						payload: {},
 						accountability: { role: null },
@@ -89,11 +89,11 @@ 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',
 						payload: {},
 						accountability: { role: null },
@@ -102,11 +102,11 @@ 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',
 						payload: {},
 						accountability: { role: null },
@@ -188,11 +188,11 @@ describe('Integration Tests', () => {
 					},
 				});
 			});
 
 			describe('processes dates', () => {
-				it('with zero values', () => {
+				test('with zero values', () => {
 					const result = service.processDates(
 						[
 							{
 								[dateFieldId]: '0000-00-00',
 								[dateTimeFieldId]: '0000-00-00 00:00:00',
@@ -209,11 +209,11 @@ describe('Integration Tests', () => {
 							[timestampFieldId]: null,
 						},
 					]);
 				});
 
-				it('with typical values', () => {
+				test('with typical values', () => {
 					const result = service.processDates(
 						[
 							{
 								[dateFieldId]: '2022-01-10',
 								[dateTimeFieldId]: '2021-09-31 12:34:56',
@@ -230,11 +230,11 @@ describe('Integration Tests', () => {
 							[timestampFieldId]: new Date('1980-12-08 00:11:22.333').toISOString(),
 						},
 					]);
 				});
 
-				it('with date object values', () => {
+				test('with date object values', () => {
 					const result = service.processDates(
 						[
 							{
 								[dateFieldId]: new Date(1666777777000),
 								[dateTimeFieldId]: new Date(1666666666000),
@@ -252,10 +252,89 @@ 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 });
+			});
+		});
 	});
 });
 
 function toLocalISOString(date: Date) {
 	const year = String(date.getFullYear());
diff --git a/api/src/services/payload.ts b/api/src/services/payload.ts
index a1fe061690..717d1f7818 100644
--- a/api/src/services/payload.ts
+++ b/api/src/services/payload.ts
@@ -1,7 +1,15 @@
 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';
 import Joi from 'joi';
 import type { Knex } from 'knex';
@@ -139,34 +147,49 @@ 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);
 			});
 		}
 
 		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;
 					}),
 				);
 			}),

Vulnerable code: 		let items: null | Item | Item[] = await payloadService.processValues('read', rawItems);
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
				it('Returns undefined for illegal values', async () => {
				it('Returns [] for empty strings', async () => {
				it('Returns array values as is', async () => {
				it('Splits the CSV string', async () => {
				it('Saves array values as joined string', async () => {
				it('Saves string values as is', async () => {
				it('with zero values', () => {
				it('with typical values', () => {
				it('with date object values', () => {
import type { Accountability, Alterations, Item, PrimaryKey, Query, SchemaOverview } from '@directus/types';
		let specialFieldsInCollection = Object.entries(this.schema.collections[this.collection]!.fields).filter(
			([_name, field]) => field.special && field.special.length > 0,
		);
			specialFieldsInCollection = specialFieldsInCollection.filter(([name]) => {
					specialFieldsInCollection.map(async ([name, field]) => {
Fixed code: ---
"@directus/api": patch
---

Improved redacting of sensitive values
		let items: null | Item | Item[] = await payloadService.processValues('read', rawItems, query.alias ?? {});
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
				test('Returns undefined for illegal values', async () => {
				test('Returns [] for empty strings', async () => {
				test('Returns array values as is', async () => {
				test('Splits the CSV string', async () => {
				test('Saves array values as joined string', async () => {
				test('Saves string values as is', async () => {
				test('with zero values', () => {
				test('with typical values', () => {
				test('with date object values', () => {

		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 });
			});
		});
import type {
	Accountability,
	Alterations,
	Item,
	PrimaryKey,
	FieldOverview,
	Query,
	SchemaOverview,
} from '@directus/types';
	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>>;
		aliasMap: Record<string, string> = {},
		const fieldEntries = Object.entries(this.schema.collections[this.collection]!.fields);
		const aliasEntries = Object.entries(aliasMap);
		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 }]);
					}
				}
			}
		}
			specialFields = specialFields.filter(([name]) => {
					specialFields.map(async ([name, field]) => {