Skip to content

Commit 6bf7ee4

Browse files
n0wshvelocityx034-specemmatown
committed
Fix isFilterable bypass via cursor parameter in findMany
The cursor parameter in findMany accepted uniqueWhere inputs without validating them against isFilterable access controls. This allowed users to bypass dynamic isFilterable functions by using cursor instead of where to probe for records by protected field values. Add checkFilterOrderAccess validation for cursor fields, matching the existing validation for where fields. Add tests for cursor-based filtering with both allowed and denied isFilterable configurations. --------- Co-authored-by: velocityx034-spec <velocityx034@gmail.com> Co-authored-by: Emma Hamilton <git@emmas.town>
1 parent c48c76e commit 6bf7ee4

File tree

3 files changed

+47
-0
lines changed

3 files changed

+47
-0
lines changed

.changeset/young-mangos-go.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@keystone-6/core": patch
3+
---
4+
5+
Fix `isFilterable` bypass via `cursor` parameter in `findMany` query

packages/core/src/lib/core/queries/resolvers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ export async function findMany(
137137
// check filter access (TODO: why isn't this using resolvedWhere)
138138
await checkFilterOrderAccess([...traverse(list, where)], context, 'filter')
139139

140+
// check filter access for cursor
141+
if (cursor) {
142+
await checkFilterOrderAccess([...traverse(list, cursor)], context, 'filter')
143+
}
144+
140145
// WARNING: this checks .isOrderable
141146
const orderBy = await resolveOrderBy(rawOrderBy, list, context)
142147

tests/api-tests/queries/filters.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const runner = setupTestRunner({
2222
many_many_many_dashes: text(),
2323
multi____dash: text({ isFilterable: true }),
2424
email: text({ isIndexed: 'unique', isFilterable: true, db: { isNullable: true } }),
25+
uniqueButNotFilterable: text({ isIndexed: 'unique', isFilterable: () => false, db: { isNullable: true } }),
2526

2627
filterFalse: integer({ isFilterable: false }),
2728
filterTrue: integer({ isFilterable: true }),
@@ -392,6 +393,42 @@ describe('isFilterable', () => {
392393
)
393394
})
394395

396+
describe('isFilterable with cursor', () => {
397+
test(
398+
'cursor with isFilterable: () => false is denied',
399+
runner(async ({ context }) => {
400+
await context.query.User.createOne({
401+
data: { uniqueButNotFilterable: 'secret-value' },
402+
})
403+
const { data, errors } = await context.graphql.raw({
404+
query: '{ users(cursor: { uniqueButNotFilterable: "secret-value" }, take: 1) { id } }',
405+
})
406+
// cursor should be checked against isFilterable the same as where
407+
expect(data).toEqual({ users: null })
408+
expectFilterDenied(errors, [
409+
{
410+
path: ['users'],
411+
message: 'Access denied: You cannot filter by User.uniqueButNotFilterable',
412+
},
413+
])
414+
})
415+
)
416+
417+
test(
418+
'cursor with isFilterable: true is allowed',
419+
runner(async ({ context }) => {
420+
const item = await context.query.User.createOne({
421+
data: { email: 'cursor-allowed@example.com' },
422+
})
423+
const { data, errors } = await context.graphql.raw({
424+
query: '{ users(cursor: { email: "cursor-allowed@example.com" }, take: 1) { id } }',
425+
})
426+
expect(errors).toBe(undefined)
427+
expect(data).toEqual({ users: [{ id: item.id }] })
428+
})
429+
)
430+
})
431+
395432
describe('defaultIsFilterable', () => {
396433
test(
397434
'defaultIsFilterable: undefined',

0 commit comments

Comments
 (0)