VYPR
Moderate severityNVD Advisory· Published Mar 26, 2025· Updated Mar 27, 2025

Directus `search` query parameter allows enumeration of non permitted fields

CVE-2025-30352

Description

Directus is a real-time API and App dashboard for managing SQL database content. Starting in version 9.0.0-alpha.4 and prior to version 11.5.0, the search query parameter allows users with access to a collection to filter items based on fields they do not have permission to view. This allows the enumeration of unknown field contents. The searchable columns (numbers & strings) are not checked against permissions when injecting the where clauses for applying the search query. This leads to the possibility of enumerating those un-permitted fields. Version 11.5.0 fixes the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
directusnpm
>= 9.0.0-alpha.4, < 11.5.011.5.0

Affected products

1

Patches

1
ac5a9964d992

Merge from fork (#24715)

https://github.com/directus/directusBrainslugFeb 26, 2025via ghsa
7 files changed · +365 178
  • api/src/database/helpers/number/dialects/mssql.ts+4 3 modified
    @@ -1,17 +1,18 @@
    +import type { NumericValue } from '@directus/types';
     import type { Knex } from 'knex';
    +import { NumberDatabaseHelper, type NumberInfo } from '../types.js';
     import { maybeStringifyBigInt } from '../utils/maybe-stringify-big-int.js';
     import { numberInRange } from '../utils/number-in-range.js';
    -import { NumberDatabaseHelper, type NumberInfo } from '../types.js';
    -import type { NumericValue } from '@directus/types';
     
     export class NumberHelperMSSQL extends NumberDatabaseHelper {
     	override addSearchCondition(
     		dbQuery: Knex.QueryBuilder,
     		collection: string,
     		name: string,
     		value: NumericValue,
    +		logical: 'and' | 'or',
     	): Knex.QueryBuilder {
    -		return dbQuery.orWhere({ [`${collection}.${name}`]: maybeStringifyBigInt(value) });
    +		return dbQuery[logical].where({ [`${collection}.${name}`]: maybeStringifyBigInt(value) });
     	}
     
     	override isNumberValid(value: NumericValue, info: NumberInfo) {
    
  • api/src/database/helpers/number/dialects/oracle.ts+3 2 modified
    @@ -1,6 +1,6 @@
    +import type { NumericValue } from '@directus/types';
     import type { Knex } from 'knex';
     import { NumberDatabaseHelper } from '../types.js';
    -import type { NumericValue } from '@directus/types';
     import { maybeStringifyBigInt } from '../utils/maybe-stringify-big-int.js';
     
     export class NumberHelperOracle extends NumberDatabaseHelper {
    @@ -9,7 +9,8 @@ export class NumberHelperOracle extends NumberDatabaseHelper {
     		collection: string,
     		name: string,
     		value: NumericValue,
    +		logical: 'and' | 'or',
     	): Knex.QueryBuilder {
    -		return dbQuery.orWhere({ [`${collection}.${name}`]: maybeStringifyBigInt(value) });
    +		return dbQuery[logical].where({ [`${collection}.${name}`]: maybeStringifyBigInt(value) });
     	}
     }
    
  • api/src/database/helpers/number/dialects/sqlite.ts+3 2 modified
    @@ -1,6 +1,6 @@
    +import type { NumericValue } from '@directus/types';
     import type { Knex } from 'knex';
     import { NumberDatabaseHelper } from '../types.js';
    -import type { NumericValue } from '@directus/types';
     import { maybeStringifyBigInt } from '../utils/maybe-stringify-big-int.js';
     
     export class NumberHelperSQLite extends NumberDatabaseHelper {
    @@ -9,7 +9,8 @@ export class NumberHelperSQLite extends NumberDatabaseHelper {
     		collection: string,
     		name: string,
     		value: NumericValue,
    +		logical: 'and' | 'or',
     	): Knex.QueryBuilder {
    -		return dbQuery.orWhere({ [`${collection}.${name}`]: maybeStringifyBigInt(value) });
    +		return dbQuery[logical].where({ [`${collection}.${name}`]: maybeStringifyBigInt(value) });
     	}
     }
    
  • api/src/database/helpers/number/types.ts+3 2 modified
    @@ -1,6 +1,6 @@
    +import type { NumericType, NumericValue } from '@directus/types';
     import type { Knex } from 'knex';
     import { DatabaseHelper } from '../types.js';
    -import type { NumericType, NumericValue } from '@directus/types';
     
     export type NumberInfo = {
     	type: NumericType;
    @@ -14,8 +14,9 @@ export abstract class NumberDatabaseHelper extends DatabaseHelper {
     		collection: string,
     		name: string,
     		value: NumericValue,
    +		logical: 'and' | 'or',
     	): Knex.QueryBuilder {
    -		return dbQuery.orWhere({ [`${collection}.${name}`]: value });
    +		return dbQuery[logical].where({ [`${collection}.${name}`]: value });
     	}
     
     	isNumberValid(_value: NumericValue, _info: NumberInfo) {
    
  • api/src/services/meta.ts+33 119 modified
    @@ -1,12 +1,11 @@
    -import type { Accountability, Filter, Permission, Query, SchemaOverview } from '@directus/types';
    +import type { Accountability, Query, SchemaOverview } from '@directus/types';
     import type { Knex } from 'knex';
    +import { isArray } from 'lodash-es';
    +import { getAstFromQuery } from '../database/get-ast-from-query/get-ast-from-query.js';
     import getDatabase from '../database/index.js';
    -import { fetchPermissions } from '../permissions/lib/fetch-permissions.js';
    -import { fetchPolicies } from '../permissions/lib/fetch-policies.js';
    -import { dedupeAccess } from '../permissions/modules/process-ast/utils/dedupe-access.js';
    -import { validateAccess } from '../permissions/modules/validate-access/validate-access.js';
    +import { runAst } from '../database/run-ast/run-ast.js';
    +import { processAst } from '../permissions/modules/process-ast/process-ast.js';
     import type { AbstractServiceOptions } from '../types/index.js';
    -import { applyFilter, applySearch } from '../utils/apply-query.js';
     
     export class MetaService {
     	knex: Knex;
    @@ -39,124 +38,39 @@ export class MetaService {
     	}
     
     	async totalCount(collection: string): Promise<number> {
    -		const dbQuery = this.knex(collection);
    -
    -		let hasJoins = false;
    -
    -		if (this.accountability && this.accountability.admin === false) {
    -			const context = { knex: this.knex, schema: this.schema };
    -
    -			await validateAccess(
    -				{
    -					accountability: this.accountability,
    -					action: 'read',
    -					collection,
    -				},
    -				context,
    -			);
    -
    -			const policies = await fetchPolicies(this.accountability, context);
    -
    -			const permissions = await fetchPermissions(
    -				{
    -					action: 'read',
    -					policies,
    -					accountability: this.accountability,
    -				},
    -				context,
    -			);
    -
    -			const collectionPermissions = permissions.filter((permission) => permission.collection === collection);
    -
    -			const rules = dedupeAccess(collectionPermissions);
    -			const cases = rules.map(({ rule }) => rule);
    -
    -			const filter = {
    -				_or: cases,
    -			};
    -
    -			const result = applyFilter(this.knex, this.schema, dbQuery, filter, collection, {}, cases, permissions);
    -			hasJoins = result.hasJoins;
    -		}
    -
    -		if (hasJoins) {
    -			const primaryKeyName = this.schema.collections[collection]!.primary;
    -
    -			dbQuery.countDistinct({ count: [`${collection}.${primaryKeyName}`] });
    -		} else {
    -			dbQuery.count('*', { as: 'count' });
    -		}
    -
    -		const result = await dbQuery.first();
    -
    -		return Number(result?.count ?? 0);
    +		return this.filterCount(collection, {});
     	}
     
     	async filterCount(collection: string, query: Query): Promise<number> {
    -		const dbQuery = this.knex(collection);
    -
    -		let filter = query.filter || {};
    -		let hasJoins = false;
    -		let cases: Filter[] = [];
    -		let permissions: Permission[] = [];
    -
    -		if (this.accountability && this.accountability.admin === false) {
    -			const context = { knex: this.knex, schema: this.schema };
    -
    -			await validateAccess(
    -				{
    -					accountability: this.accountability,
    -					action: 'read',
    -					collection,
    -				},
    -				context,
    -			);
    -
    -			const policies = await fetchPolicies(this.accountability, context);
    -
    -			permissions = await fetchPermissions(
    -				{
    -					action: 'read',
    -					policies,
    -					accountability: this.accountability,
    -				},
    -				context,
    -			);
    -
    -			const collectionPermissions = permissions.filter((permission) => permission.collection === collection);
    -
    -			const rules = dedupeAccess(collectionPermissions);
    -			cases = rules.map(({ rule }) => rule);
    -
    -			const permissionsFilter = {
    -				_or: cases,
    -			};
    -
    -			if (Object.keys(filter).length > 0) {
    -				filter = { _and: [permissionsFilter, filter] };
    -			} else {
    -				filter = permissionsFilter;
    -			}
    -		}
    -
    -		if (Object.keys(filter).length > 0) {
    -			({ hasJoins } = applyFilter(this.knex, this.schema, dbQuery, filter, collection, {}, cases, permissions));
    -		}
    -
    -		if (query.search) {
    -			applySearch(this.knex, this.schema, dbQuery, query.search, collection);
    -		}
    -
    -		if (hasJoins) {
    -			const primaryKeyName = this.schema.collections[collection]!.primary;
    +		const aggregateQuery: Query = {
    +			aggregate: {
    +				count: ['*'],
    +			},
    +			search: query.search ?? null,
    +			filter: query.filter ?? null,
    +		};
    +
    +		let ast = await getAstFromQuery(
    +			{
    +				collection,
    +				query: aggregateQuery,
    +				accountability: this.accountability,
    +			},
    +			{
    +				schema: this.schema,
    +				knex: this.knex,
    +			},
    +		);
     
    -			dbQuery.countDistinct({ count: [`${collection}.${primaryKeyName}`] });
    -		} else {
    -			dbQuery.count('*', { as: 'count' });
    -		}
    +		ast = await processAst(
    +			{ ast, action: 'read', accountability: this.accountability },
    +			{ knex: this.knex, schema: this.schema },
    +		);
     
    -		const result = await dbQuery.first();
    +		const records = await runAst(ast, this.schema, this.accountability, {
    +			knex: this.knex,
    +		});
     
    -		return Number(result?.count ?? 0);
    +		return Number((isArray(records) ? records[0]?.['count'] : records?.['count']) ?? 0);
     	}
     }
    
  • api/src/utils/apply-query.test.ts+242 29 modified
    @@ -1,5 +1,5 @@
    -import type { SchemaOverview } from '@directus/types';
    -import knex from 'knex';
    +import type { Permission, SchemaOverview } from '@directus/types';
    +import knex, { Knex } from 'knex';
     import { MockClient, createTracker } from 'knex-mock-client';
     import { describe, expect, test, vi } from 'vitest';
     import { applyFilter, applySearch } from './apply-query.js';
    @@ -28,6 +28,20 @@ const FAKE_SCHEMA: SchemaOverview = {
     					validation: null,
     					alias: false,
     				},
    +				text1: {
    +					field: 'text',
    +					defaultValue: null,
    +					nullable: false,
    +					generated: false,
    +					type: 'text',
    +					dbType: null,
    +					precision: null,
    +					scale: null,
    +					special: [],
    +					note: null,
    +					validation: null,
    +					alias: false,
    +				},
     				float: {
     					field: 'float',
     					defaultValue: null,
    @@ -76,10 +90,26 @@ const FAKE_SCHEMA: SchemaOverview = {
     	relations: [],
     };
     
    +const permissions = [
    +	{
    +		collection: 'test',
    +		action: 'read',
    +		fields: ['text', 'float', 'integer', 'id'],
    +		permissions: {
    +			text: {},
    +		},
    +	},
    +] as unknown as Permission[];
    +
     class Client_SQLite3 extends MockClient {}
     
     describe('applySearch', () => {
     	function mockDatabase(dbClient: string = 'Client_SQLite3') {
    +		const whereQueries = {
    +			whereRaw: vi.fn(() => self),
    +			where: vi.fn(() => self),
    +		};
    +
     		const self: Record<string, any> = {
     			client: {
     				constructor: {
    @@ -89,6 +119,8 @@ describe('applySearch', () => {
     			andWhere: vi.fn(() => self),
     			orWhere: vi.fn(() => self),
     			orWhereRaw: vi.fn(() => self),
    +			and: whereQueries,
    +			or: whereQueries,
     		};
     
     		return self;
    @@ -98,58 +130,239 @@ describe('applySearch', () => {
     		'Prevent %s from being cast to number',
     		async (number) => {
     			const db = mockDatabase();
    +			const queryBuilder = db as any;
     
    -			db['andWhere'].mockImplementation((callback: () => void) => {
    -				// detonate the andWhere function
    -				callback.call(db);
    -				return db;
    +			db['andWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +				callback(queryBuilder);
    +				return queryBuilder;
     			});
     
    -			applySearch(db as any, FAKE_SCHEMA, db as any, number, 'test');
    +			queryBuilder['orWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +				callback(queryBuilder);
    +				return queryBuilder;
    +			});
    +
    +			applySearch(db as any, FAKE_SCHEMA, queryBuilder, number, 'test', {}, permissions);
     
     			expect(db['andWhere']).toBeCalledTimes(1);
    -			expect(db['orWhere']).toBeCalledTimes(0);
    -			expect(db['orWhereRaw']).toBeCalledTimes(1);
    -			expect(db['orWhereRaw']).toBeCalledWith('LOWER(??) LIKE ?', ['test.text', `%${number.toLowerCase()}%`]);
    +			expect(queryBuilder['orWhere']).toBeCalledTimes(1);
    +			expect(queryBuilder['orWhereRaw']).toBeCalledTimes(0);
    +			expect(queryBuilder['and']['whereRaw']).toBeCalledTimes(1);
    +
    +			expect(queryBuilder['and']['whereRaw']).toBeCalledWith('LOWER(??) LIKE ?', [
    +				'test.text',
    +				`%${number.toLowerCase()}%`,
    +			]);
     		},
     	);
     
     	test.each(['1234', '-128', '12.34'])('Casting number %s', async (number) => {
     		const db = mockDatabase();
    +		const queryBuilder = db as any;
    +
    +		db['andWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +			callback(queryBuilder);
    +			return queryBuilder;
    +		});
     
    -		db['andWhere'].mockImplementation((callback: () => void) => {
    -			// detonate the andWhere function
    -			callback.call(db);
    -			return db;
    +		queryBuilder['orWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +			callback(queryBuilder);
    +			return queryBuilder;
     		});
     
    -		applySearch(db as any, FAKE_SCHEMA, db as any, number, 'test');
    +		applySearch(db as any, FAKE_SCHEMA, queryBuilder, number, 'test', {}, permissions);
     
     		expect(db['andWhere']).toBeCalledTimes(1);
    -		expect(db['orWhere']).toBeCalledTimes(2);
    -		expect(db['orWhereRaw']).toBeCalledTimes(1);
    -		expect(db['orWhereRaw']).toBeCalledWith('LOWER(??) LIKE ?', ['test.text', `%${number.toLowerCase()}%`]);
    +		expect(queryBuilder['orWhere']).toBeCalledTimes(3);
    +		expect(queryBuilder['orWhereRaw']).toBeCalledTimes(0);
    +		expect(queryBuilder['and']['whereRaw']).toBeCalledTimes(1);
    +
    +		expect(queryBuilder['and']['whereRaw']).toBeCalledWith('LOWER(??) LIKE ?', [
    +			'test.text',
    +			`%${number.toLowerCase()}%`,
    +		]);
     	});
     
     	test('Query is falsy if no other clause is added', async () => {
     		const db = mockDatabase();
    +		const queryBuilder = db as any;
     
     		const schemaWithStringFieldRemoved = JSON.parse(JSON.stringify(FAKE_SCHEMA));
     
     		delete schemaWithStringFieldRemoved.collections.test.fields.text;
     
    -		db['andWhere'].mockImplementation((callback: () => void) => {
    -			// detonate the andWhere function
    -			callback.call(db);
    -			return db;
    +		db['andWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +			callback(queryBuilder);
    +			return queryBuilder;
    +		});
    +
    +		queryBuilder['orWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +			callback(queryBuilder);
    +			return queryBuilder;
    +		});
    +
    +		applySearch(db as any, schemaWithStringFieldRemoved, queryBuilder, 'searchstring', 'test', {}, permissions);
    +
    +		expect(db['andWhere']).toBeCalledTimes(1);
    +		expect(queryBuilder['orWhere']).toBeCalledTimes(0);
    +		expect(queryBuilder['orWhereRaw']).toBeCalledTimes(1);
    +		expect(queryBuilder['orWhereRaw']).toBeCalledWith('1 = 0');
    +	});
    +
    +	test('Remove forbidden field "text" from search', () => {
    +		const db = mockDatabase();
    +		const queryBuilder = db as any;
    +
    +		db['andWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +			callback(queryBuilder);
    +			return queryBuilder;
    +		});
    +
    +		queryBuilder['orWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +			callback(queryBuilder);
    +			return queryBuilder;
    +		});
    +
    +		applySearch(db as any, FAKE_SCHEMA, queryBuilder, 'directus', 'test', {}, [
    +			{
    +				collection: 'test',
    +				action: 'read',
    +				fields: ['text1'],
    +				permissions: {
    +					text: {},
    +				},
    +			} as unknown as Permission,
    +		]);
    +
    +		expect(db['andWhere']).toBeCalledTimes(1);
    +		expect(queryBuilder['orWhere']).toBeCalledTimes(1);
    +		expect(queryBuilder['orWhereRaw']).toBeCalledTimes(0);
    +		expect(queryBuilder['and']['whereRaw']).toBeCalledTimes(1);
    +		expect(queryBuilder['and']['whereRaw']).toBeCalledWith('LOWER(??) LIKE ?', ['test.text1', `%directus%`]);
    +	});
    +
    +	test('Add all fields for * field rule and no item rule', () => {
    +		const db = mockDatabase();
    +		const queryBuilder = db as any;
    +
    +		db['andWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +			callback(queryBuilder);
    +			return queryBuilder;
    +		});
    +
    +		queryBuilder['orWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +			callback(queryBuilder);
    +			return queryBuilder;
    +		});
    +
    +		applySearch(db as any, FAKE_SCHEMA, queryBuilder, `1`, 'test', {}, [
    +			{
    +				collection: 'test',
    +				action: 'read',
    +				fields: ['*'],
    +				permissions: null,
    +			} as unknown as Permission,
    +		]);
    +
    +		expect(db['andWhere']).toBeCalledTimes(1);
    +		expect(queryBuilder['orWhere']).toBeCalledTimes(0);
    +		expect(queryBuilder['orWhereRaw']).toBeCalledTimes(0);
    +		expect(queryBuilder['or']['whereRaw']).toBeCalledTimes(2);
    +		expect(queryBuilder['or']['where']).toBeCalledTimes(2);
    +	});
    +
    +	test('Add all fields for * field rule and item rules', () => {
    +		const db = mockDatabase();
    +		const queryBuilder = db as any;
    +
    +		db['andWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +			callback(queryBuilder);
    +			return queryBuilder;
    +		});
    +
    +		queryBuilder['orWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +			callback(queryBuilder);
    +			return queryBuilder;
    +		});
    +
    +		applySearch(db as any, FAKE_SCHEMA, queryBuilder, '1', 'test', {}, [
    +			{
    +				collection: 'test',
    +				action: 'read',
    +				fields: ['*'],
    +				permissions: {
    +					text: {},
    +				},
    +			} as unknown as Permission,
    +		]);
    +
    +		expect(db['andWhere']).toBeCalledTimes(1);
    +		expect(queryBuilder['orWhere']).toBeCalledTimes(0);
    +		expect(queryBuilder['orWhereRaw']).toBeCalledTimes(0);
    +		expect(queryBuilder['or']['whereRaw']).toBeCalledTimes(2);
    +		expect(queryBuilder['or']['where']).toBeCalledTimes(2);
    +	});
    +
    +	test('Add all fields when at least one policy contains a * field rule', () => {
    +		const db = mockDatabase();
    +		const queryBuilder = db as any;
    +
    +		db['andWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +			callback(queryBuilder);
    +			return queryBuilder;
    +		});
    +
    +		queryBuilder['orWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +			callback(queryBuilder);
    +			return queryBuilder;
    +		});
    +
    +		applySearch(db as any, FAKE_SCHEMA, queryBuilder, '1', 'test', {}, [
    +			{
    +				collection: 'test',
    +				action: 'read',
    +				fields: ['text'],
    +				permissions: {
    +					text: {},
    +				},
    +			} as unknown as Permission,
    +			{
    +				collection: 'test',
    +				action: 'read',
    +				fields: ['*'],
    +				permissions: {
    +					text: {},
    +				},
    +			} as unknown as Permission,
    +		]);
    +
    +		expect(db['andWhere']).toBeCalledTimes(1);
    +		expect(queryBuilder['orWhere']).toBeCalledTimes(1);
    +		expect(queryBuilder['orWhereRaw']).toBeCalledTimes(0);
    +		expect(queryBuilder['or']['whereRaw']).toBeCalledTimes(2);
    +		expect(queryBuilder['or']['where']).toBeCalledTimes(3);
    +	});
    +
    +	test('Add field(s) without permissions for admin', () => {
    +		const db = mockDatabase();
    +		const queryBuilder = db as any;
    +
    +		db['andWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +			callback(queryBuilder);
    +			return queryBuilder;
    +		});
    +
    +		queryBuilder['orWhere'].mockImplementation((callback: (queryBuilder: Knex.QueryBuilder) => void) => {
    +			callback(queryBuilder);
    +			return queryBuilder;
     		});
     
    -		applySearch(db as any, schemaWithStringFieldRemoved, db as any, 'searchstring', 'test');
    +		applySearch(db as any, FAKE_SCHEMA, queryBuilder, '1', 'test', {}, []);
     
     		expect(db['andWhere']).toBeCalledTimes(1);
    -		expect(db['orWhere']).toBeCalledTimes(0);
    -		expect(db['orWhereRaw']).toBeCalledTimes(1);
    -		expect(db['orWhereRaw']).toBeCalledWith('1 = 0');
    +		expect(queryBuilder['orWhere']).toBeCalledTimes(0);
    +		expect(queryBuilder['orWhereRaw']).toBeCalledTimes(0);
    +		expect(queryBuilder['or']['whereRaw']).toBeCalledTimes(2);
     	});
     });
     
    @@ -198,7 +411,7 @@ describe('applyFilter', () => {
     						_and: [{ [field]: { [`_${filterOperator}`]: filterValue } }],
     					};
     
    -					const { query } = applyFilter(db, FAKE_SCHEMA, queryBuilder, rootFilter, collection, {}, []);
    +					const { query } = applyFilter(db, FAKE_SCHEMA, queryBuilder, rootFilter, collection, {}, [], []);
     
     					const tracker = createTracker(db);
     					tracker.on.select('*').response([]);
    @@ -264,7 +477,7 @@ describe('applyFilter', () => {
     			},
     		};
     
    -		const { query } = applyFilter(db, BIGINT_FAKE_SCHEMA, queryBuilder, rootFilter, collection, {}, []);
    +		const { query } = applyFilter(db, BIGINT_FAKE_SCHEMA, queryBuilder, rootFilter, collection, {}, [], []);
     
     		const tracker = createTracker(db);
     		tracker.on.select('*').response([]);
    @@ -324,7 +537,7 @@ describe('applyFilter', () => {
     			},
     		};
     
    -		const { query } = applyFilter(db, sampleSchema, queryBuilder, rootFilter, collection, {});
    +		const { query } = applyFilter(db, sampleSchema, queryBuilder, rootFilter, collection, {}, [], []);
     
     		const tracker = createTracker(db);
     		tracker.on.select('*').response([]);
    
  • api/src/utils/apply-query.ts+77 21 modified
    @@ -72,10 +72,6 @@ export default function applyQuery(
     		}
     	}
     
    -	if (query.search) {
    -		applySearch(knex, schema, dbQuery, query.search, collection);
    -	}
    -
     	// `cases` are the permissions cases that are required for the current data set. We're
     	// dynamically adding those into the filters that the user provided to enforce the permission
     	// rules. You should be able to read an item if one or more of the cases matches. The actual case
    @@ -137,6 +133,10 @@ export default function applyQuery(
     		dbQuery.groupBy(columns);
     	}
     
    +	if (query.search) {
    +		applySearch(knex, schema, dbQuery, query.search, collection, aliasMap, permissions);
    +	}
    +
     	if (query.aggregate) {
     		applyAggregate(schema, dbQuery, query.aggregate, collection, hasJoins);
     	}
    @@ -923,38 +923,94 @@ export function applySearch(
     	dbQuery: Knex.QueryBuilder,
     	searchQuery: string,
     	collection: string,
    +	aliasMap: AliasMap,
    +	permissions: Permission[],
     ) {
     	const { number: numberHelper } = getHelpers(knex);
    -	const fields = Object.entries(schema.collections[collection]!.fields);
     
    -	dbQuery.andWhere(function () {
    +	const allowedFields = new Set(permissions.filter((p) => p.collection === collection).flatMap((p) => p.fields ?? []));
    +
    +	let fields = Object.entries(schema.collections[collection]!.fields);
    +
    +	const { cases, caseMap } = getCases(collection, permissions, []);
    +
    +	// Add field restrictions if non-admin and "everything" is not allowed
    +	if (cases.length !== 0 && !allowedFields.has('*')) {
    +		fields = fields.filter((field) => allowedFields.has(field[0]));
    +	}
    +
    +	dbQuery.andWhere(function (queryBuilder) {
     		let needsFallbackCondition = true;
     
     		fields.forEach(([name, field]) => {
    -			if (['text', 'string'].includes(field.type)) {
    -				this.orWhereRaw(`LOWER(??) LIKE ?`, [`${collection}.${name}`, `%${searchQuery.toLowerCase()}%`]);
    -				needsFallbackCondition = false;
    -			} else if (isNumericField(field)) {
    -				const number = parseNumericString(searchQuery);
    +			const whenCases = (caseMap[name] ?? []).map((caseIndex) => cases[caseIndex]!);
     
    -				if (number === null) {
    -					return; // unable to parse
    -				}
    +			const fieldType = getFieldType(field);
     
    -				if (numberHelper.isNumberValid(number, field)) {
    -					numberHelper.addSearchCondition(this, collection, name, number);
    -					needsFallbackCondition = false;
    -				}
    -			} else if (field.type === 'uuid' && isValidUuid(searchQuery)) {
    -				this.orWhere({ [`${collection}.${name}`]: searchQuery });
    +			if (fieldType !== null) {
     				needsFallbackCondition = false;
    +			} else {
    +				return;
    +			}
    +
    +			if (cases.length !== 0 && whenCases?.length !== 0) {
    +				queryBuilder.orWhere((subQuery) => {
    +					addSearchCondition(subQuery, name, fieldType, 'and');
    +
    +					applyFilter(knex, schema, subQuery, { _or: whenCases }, collection, aliasMap, cases, permissions);
    +				});
    +			} else {
    +				addSearchCondition(queryBuilder, name, fieldType, 'or');
     			}
     		});
     
     		if (needsFallbackCondition) {
    -			this.orWhereRaw('1 = 0');
    +			queryBuilder.orWhereRaw('1 = 0');
     		}
     	});
    +
    +	function addSearchCondition(
    +		queryBuilder: Knex.QueryBuilder,
    +		name: string,
    +		fieldType: 'string' | 'numeric' | 'uuid',
    +		logical: 'and' | 'or',
    +	) {
    +		if (fieldType === null) {
    +			return;
    +		}
    +
    +		if (fieldType === 'string') {
    +			queryBuilder[logical].whereRaw(`LOWER(??) LIKE ?`, [`${collection}.${name}`, `%${searchQuery.toLowerCase()}%`]);
    +		} else if (fieldType === 'numeric') {
    +			numberHelper.addSearchCondition(queryBuilder, collection, name, parseNumericString(searchQuery)!, logical);
    +		} else if (fieldType === 'uuid') {
    +			queryBuilder[logical].where({ [`${collection}.${name}`]: searchQuery });
    +		}
    +	}
    +
    +	function getFieldType(field: FieldOverview): null | 'string' | 'numeric' | 'uuid' {
    +		if (['text', 'string'].includes(field.type)) {
    +			return 'string';
    +		}
    +
    +		if (isNumericField(field)) {
    +			const number = parseNumericString(searchQuery);
    +
    +			if (number === null) {
    +				return null;
    +			}
    +
    +			if (numberHelper.isNumberValid(number, field)) {
    +				return 'numeric';
    +			}
    +		}
    +
    +		if (field.type === 'uuid' && isValidUuid(searchQuery)) {
    +			return 'uuid';
    +		}
    +
    +		return null;
    +	}
     }
     
     export function applyAggregate(
    

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

News mentions

0

No linked articles in our index yet.