Directus privilege escalation vulnerability using Share feature
Description
Directus is a real-time API and App dashboard for managing SQL database content. Prior to version 11.2.0, when sharing an item, a typical user can specify an arbitrary role. It allows the user to use a higher-privileged role to see fields that otherwise the user should not be able to see. Instances that are impacted are those that use the share feature and have specific roles hierarchy and fields that are not visible for certain roles. Version 11.2.0 contains a patch the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
directusnpm | < 11.2.0 | 11.2.0 |
@directus/appnpm | < 13.3.1 | 13.3.1 |
Affected products
1Patches
1e288a43a7961Fix permission generation for Shares 📰 (#23716)
21 files changed · +1209 −162
api/src/controllers/permissions.ts+2 −1 modified@@ -91,7 +91,8 @@ router.search('/', validateBatch('read'), readHandler, respond); router.get( '/me', asyncHandler(async (req, res, next) => { - if (!req.accountability?.user && !req.accountability?.role) throw new ForbiddenError(); + if (!req.accountability?.user && !req.accountability?.role && !req.accountability?.share) + throw new ForbiddenError(); const result = await fetchAccountabilityCollectionAccess(req.accountability, { schema: req.schema,
api/src/controllers/users.ts+4 −8 modified@@ -86,17 +86,13 @@ router.search('/', validateBatch('read'), readHandler, respond); router.get( '/me', asyncHandler(async (req, res, next) => { - if (req.accountability?.share_scope) { - const user = { - share: req.accountability?.share, - role: { - id: req.accountability.role, - admin_access: false, - app_access: false, + if (req.accountability?.share) { + res.locals['payload'] = { + data: { + share: req.accountability?.share, }, }; - res.locals['payload'] = { data: user }; return next(); }
api/src/database/migrations/20240806A-permissions-policies.ts+10 −105 modified@@ -1,15 +1,16 @@ import { processChunk, toBoolean } from '@directus/utils'; import type { Knex } from 'knex'; -import { flatten, intersection, isEqual, merge, omit, uniq } from 'lodash-es'; +import { omit } from 'lodash-es'; import { randomUUID } from 'node:crypto'; import { useLogger } from '../../logger/index.js'; import { fetchPermissions } from '../../permissions/lib/fetch-permissions.js'; import { fetchPolicies } from '../../permissions/lib/fetch-policies.js'; import { fetchRolesTree } from '../../permissions/lib/fetch-roles-tree.js'; import { getSchema } from '../../utils/get-schema.js'; -import type { LogicalFilterAND, LogicalFilterOR, Permission } from '@directus/types'; +import type { Accountability, Permission } from '@directus/types'; import { getSchemaInspector } from '../index.js'; +import { mergePermissions } from '../../permissions/utils/merge-permissions.js'; type RoleAccess = { app_access: boolean; @@ -18,107 +19,6 @@ type RoleAccess = { enforce_tfa: boolean; }; -// Adapted from https://github.com/directus/directus/blob/141b8adbf4dd8e06530a7929f34e3fc68a522053/api/src/utils/merge-permissions.ts#L4 -export function mergePermissions(strategy: 'and' | 'or', ...permissions: Permission[][]) { - const allPermissions = flatten(permissions); - - const mergedPermissions = allPermissions - .reduce((acc, val) => { - const key = `${val.collection}__${val.action}`; - const current = acc.get(key); - acc.set(key, current ? mergePermission(strategy, current, val) : val); - return acc; - }, new Map()) - .values(); - - return Array.from(mergedPermissions); -} - -export function mergePermission( - strategy: 'and' | 'or', - currentPerm: Permission, - newPerm: Permission, -): Omit<Permission, 'id' | 'system'> { - const logicalKey = `_${strategy}` as keyof LogicalFilterOR | keyof LogicalFilterAND; - - let { permissions, validation, fields, presets } = currentPerm; - - if (newPerm.permissions) { - if (currentPerm.permissions && Object.keys(currentPerm.permissions)[0] === logicalKey) { - permissions = { - [logicalKey]: [ - ...(currentPerm.permissions as LogicalFilterOR & LogicalFilterAND)[logicalKey], - newPerm.permissions, - ], - } as LogicalFilterAND | LogicalFilterOR; - } else if (currentPerm.permissions) { - // Empty {} supersedes other permissions in _OR merge - if (strategy === 'or' && (isEqual(currentPerm.permissions, {}) || isEqual(newPerm.permissions, {}))) { - permissions = {}; - } else { - permissions = { - [logicalKey]: [currentPerm.permissions, newPerm.permissions], - } as LogicalFilterAND | LogicalFilterOR; - } - } else { - permissions = { - [logicalKey]: [newPerm.permissions], - } as LogicalFilterAND | LogicalFilterOR; - } - } - - if (newPerm.validation) { - if (currentPerm.validation && Object.keys(currentPerm.validation)[0] === logicalKey) { - validation = { - [logicalKey]: [ - ...(currentPerm.validation as LogicalFilterOR & LogicalFilterAND)[logicalKey], - newPerm.validation, - ], - } as LogicalFilterAND | LogicalFilterOR; - } else if (currentPerm.validation) { - // Empty {} supersedes other validations in _OR merge - if (strategy === 'or' && (isEqual(currentPerm.validation, {}) || isEqual(newPerm.validation, {}))) { - validation = {}; - } else { - validation = { - [logicalKey]: [currentPerm.validation, newPerm.validation], - } as LogicalFilterAND | LogicalFilterOR; - } - } else { - validation = { - [logicalKey]: [newPerm.validation], - } as LogicalFilterAND | LogicalFilterOR; - } - } - - if (newPerm.fields) { - if (Array.isArray(currentPerm.fields) && strategy === 'or') { - fields = uniq([...currentPerm.fields, ...newPerm.fields]); - } else if (Array.isArray(currentPerm.fields) && strategy === 'and') { - fields = intersection(currentPerm.fields, newPerm.fields); - } else { - fields = newPerm.fields; - } - - if (fields.includes('*')) fields = ['*']; - } - - if (newPerm.presets) { - presets = merge({}, presets, newPerm.presets); - } - - return omit( - { - ...currentPerm, - permissions, - validation, - fields, - presets, - }, - ['id', 'system'], - ); -} - async function fetchRoleAccess(roles: string[], context: { knex: Knex }) { const roleAccess: RoleAccess = { admin_access: false, @@ -361,15 +261,20 @@ export async function down(knex: Knex) { // fetch all of the policies permissions const rawPermissions = await fetchPermissions( { - accountability: { role: null, roles: roleTree, user: null, app: roleAccess?.app_access || false }, + accountability: { + role: null, + roles: roleTree, + user: null, + app: roleAccess?.app_access || false, + } as Accountability, policies, bypassDynamicVariableProcessing: true, }, context, ); // merge all permissions to single version (v10) and save for later use - mergePermissions('or', rawPermissions).forEach((permission) => { + (mergePermissions('or', rawPermissions) as any[]).forEach((permission) => { // System permissions are automatically populated if (permission.system) { return;
api/src/database/run-ast/utils/apply-case-when.ts+1 −1 modified@@ -45,7 +45,7 @@ export function applyCaseWhen( } } - const sql = sqlParts.join(' '); + const sql = sqlParts.length > 0 ? sqlParts.join(' ') : '1'; const bindings = [...caseQuery.toSQL().bindings, column]; let rawCase = `(CASE WHEN ${sql} THEN ?? END)`;
api/src/middleware/authenticate.test.ts+0 −9 modified@@ -98,11 +98,6 @@ test('Sets accountability to payload contents if valid token is passed', async ( const roleID = '38269fc6-6eb6-475a-93cb-479d97f73039'; const share = 'ca0ad005-f4ad-4bfe-b428-419ee8784790'; - const shareScope = { - collection: 'articles', - item: 15, - }; - const appAccess = true; const adminAccess = false; @@ -113,7 +108,6 @@ test('Sets accountability to payload contents if valid token is passed', async ( app_access: appAccess, admin_access: adminAccess, share, - share_scope: shareScope, }, 'test', { issuer: 'directus' }, @@ -141,7 +135,6 @@ test('Sets accountability to payload contents if valid token is passed', async ( app: appAccess, admin: adminAccess, share, - share_scope: shareScope, ip: '127.0.0.1', userAgent: 'fake-user-agent', origin: 'fake-origin', @@ -159,7 +152,6 @@ test('Sets accountability to payload contents if valid token is passed', async ( app_access: 1, admin_access: 0, share, - share_scope: shareScope, }, 'test', { issuer: 'directus' }, @@ -174,7 +166,6 @@ test('Sets accountability to payload contents if valid token is passed', async ( app: appAccess, admin: adminAccess, share, - share_scope: shareScope, ip: '127.0.0.1', userAgent: 'fake-user-agent', origin: 'fake-origin',
api/src/permissions/lib/fetch-permissions.ts+5 −2 modified@@ -3,12 +3,13 @@ import type { Context } from '../types.js'; import { fetchDynamicVariableContext } from '../utils/fetch-dynamic-variable-context.js'; import { fetchRawPermissions } from '../utils/fetch-raw-permissions.js'; import { processPermissions } from '../utils/process-permissions.js'; +import { getPermissionsForShare } from '../utils/get-permissions-for-share.js'; export interface FetchPermissionsOptions { action?: PermissionsAction; policies: string[]; collections?: string[]; - accountability?: Pick<Accountability, 'user' | 'role' | 'roles' | 'app'>; + accountability?: Pick<Accountability, 'user' | 'role' | 'roles' | 'app' | 'share' | 'ip'>; bypassDynamicVariableProcessing?: boolean; } @@ -35,7 +36,9 @@ export async function fetchPermissions(options: FetchPermissionsOptions, context permissionsContext, }); - // TODO merge in permissions coming from the share scope + if (options.accountability.share && (options.action === undefined || options.action === 'read')) { + return await getPermissionsForShare(options.accountability, options.collections, context); + } return processedPermissions; }
api/src/permissions/utils/fetch-share-info.ts+23 −0 added@@ -0,0 +1,23 @@ +import type { AbstractServiceOptions } from '../../types/services.js'; +import { withCache } from './with-cache.js'; + +export interface ShareInfo { + collection: string; + item: string; + role: string | null; + user_created: { + id: string; + role: string; + }; +} + +export const fetchShareInfo = withCache('share-info', _fetchShareInfo); + +export async function _fetchShareInfo(shareId: string, context: AbstractServiceOptions): Promise<ShareInfo> { + const { SharesService } = await import('../../services/shares.js'); + const sharesService = new SharesService(context); + + return (await sharesService.readOne(shareId, { + fields: ['collection', 'item', 'role', 'user_created.id', 'user_created.role'], + })) as ShareInfo; +}
api/src/permissions/utils/get-permissions-for-share.test.ts+559 −0 added@@ -0,0 +1,559 @@ +import { beforeAll, describe, expect, test, vi } from 'vitest'; +import { getPermissionsForShare } from './get-permissions-for-share.js'; +import type { Accountability, SchemaOverview } from '@directus/types'; +import type { Context } from '../types.js'; + +vi.mock('../modules/fetch-global-access/fetch-global-access.js', () => ({ + fetchGlobalAccess: vi.fn().mockImplementation((accountability: Accountability) => { + return { + admin: accountability.user === 'admin' || accountability.role === 'admin', + }; + }), +})); + +vi.mock('../lib/fetch-roles-tree.js', () => ({ + fetchRolesTree: vi.fn().mockImplementation((start: string | null) => { + return [start!]; + }), +})); + +vi.mock('../lib/fetch-policies.js', () => ({ + fetchPolicies: vi.fn().mockImplementation(() => { + return []; + }), +})); + +vi.mock('../lib/fetch-permissions.js', () => ({ + fetchPermissions: vi.fn().mockImplementation(({ accountability }: { policy: any; accountability: any }) => { + if (accountability.user === 'admin' || accountability.role === 'admin') { + return []; + } else if (accountability.role === 'manager') { + return []; + } else { + return []; + } + }), +})); + +vi.mock('./fetch-share-info.js', () => ({ + fetchShareInfo: vi.fn().mockImplementation((id: string) => { + if (id === '1') { + return { + collection: 'articles', + item: 'item-id', + role: null, + user_created: { + id: 'admin', + role: 'admin', + }, + }; + } else if (id === '2') { + return { + collection: 'articles', + item: 'item-id', + role: null, + user_created: { + id: 'manager', + role: 'manager', + }, + }; + } else if (id === '3') { + return { + collection: 'articles', + item: 'item-id', + role: null, + user_created: { + id: 'user', + role: 'user', + }, + }; + } else if (id === '4') { + return { + collection: 'articles', + item: 'item-id', + role: 'admin', + user_created: { + id: 'admin', + role: 'admin', + }, + }; + } else if (id === '5') { + return { + collection: 'articles', + item: 'item-id', + role: 'manager', + user_created: { + id: 'manager', + role: 'manager', + }, + }; + } else { + return { + collection: 'articles', + item: 'item-id', + role: 'user', + user_created: { + id: 'user', + role: 'user', + }, + }; + } + }), +})); + +vi.mock('../modules/fetch-allowed-field-map/fetch-allowed-field-map.js', () => ({ + fetchAllowedFieldMap: vi.fn().mockImplementation((accountability) => { + if (accountability.user === 'admin' || accountability.role === 'admin') { + return { + articles: ['id', 'title', 'authors'], + authors: ['id', 'name', 'article'], + super_secret_table: ['id', 'secret'], + }; + } else if (accountability.role === 'manager') { + return { articles: ['id', 'title'] }; + } else { + return {}; + } + }), +})); + +describe('getPermissionsForShare', () => { + let context: Context; + + beforeAll(() => { + context = { + schema, + knex: null as any, + }; + }); + + test('no role selected and created by admin user', async () => { + const accountability: Accountability = { + user: 'admin', + role: 'admin', + admin: false, + app: false, + ip: '', + roles: [], + share: '1', + }; + + const permissions = await getPermissionsForShare(accountability, undefined, context); + + expect(permissions).toEqual([ + { + action: 'read', + collection: 'articles', + fields: null, + permissions: { + id: { + _eq: 'item-id', + }, + }, + policy: null, + presets: null, + validation: null, + }, + ...basePermissions, + ]); + }); + + test('no role selected and created by manager', async () => { + const accountability: Accountability = { + user: 'manager', + role: 'manager', + admin: false, + app: false, + ip: '', + roles: [], + share: '2', + }; + + const permissions = await getPermissionsForShare(accountability, undefined, context); + + expect(permissions).toEqual([ + { + action: 'read', + collection: 'articles', + fields: null, + permissions: { + id: { + _eq: 'item-id', + }, + }, + policy: null, + presets: null, + validation: null, + }, + ...basePermissions, + ]); + }); + + test('no role selected and created by user', async () => { + const accountability: Accountability = { + user: 'user', + role: 'user', + admin: false, + app: false, + ip: '', + roles: [], + share: '3', + }; + + const permissions = await getPermissionsForShare(accountability, undefined, context); + + expect(permissions).toEqual([ + { + action: 'read', + collection: 'articles', + fields: null, + permissions: { + id: { + _eq: 'item-id', + }, + }, + policy: null, + presets: null, + validation: null, + }, + ...basePermissions, + ]); + }); + + test('admin role selected and created by admin', async () => { + const accountability: Accountability = { + user: 'admin', + role: 'admin', + admin: false, + app: false, + ip: '', + roles: [], + share: '4', + }; + + const permissions = await getPermissionsForShare(accountability, undefined, context); + + expect(permissions).toEqual([ + { + action: 'read', + collection: 'articles', + fields: ['*'], + permissions: { + _or: [ + { + id: { + _eq: 'item-id', + }, + }, + { + '$FOLLOW(authors,article)': { + article: { + id: { + _eq: 'item-id', + }, + }, + }, + }, + ], + }, + policy: null, + presets: null, + validation: null, + }, + { + action: 'read', + collection: 'authors', + fields: ['*'], + permissions: { + article: { + _eq: 'item-id', + }, + }, + policy: null, + presets: null, + validation: null, + }, + ...basePermissions, + ]); + }); + + test('admin role selected and created by manager', async () => { + const accountability: Accountability = { + user: 'manager', + role: 'manager', + admin: false, + app: false, + ip: '', + roles: [], + share: '5', + }; + + const permissions = await getPermissionsForShare(accountability, undefined, context); + + expect(permissions).toEqual([ + { + action: 'read', + collection: 'articles', + fields: null, + permissions: { + id: { + _eq: 'item-id', + }, + }, + policy: null, + presets: null, + validation: null, + }, + ...basePermissions, + ]); + }); + + test('admin role selected and created by user', async () => { + const accountability: Accountability = { + user: 'user', + role: 'user', + admin: false, + app: false, + ip: '', + roles: [], + share: '6', + }; + + const permissions = await getPermissionsForShare(accountability, undefined, context); + + expect(permissions).toEqual([ + { + action: 'read', + collection: 'articles', + fields: null, + permissions: { + id: { + _eq: 'item-id', + }, + }, + policy: null, + presets: null, + validation: null, + }, + ...basePermissions, + ]); + }); +}); + +const basePermissions = [ + { + action: 'read', + collection: 'directus_collections', + fields: ['*'], + permissions: {}, + policy: null, + presets: null, + system: true, + validation: null, + }, + { + action: 'read', + collection: 'directus_fields', + fields: ['*'], + permissions: {}, + policy: null, + presets: null, + system: true, + validation: null, + }, + { + action: 'read', + collection: 'directus_relations', + fields: ['*'], + permissions: {}, + policy: null, + presets: null, + system: true, + validation: null, + }, + { + action: 'read', + collection: 'directus_translations', + fields: ['*'], + permissions: {}, + policy: null, + presets: null, + system: true, + validation: null, + }, +]; + +const schema: SchemaOverview = { + collections: { + articles: { + collection: 'articles', + accountability: 'all', + note: '', + primary: 'id', + singleton: false, + sortField: null, + fields: { + id: { + field: 'id', + type: 'integer', + dbType: 'integer', + nullable: false, + generated: true, + precision: null, + scale: null, + special: [], + note: '', + alias: false, + validation: null, + defaultValue: 'AUTO_INCREMENT', + }, + title: { + field: 'title', + type: 'string', + dbType: 'varchar', + nullable: false, + generated: false, + precision: null, + scale: null, + special: [], + note: '', + alias: false, + validation: null, + defaultValue: null, + }, + authors: { + field: 'authors', + defaultValue: null, + nullable: true, + generated: false, + type: 'alias', + dbType: null, + precision: null, + scale: null, + special: ['o2m'], + note: null, + alias: true, + validation: null, + }, + }, + }, + authors: { + collection: 'authors', + accountability: 'all', + note: '', + primary: 'id', + singleton: false, + sortField: null, + fields: { + id: { + field: 'id', + type: 'integer', + dbType: 'integer', + nullable: false, + generated: true, + precision: null, + scale: null, + special: [], + note: '', + alias: false, + validation: null, + defaultValue: 'AUTO_INCREMENT', + }, + name: { + field: 'name', + type: 'string', + dbType: 'varchar', + nullable: false, + generated: false, + precision: null, + scale: null, + special: [], + note: '', + alias: false, + validation: null, + defaultValue: null, + }, + article: { + field: 'article', + defaultValue: null, + nullable: true, + generated: false, + type: 'integer', + dbType: 'integer', + precision: null, + scale: null, + special: [], + note: null, + alias: false, + validation: null, + }, + }, + }, + super_secret_table: { + collection: 'super_secret_table', + accountability: 'all', + note: '', + primary: 'id', + singleton: false, + sortField: null, + fields: { + id: { + field: 'id', + type: 'integer', + dbType: 'integer', + nullable: false, + generated: true, + precision: null, + scale: null, + special: [], + note: '', + alias: false, + validation: null, + defaultValue: 'AUTO_INCREMENT', + }, + secret: { + field: 'secret', + type: 'string', + dbType: 'varchar', + nullable: false, + generated: false, + precision: null, + scale: null, + special: [], + note: '', + alias: false, + validation: null, + defaultValue: null, + }, + }, + }, + }, + relations: [ + { + collection: 'authors', + field: 'article', + related_collection: 'articles', + schema: { + table: 'authors', + column: 'article', + foreign_key_table: 'articles', + foreign_key_column: 'id', + on_update: 'NO ACTION', + on_delete: 'SET NULL', + constraint_name: null, + }, + meta: { + id: 1, + many_collection: 'authors', + many_field: 'article', + one_collection: 'articles', + one_field: 'authors', + one_collection_field: null, + one_allowed_collections: null, + junction_field: null, + sort_field: null, + one_deselect_action: 'nullify', + }, + }, + ], +};
api/src/permissions/utils/get-permissions-for-share.ts+281 −0 added@@ -0,0 +1,281 @@ +import { schemaPermissions } from '@directus/system-data'; +import type { Accountability, Filter, Permission, SchemaOverview } from '@directus/types'; +import { set, uniq } from 'lodash-es'; +import { fetchAllowedFieldMap } from '../modules/fetch-allowed-field-map/fetch-allowed-field-map.js'; +import type { Context } from '../types.js'; +import { fetchShareInfo } from './fetch-share-info.js'; +import { mergePermissions } from './merge-permissions.js'; +import { fetchPermissions } from '../lib/fetch-permissions.js'; +import { fetchPolicies } from '../lib/fetch-policies.js'; +import { fetchRolesTree } from '../lib/fetch-roles-tree.js'; +import { reduceSchema } from '../../utils/reduce-schema.js'; +import { fetchGlobalAccess } from '../modules/fetch-global-access/fetch-global-access.js'; + +export async function getPermissionsForShare( + accountability: Pick<Accountability, 'share' | 'ip'>, + collections: string[] | undefined, + context: Context, +): Promise<Permission[]> { + const defaults: Permission = { + action: 'read', + collection: '', + permissions: {}, + policy: null, + validation: null, + presets: null, + fields: null, + }; + + const { collection, item, role, user_created } = await fetchShareInfo(accountability.share!, context); + + const userAccountability: Accountability = { + user: user_created.id, + role: user_created.role, + roles: await fetchRolesTree(user_created.role, context.knex), + admin: false, + app: false, + ip: accountability.ip, + }; + + // Fallback to public accountability so merging later on has no issues + const shareAccountability: Accountability = { + user: null, + role: role, + roles: await fetchRolesTree(role, context.knex), + admin: false, + app: false, + ip: accountability.ip, + }; + + const [ + { admin: shareIsAdmin }, + { admin: userIsAdmin }, + userPermissions, + sharePermissions, + shareFieldMap, + userFieldMap, + ] = await Promise.all([ + fetchGlobalAccess(shareAccountability, context.knex), + fetchGlobalAccess(userAccountability, context.knex), + getPermissionsForAccountability(userAccountability, context), + getPermissionsForAccountability(shareAccountability, context), + fetchAllowedFieldMap( + { + accountability: shareAccountability, + action: 'read', + }, + context, + ), + fetchAllowedFieldMap( + { + accountability: userAccountability, + action: 'read', + }, + context, + ), + ]); + + const isAdmin = userIsAdmin && shareIsAdmin; + + let permissions: Permission[] = []; + let reducedSchema: SchemaOverview; + + if (isAdmin) { + defaults.fields = ['*']; + reducedSchema = context.schema; + } else if (userIsAdmin && !shareIsAdmin) { + permissions = sharePermissions; + reducedSchema = reduceSchema(context.schema, shareFieldMap); + } else if (shareIsAdmin && !userIsAdmin) { + permissions = userPermissions; + reducedSchema = reduceSchema(context.schema, userFieldMap); + } else { + permissions = mergePermissions('intersection', sharePermissions, userPermissions); + reducedSchema = reduceSchema(context.schema, shareFieldMap); + reducedSchema = reduceSchema(reducedSchema, userFieldMap); + } + + const parentPrimaryKeyField = context.schema.collections[collection]!.primary; + + const relationalPermissions = traverse(reducedSchema, parentPrimaryKeyField, item, collection); + + const parentCollectionPermission: Permission = { + ...defaults, + collection, + permissions: { + [parentPrimaryKeyField]: { + _eq: item, + }, + }, + }; + + // All permissions that will be merged into the original permissions set + const allGeneratedPermissions = [ + parentCollectionPermission, + ...relationalPermissions.map((generated) => ({ ...defaults, ...generated })), + ...schemaPermissions, + ]; + + // All the collections that are touched through the relational tree from the current root collection, and the schema collections + const allowedCollections = uniq(allGeneratedPermissions.map(({ collection }) => collection)); + + const generatedPermissions: Permission[] = []; + + // Merge all the permissions that relate to the same collection with an _or (this allows you to properly retrieve) + // the items of a collection if you entered that collection from multiple angles + for (const collection of allowedCollections) { + const permissionsForCollection = allGeneratedPermissions.filter( + (permission) => permission.collection === collection, + ); + + if (permissionsForCollection.length > 0) { + generatedPermissions.push(...mergePermissions('or', permissionsForCollection)); + } else { + generatedPermissions.push(...permissionsForCollection); + } + } + + if (isAdmin) { + return filterCollections(collections, generatedPermissions); + } + + // Explicitly filter out permissions to collections unrelated to the root parent item. + const limitedPermissions = permissions.filter( + ({ action, collection }) => allowedCollections.includes(collection) && action === 'read', + ); + + return filterCollections(collections, mergePermissions('and', limitedPermissions, generatedPermissions)); +} + +function filterCollections(collections: string[] | undefined, permissions: Permission[]): Permission[] { + if (!collections) { + return permissions; + } + + return permissions.filter(({ collection }) => collections.includes(collection)); +} + +async function getPermissionsForAccountability( + accountability: Accountability, + context: Context, +): Promise<Permission[]> { + const policies = await fetchPolicies(accountability, context); + + return fetchPermissions( + { + policies, + accountability, + }, + context, + ); +} + +export function traverse( + schema: SchemaOverview, + rootItemPrimaryKeyField: string, + rootItemPrimaryKey: string, + currentCollection: string, + parentCollections: string[] = [], + path: string[] = [], +): Partial<Permission>[] { + const permissions: Partial<Permission>[] = []; + + // If there's already a permissions rule for the collection we're currently checking, we'll shortcircuit. + // This prevents infinite loop in recursive relationships, like articles->related_articles->articles, or + // articles.author->users.avatar->files.created_by->users.avatar->files.created_by->🔁 + if (parentCollections.includes(currentCollection)) { + return permissions; + } + + const relationsInCollection = schema.relations.filter((relation) => { + return relation.collection === currentCollection || relation.related_collection === currentCollection; + }); + + for (const relation of relationsInCollection) { + let type; + + if (relation.related_collection === currentCollection) { + type = 'o2m'; + } else if (!relation.related_collection) { + type = 'a2o'; + } else { + type = 'm2o'; + } + + if (type === 'o2m') { + permissions.push({ + collection: relation.collection, + permissions: getFilterForPath(type, [...path, relation.field], rootItemPrimaryKeyField, rootItemPrimaryKey), + }); + + permissions.push( + ...traverse( + schema, + rootItemPrimaryKeyField, + rootItemPrimaryKey, + relation.collection, + [...parentCollections, currentCollection], + [...path, relation.field], + ), + ); + } + + if (type === 'a2o' && relation.meta?.one_allowed_collections) { + for (const collection of relation.meta.one_allowed_collections) { + permissions.push({ + collection, + permissions: getFilterForPath( + type, + [...path, `$FOLLOW(${relation.collection},${relation.field},${relation.meta.one_collection_field})`], + rootItemPrimaryKeyField, + rootItemPrimaryKey, + ), + }); + } + } + + if (type === 'm2o') { + permissions.push({ + collection: relation.related_collection!, + permissions: getFilterForPath( + type, + [...path, `$FOLLOW(${relation.collection},${relation.field})`], + rootItemPrimaryKeyField, + rootItemPrimaryKey, + ), + }); + + if (relation.meta?.one_field) { + permissions.push( + ...traverse( + schema, + rootItemPrimaryKeyField, + rootItemPrimaryKey, + relation.related_collection!, + [...parentCollections, currentCollection], + [...path, relation.meta?.one_field], + ), + ); + } + } + } + + return permissions; +} + +function getFilterForPath( + type: 'o2m' | 'm2o' | 'a2o', + path: string[], + rootPrimaryKeyField: string, + rootPrimaryKey: string, +): Filter { + const filter: Filter = {}; + + if (type === 'm2o' || type === 'a2o') { + set(filter, path.reverse(), { [rootPrimaryKeyField]: { _eq: rootPrimaryKey } }); + } else { + set(filter, path.reverse(), { _eq: rootPrimaryKey }); + } + + return filter; +}
api/src/permissions/utils/merge-permissions.test.ts+145 −0 added@@ -0,0 +1,145 @@ +import { expect, test } from 'vitest'; +import { mergePermissions } from './merge-permissions.js'; +import type { Permission } from '@directus/types'; + +test('merge empty permission arrays', () => { + const permissions = mergePermissions('and', [], []); + + expect(permissions).toEqual([]); +}); + +test('merge two permissions', () => { + const perm1: Permission = { + collection: 'collection', + policy: 'some-policy', + action: 'read', + permissions: {}, + validation: {}, + presets: {}, + fields: ['*'], + }; + + const perm2: Permission = { + collection: 'collection', + policy: 'some-policy', + action: 'read', + permissions: {}, + validation: {}, + presets: {}, + fields: ['*'], + }; + + let permissions = mergePermissions('and', [perm1], [perm2]); + + expect(permissions).toEqual([ + { + collection: 'collection', + policy: 'some-policy', + action: 'read', + permissions: { + _and: [{}, {}], + }, + validation: { + _and: [{}, {}], + }, + presets: {}, + fields: ['*'], + }, + ]); + + permissions = mergePermissions('or', [perm1], [perm2]); + + expect(permissions).toEqual([ + { + collection: 'collection', + policy: 'some-policy', + action: 'read', + permissions: {}, + validation: {}, + presets: {}, + fields: ['*'], + }, + ]); +}); + +test('merge three permissions', () => { + const perm1: Permission = { + collection: 'collection', + policy: 'some-policy', + action: 'update', + permissions: {}, + validation: {}, + presets: {}, + fields: ['*'], + }; + + const perm2: Permission = { + collection: 'collection', + policy: 'some-policy', + action: 'read', + permissions: {}, + validation: {}, + presets: {}, + fields: ['aa', 'bb'], + }; + + const perm3: Permission = { + collection: 'collection', + policy: 'some-policy', + action: 'read', + permissions: { aa: { _eq: 1 } }, + validation: {}, + presets: {}, + fields: ['aa'], + }; + + let permissions = mergePermissions('and', [perm1, perm3], [perm2]); + + expect(permissions).toEqual([ + { + collection: 'collection', + policy: 'some-policy', + action: 'update', + permissions: {}, + validation: {}, + presets: {}, + fields: ['*'], + }, + { + collection: 'collection', + policy: 'some-policy', + action: 'read', + permissions: { + _and: [{ aa: { _eq: 1 } }, {}], + }, + validation: { + _and: [{}, {}], + }, + presets: {}, + fields: ['aa'], + }, + ]); + + permissions = mergePermissions('or', [perm1, perm3], [perm2]); + + expect(permissions).toEqual([ + { + collection: 'collection', + policy: 'some-policy', + action: 'update', + permissions: {}, + validation: {}, + presets: {}, + fields: ['*'], + }, + { + collection: 'collection', + policy: 'some-policy', + action: 'read', + permissions: {}, + validation: {}, + presets: {}, + fields: ['aa', 'bb'], + }, + ]); +});
api/src/permissions/utils/merge-permissions.ts+135 −0 added@@ -0,0 +1,135 @@ +import type { LogicalFilterAND, LogicalFilterOR, Permission } from '@directus/types'; +import { flatten, intersection, isEqual, merge, omit, uniq } from 'lodash-es'; + +// Adapted from https://github.com/directus/directus/blob/141b8adbf4dd8e06530a7929f34e3fc68a522053/api/src/utils/merge-permissions.ts#L4 +/** + * Merges multiple permission lists into a flat list of unique permissions. + * @param strategy `and` or `or` deduplicate permissions while `intersection` makes sure only common permissions across all lists are kept and overlapping permissions are merged through `and`. + * @param permissions List of permission lists to merge. + * @returns A flat list of unique permissions. + */ +export function mergePermissions( + strategy: 'and' | 'or' | 'intersection', + ...permissions: Permission[][] +): Permission[] { + let allPermissions; + + // Only keep permissions that are common to all lists + if (strategy === 'intersection') { + const permissionKeys = permissions.map((permissions) => { + return new Set(permissions.map((permission) => `${permission.collection}__${permission.action}`)); + }); + + const intersectionKeys = permissionKeys.reduce((acc, val) => { + return new Set([...acc].filter((x) => val.has(x))); + }, permissionKeys[0]!); + + const deduplicateSubpermissions = permissions.map((permissions) => { + return mergePermissions('or', permissions); + }); + + allPermissions = flatten(deduplicateSubpermissions).filter((permission) => { + return intersectionKeys.has(`${permission.collection}__${permission.action}`); + }); + + strategy = 'and'; + } else { + allPermissions = flatten(permissions); + } + + const mergedPermissions = allPermissions + .reduce((acc, val) => { + const key = `${val.collection}__${val.action}`; + const current = acc.get(key); + acc.set(key, current ? mergePermission(strategy, current, val) : val); + return acc; + }, new Map()) + .values(); + + return Array.from(mergedPermissions); +} + +export function mergePermission( + strategy: 'and' | 'or', + currentPerm: Permission, + newPerm: Permission, +): Omit<Permission, 'id' | 'system'> { + const logicalKey = `_${strategy}` as keyof LogicalFilterOR | keyof LogicalFilterAND; + + let { permissions, validation, fields, presets } = currentPerm; + + if (newPerm.permissions) { + if (currentPerm.permissions && Object.keys(currentPerm.permissions)[0] === logicalKey) { + permissions = { + [logicalKey]: [ + ...(currentPerm.permissions as LogicalFilterOR & LogicalFilterAND)[logicalKey], + newPerm.permissions, + ], + } as LogicalFilterAND | LogicalFilterOR; + } else if (currentPerm.permissions) { + // Empty {} supersedes other permissions in _OR merge + if (strategy === 'or' && (isEqual(currentPerm.permissions, {}) || isEqual(newPerm.permissions, {}))) { + permissions = {}; + } else { + permissions = { + [logicalKey]: [currentPerm.permissions, newPerm.permissions], + } as LogicalFilterAND | LogicalFilterOR; + } + } else { + permissions = { + [logicalKey]: [newPerm.permissions], + } as LogicalFilterAND | LogicalFilterOR; + } + } + + if (newPerm.validation) { + if (currentPerm.validation && Object.keys(currentPerm.validation)[0] === logicalKey) { + validation = { + [logicalKey]: [ + ...(currentPerm.validation as LogicalFilterOR & LogicalFilterAND)[logicalKey], + newPerm.validation, + ], + } as LogicalFilterAND | LogicalFilterOR; + } else if (currentPerm.validation) { + // Empty {} supersedes other validations in _OR merge + if (strategy === 'or' && (isEqual(currentPerm.validation, {}) || isEqual(newPerm.validation, {}))) { + validation = {}; + } else { + validation = { + [logicalKey]: [currentPerm.validation, newPerm.validation], + } as LogicalFilterAND | LogicalFilterOR; + } + } else { + validation = { + [logicalKey]: [newPerm.validation], + } as LogicalFilterAND | LogicalFilterOR; + } + } + + if (newPerm.fields) { + if (Array.isArray(currentPerm.fields) && strategy === 'or') { + fields = uniq([...currentPerm.fields, ...newPerm.fields]); + } else if (Array.isArray(currentPerm.fields) && strategy === 'and') { + fields = intersection(currentPerm.fields, newPerm.fields); + } else { + fields = newPerm.fields; + } + + if (fields.includes('*')) fields = ['*']; + } + + if (newPerm.presets) { + presets = merge({}, presets, newPerm.presets); + } + + return omit( + { + ...currentPerm, + permissions, + validation, + fields, + presets, + }, + ['id', 'system'], + ); +}
api/src/services/authentication.ts+1 −11 modified@@ -293,13 +293,8 @@ export class AuthenticationService { user_auth_data: 'u.auth_data', user_role: 'u.role', share_id: 'd.id', - share_item: 'd.item', - share_role: 'd.role', - share_collection: 'd.collection', share_start: 'd.date_start', share_end: 'd.date_end', - share_times_used: 'd.times_used', - share_max_uses: 'd.max_uses', }) .from('directus_sessions AS s') .leftJoin('directus_users AS u', 's.user', 'u.id') @@ -382,12 +377,7 @@ export class AuthenticationService { if (record.share_id) { tokenPayload.share = record.share_id; - tokenPayload.role = record.share_role; - - tokenPayload.share_scope = { - collection: record.share_collection, - item: record.share_item, - }; + tokenPayload.role = null; tokenPayload.app_access = false; tokenPayload.admin_access = false;
api/src/services/shares.ts+15 −10 modified@@ -3,6 +3,7 @@ import { ForbiddenError, InvalidCredentialsError } from '@directus/errors'; import type { Item, PrimaryKey } from '@directus/types'; import argon2 from 'argon2'; import jwt from 'jsonwebtoken'; +import { nanoid } from 'nanoid'; import { useLogger } from '../logger/index.js'; import { validateAccess } from '../permissions/modules/validate-access/validate-access.js'; import type { @@ -20,6 +21,7 @@ import { userName } from '../utils/user-name.js'; import { ItemsService } from './items.js'; import { MailService } from './mail/index.js'; import { UsersService } from './users.js'; +import { clearCache as clearPermissionsCache } from '../permissions/cache.js'; const env = useEnv(); const logger = useLogger(); @@ -48,20 +50,27 @@ export class SharesService extends ItemsService { return super.createOne(data, opts); } + override async updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]> { + await clearPermissionsCache(); + + return super.updateMany(keys, data, opts); + } + + override async deleteMany(keys: PrimaryKey[], opts?: MutationOptions): Promise<PrimaryKey[]> { + await clearPermissionsCache(); + + return super.deleteMany(keys, opts); + } + async login( payload: Record<string, any>, options?: Partial<{ session: boolean; }>, ): Promise<Omit<LoginResult, 'id'>> { - const { nanoid } = await import('nanoid'); - const record = await this.knex .select<ShareData>({ share_id: 'id', - share_role: 'role', - share_item: 'item', - share_collection: 'collection', share_start: 'date_start', share_end: 'date_end', share_times_used: 'times_used', @@ -96,12 +105,8 @@ export class SharesService extends ItemsService { const tokenPayload: DirectusTokenPayload = { app_access: false, admin_access: false, - role: record.share_role, + role: null, share: record.share_id, - share_scope: { - item: record.share_item, - collection: record.share_collection, - }, }; const refreshToken = nanoid(64);
api/src/types/auth.ts+0 −7 modified@@ -36,17 +36,10 @@ export type DirectusTokenPayload = { app_access: boolean | number; admin_access: boolean | number; share?: string; - share_scope?: { - collection: string; - item: string; - }; }; export type ShareData = { share_id: string; - share_role: string; - share_item: string; - share_collection: string; share_start: Date; share_end: Date; share_times_used: number;
api/src/utils/get-accountability-for-token.test.ts+0 −2 modified@@ -53,7 +53,6 @@ describe('getAccountabilityForToken', async () => { const token = jwt.sign( { share: 'share-id', - share_scope: 'share-scope', id: 'user-id', role: 'role-id', admin_access: 1, @@ -76,7 +75,6 @@ describe('getAccountabilityForToken', async () => { roles: [], ip: null, share: 'share-id', - share_scope: 'share-scope', }); });
api/src/utils/get-accountability-for-token.ts+1 −1 modified@@ -29,7 +29,7 @@ export async function getAccountabilityForToken( } if (payload.share) accountability.share = payload.share; - if (payload.share_scope) accountability.share_scope = payload.share_scope; + if (payload.id) accountability.user = payload.id; accountability.role = payload.role;
app/src/routes/shared/shared.vue+16 −1 modified@@ -1,7 +1,6 @@ <script setup lang="ts"> import api, { RequestError } from '@/api'; import { login, logout } from '@/auth'; -import { hydrate } from '@/hydrate'; import { getItemRoute } from '@/utils/get-route'; import { useCollection } from '@directus/composables'; import { useAppStore } from '@directus/stores'; @@ -11,6 +10,10 @@ import { computed, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import ShareItem from './components/share-item.vue'; +import { useFieldsStore } from '@/stores/fields'; +import { usePermissionsStore } from '@/stores/permissions'; +import { useRelationsStore } from '@/stores/relations'; +import { useCollectionsStore } from '@/stores/collections'; type ShareInfo = Pick< Share, @@ -116,6 +119,18 @@ async function handleAuth() { } } +async function hydrate() { + const collectionsStore = useCollectionsStore(); + const fieldsStore = useFieldsStore(); + const permissionsStore = usePermissionsStore(); + const relationsStore = useRelationsStore(); + + await collectionsStore.hydrate(); + await permissionsStore.hydrate(); + await fieldsStore.hydrate({ skipTranslation: true }); + await relationsStore.hydrate(); +} + async function authenticate() { authenticating.value = true;
.changeset/tidy-chicken-chew.md+8 −0 added@@ -0,0 +1,8 @@ +--- +'@directus/system-data': patch +'@directus/types': patch +'@directus/api': patch +'@directus/app': patch +--- + +Fix permission generation for Shares
packages/system-data/src/app-access-permissions/schema-access-permissions.yaml+3 −0 modified@@ -9,3 +9,6 @@ - collection: directus_relations action: read + +- collection: directus_translations + action: read
packages/system-data/src/fields/shares.yaml+0 −3 modified@@ -22,9 +22,6 @@ fields: width: half options: template: '{{name}}' - filter: - admin_access: - _eq: false - field: password special:
packages/types/src/accountability.ts+0 −1 modified@@ -10,7 +10,6 @@ export type Accountability = { admin: boolean; app: boolean; share?: string; - share_scope?: ShareScope; ip: string | null; userAgent?: string; origin?: string;
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
7- github.com/advisories/GHSA-pmf4-v838-29hgghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-24353ghsaADVISORY
- github.com/directus/directus/commit/e288a43a79613dada905da683f4919c6965ac804ghsax_refsource_MISCWEB
- github.com/directus/directus/pull/23716ghsax_refsource_MISCWEB
- github.com/directus/directus/releases/tag/v11.2.0ghsax_refsource_MISCWEB
- github.com/directus/directus/security/advisories/GHSA-pmf4-v838-29hgghsax_refsource_CONFIRMWEB
- www.youtube.com/watchghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.