Skip to content

Commit 2dc2e7c

Browse files
authored
fix: isolate payload-preferences by auth collection (#15425)
### What Fixes preferences being accessible across different auth collections in multi-auth setups. ### Why The `preferenceAccess` function only checked user ID without verifying which auth collection the user belonged to. In setups with multiple auth collections using sequential IDs, this could allow unintended access to preferences. ### How Updated `preferenceAccess` to check both user ID and auth collection, consistent with the existing operation handlers. Added tests to verify proper isolation.
1 parent 99d61db commit 2dc2e7c

File tree

2 files changed

+114
-4
lines changed

2 files changed

+114
-4
lines changed

packages/payload/src/preferences/config.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { CollectionConfig } from '../collections/config/types.js'
22
import type { Access, Config } from '../config/types.js'
3+
import type { Where } from '../types/index.js'
34

45
import { deleteHandler } from './requestHandlers/delete.js'
56
import { findByIDHandler } from './requestHandlers/findOne.js'
@@ -10,11 +11,21 @@ const preferenceAccess: Access = ({ req }) => {
1011
return false
1112
}
1213

13-
return {
14+
const userValueCondition: Where = {
1415
'user.value': {
15-
equals: req?.user?.id,
16+
equals: req.user.id,
17+
},
18+
}
19+
20+
const userRelationCondition: Where = {
21+
'user.relationTo': {
22+
equals: req.user.collection,
1623
},
1724
}
25+
26+
return {
27+
and: [userValueCondition, userRelationCondition],
28+
}
1829
}
1930

2031
export const preferencesCollectionSlug = 'payload-preferences'

test/auth/int.spec.ts

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -560,8 +560,8 @@ describe('Auth', () => {
560560
},
561561
})
562562

563-
expect(result.docs[0].value.property).toStrictEqual('updated')
564-
expect(result.docs[0].value.property2).toStrictEqual('updated')
563+
expect((result.docs[0]?.value as any)?.property).toStrictEqual('updated')
564+
expect((result.docs[0]?.value as any)?.property2).toStrictEqual('updated')
565565

566566
expect(result.docs).toHaveLength(1)
567567
})
@@ -600,6 +600,105 @@ describe('Auth', () => {
600600
})
601601
})
602602

603+
describe('Cross-Collection Preference Isolation', () => {
604+
const adminKey = 'cross-collection-admin'
605+
const publicKey = 'cross-collection-public'
606+
let publicUserToken: string
607+
let publicUserId: number | string
608+
const createdIDs: (number | string)[] = []
609+
610+
beforeAll(async () => {
611+
// Admin creates preference
612+
const adminPref = await restClient.POST(`/payload-preferences/${adminKey}`, {
613+
body: JSON.stringify({ value: { data: 'admin-sensitive' } }),
614+
headers: { Authorization: `JWT ${token}` },
615+
})
616+
createdIDs.push(((await adminPref.json()) as any).doc.id)
617+
618+
// Create and verify public user
619+
const userRes = await restClient.POST(`/${publicUsersSlug}`, {
620+
body: JSON.stringify({ email: 'crosscollection@test.com', password: 'test123!' }),
621+
headers: { Authorization: `JWT ${token}` },
622+
})
623+
publicUserId = ((await userRes.json()) as any).doc.id
624+
625+
const user = await payload.findByID({
626+
collection: publicUsersSlug,
627+
id: publicUserId,
628+
showHiddenFields: true,
629+
})
630+
await restClient.POST(`/${publicUsersSlug}/verify/${(user as any)._verificationToken}`)
631+
632+
// Login as public user
633+
const login = await restClient.POST(`/${publicUsersSlug}/login`, {
634+
body: JSON.stringify({ email: 'crosscollection@test.com', password: 'test123!' }),
635+
})
636+
publicUserToken = ((await login.json()) as any).token
637+
638+
// Public user creates preference
639+
const publicPref = await restClient.POST(`/payload-preferences/${publicKey}`, {
640+
body: JSON.stringify({ value: { data: 'public-data' } }),
641+
headers: { Authorization: `JWT ${publicUserToken}` },
642+
})
643+
createdIDs.push(((await publicPref.json()) as any).doc.id)
644+
})
645+
646+
afterAll(async () => {
647+
await Promise.all(
648+
createdIDs.map((id) =>
649+
payload.delete({ collection: 'payload-preferences', id }).catch(() => {}),
650+
),
651+
)
652+
if (publicUserId) {
653+
await payload.delete({ collection: publicUsersSlug, id: publicUserId }).catch(() => {})
654+
}
655+
})
656+
657+
it('should only return own preferences via REST find', async () => {
658+
const res = await restClient.GET('/payload-preferences', {
659+
headers: { Authorization: `JWT ${publicUserToken}` },
660+
})
661+
const data: any = await res.json()
662+
663+
expect(data.docs).toHaveLength(1)
664+
expect(data.docs[0].user.relationTo).toBe(publicUsersSlug)
665+
expect(data.docs.some((doc: any) => doc.user.relationTo === 'users')).toBe(false)
666+
})
667+
668+
it('should not delete other collection preferences via REST', async () => {
669+
const before = await payload.find({
670+
collection: 'payload-preferences',
671+
where: { 'user.relationTo': { equals: 'users' } },
672+
})
673+
expect(before.docs).toHaveLength(1)
674+
675+
await restClient.DELETE(`/payload-preferences?where[key][equals]=${adminKey}`, {
676+
headers: { Authorization: `JWT ${publicUserToken}` },
677+
})
678+
679+
const after = await payload.find({
680+
collection: 'payload-preferences',
681+
where: { 'user.relationTo': { equals: 'users' } },
682+
})
683+
expect(after.docs).toHaveLength(1)
684+
expect((after.docs[0]?.value as any)?.data).toBe('admin-sensitive')
685+
})
686+
687+
it('should isolate preferences by user ID and collection', async () => {
688+
const publicPrefs = await payload.find({
689+
collection: 'payload-preferences',
690+
where: { 'user.relationTo': { equals: publicUsersSlug } },
691+
})
692+
expect(publicPrefs.docs).toHaveLength(1)
693+
694+
const adminPrefs = await payload.find({
695+
collection: 'payload-preferences',
696+
where: { 'user.relationTo': { equals: 'users' } },
697+
})
698+
expect(adminPrefs.docs).toHaveLength(1)
699+
})
700+
})
701+
603702
describe('Account Locking', () => {
604703
const userEmail = 'lock@me.com'
605704

0 commit comments

Comments
 (0)