Skip to content

Commit 932c688

Browse files
committed
fix(api): reject an undecodable offset cursor on v2 table rows
GET /api/v2/tables/{tableId}/rows coerced an undecodable pagination cursor to offset 0 and re-served page one. A client paging forward reads that as a fresh first page and can loop over it forever. Every sibling v2 cursor list — logs, files, workflows, workflow runs, workflow versions, workspace members, tables, knowledge documents — already rejects with a validation error instead. Extracts the offset-cursor decode both offset-paginated v2 routes had inlined into `decodeOffsetCursor`, next to the existing `decodeSortedCursor`, so the reject-don't-restart rule has one home.
1 parent 013b808 commit 932c688

4 files changed

Lines changed: 69 additions & 26 deletions

File tree

apps/sim/app/api/v2/knowledge/[id]/documents/route.ts

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
3030
import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types'
3131
import { validateFileType } from '@/lib/uploads/utils/validation'
3232
import { serializeDate } from '@/app/api/v1/knowledge/utils'
33-
import { decodeCursor, encodeCursor } from '@/app/api/v2/lib/response'
33+
import { decodeOffsetCursor, encodeCursor } from '@/app/api/v2/lib/response'
3434

3535
export const dynamic = 'force-dynamic'
3636
export const revalidate = 0
@@ -72,25 +72,16 @@ export const GET = defineV2JsonRoute({
7272
operation: knowledgeOperations.listDocuments,
7373
rateLimit: v2RateLimits.publicApi,
7474
errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization,
75-
mapInput: ({ params, query }) => {
76-
const decodedCursor = query.cursor ? decodeCursor<{ offset: number }>(query.cursor) : null
77-
if (
78-
query.cursor &&
79-
(!decodedCursor || !Number.isInteger(decodedCursor.offset) || decodedCursor.offset < 0)
80-
) {
81-
throw new OrchestrationError('validation', 'Invalid cursor')
82-
}
83-
return {
84-
knowledgeBaseId: params.id,
85-
assertedWorkspaceId: query.workspaceId,
86-
enabledFilter: query.enabledFilter,
87-
search: query.search,
88-
limit: query.limit,
89-
offset: decodedCursor?.offset ?? 0,
90-
sortBy: query.sortBy,
91-
sortOrder: query.sortOrder,
92-
}
93-
},
75+
mapInput: ({ params, query }) => ({
76+
knowledgeBaseId: params.id,
77+
assertedWorkspaceId: query.workspaceId,
78+
enabledFilter: query.enabledFilter,
79+
search: query.search,
80+
limit: query.limit,
81+
offset: decodeOffsetCursor(query.cursor),
82+
sortBy: query.sortBy,
83+
sortOrder: query.sortOrder,
84+
}),
9485
useCase: listKnowledgeDocuments,
9586
present: ({ documents, pagination }) => ({
9687
data: documents.map(toV2DocumentSummary),

apps/sim/app/api/v2/lib/response.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { NextResponse } from 'next/server'
22
import type { ZodError } from 'zod'
33
import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query'
44
import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server'
5-
import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types'
5+
import {
6+
asOrchestrationError,
7+
OrchestrationError,
8+
type OrchestrationErrorCode,
9+
} from '@/lib/core/orchestration/types'
610
import type { RateLimitResult, WorkspaceAccessError } from '@/app/api/v1/middleware'
711

812
/**
@@ -158,6 +162,26 @@ export function decodeCursor<T = Record<string, unknown>>(cursor: string): T | n
158162
}
159163
}
160164

165+
/**
166+
* Reads back an offset cursor minted by `encodeCursor({ offset })`.
167+
*
168+
* An absent cursor means page one. A cursor that is not valid base64-JSON, or
169+
* that does not carry a non-negative integer `offset`, is REJECTED rather than
170+
* coerced to 0: silently restarting at page one while the caller believes it is
171+
* paging forward makes a paging client loop over the first page forever.
172+
*
173+
* Throwing an `OrchestrationError('validation')` is what the other v2 cursor
174+
* lists do, and the v2 error policies render it as the canonical 400.
175+
*/
176+
export function decodeOffsetCursor(cursor: string | undefined): number {
177+
if (!cursor) return 0
178+
const decoded = decodeCursor<{ offset?: unknown }>(cursor)
179+
if (!decoded || !Number.isInteger(decoded.offset) || (decoded.offset as number) < 0) {
180+
throw new OrchestrationError('validation', 'Invalid cursor')
181+
}
182+
return decoded.offset as number
183+
}
184+
161185
/**
162186
* The sort a keyset cursor was minted under, as it is written into the cursor
163187
* payload. Comparing the whole string is what makes a mid-pagination sort

apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,36 @@ describe('/api/v2/tables/[tableId]/rows', () => {
111111
})
112112
})
113113

114-
it('retains malformed GET cursor fallback compatibility', async () => {
115-
const req = request('GET', undefined, `?workspaceId=${WORKSPACE_ID}&limit=25&cursor=malformed`)
114+
/**
115+
* Coercing an undecodable cursor to offset 0 re-served page one while the
116+
* client believed it was paging forward, which loops a paging client forever.
117+
* Every sibling v2 cursor list rejects instead, so this one does too.
118+
*/
119+
it.each([
120+
['undecodable base64-JSON', 'malformed'],
121+
['a payload with no offset', Buffer.from(JSON.stringify({ o: 5 })).toString('base64')],
122+
['a non-integer offset', Buffer.from(JSON.stringify({ offset: 1.5 })).toString('base64')],
123+
['a negative offset', Buffer.from(JSON.stringify({ offset: -1 })).toString('base64')],
124+
])('rejects a GET cursor with %s instead of restarting pagination', async (_label, cursor) => {
125+
const req = request(
126+
'GET',
127+
undefined,
128+
`?workspaceId=${WORKSPACE_ID}&limit=25&cursor=${encodeURIComponent(cursor)}`
129+
)
130+
const response = await GET(req, CONTEXT)
131+
132+
expect(response.status).toBe(400)
133+
expect((await response.json()).error).toMatchObject({ message: 'Invalid cursor' })
134+
expect(mocks.listRows).not.toHaveBeenCalled()
135+
})
136+
137+
it('resumes at the encoded offset for a well-formed cursor', async () => {
138+
const cursor = Buffer.from(JSON.stringify({ offset: 50 })).toString('base64')
139+
const req = request(
140+
'GET',
141+
undefined,
142+
`?workspaceId=${WORKSPACE_ID}&limit=25&cursor=${encodeURIComponent(cursor)}`
143+
)
116144
const response = await GET(req, CONTEXT)
117145

118146
expect(response.status).toBe(200)
@@ -122,7 +150,7 @@ describe('/api/v2/tables/[tableId]/rows', () => {
122150
tableId: 'table-1',
123151
assertedWorkspaceId: WORKSPACE_ID,
124152
limit: 25,
125-
offset: 0,
153+
offset: 50,
126154
},
127155
request: req,
128156
})

apps/sim/app/api/v2/tables/[tableId]/rows/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
updateTableRows,
1515
} from '@/lib/table/application/rows'
1616
import { namedRowMapper } from '@/lib/table/cell-format'
17-
import { decodeCursor, encodeCursor } from '@/app/api/v2/lib/response'
17+
import { decodeOffsetCursor, encodeCursor } from '@/app/api/v2/lib/response'
1818
import { toApiRow } from '@/app/api/v2/tables/utils'
1919

2020
export const dynamic = 'force-dynamic'
@@ -30,7 +30,7 @@ export const GET = defineV2JsonRoute({
3030
tableId: params.tableId,
3131
assertedWorkspaceId: query.workspaceId,
3232
limit: query.limit,
33-
offset: query.cursor ? (decodeCursor<{ offset: number }>(query.cursor)?.offset ?? 0) : 0,
33+
offset: decodeOffsetCursor(query.cursor),
3434
}),
3535
useCase: listTableRows,
3636
present: ({ table, rows, nextOffset }) => {

0 commit comments

Comments
 (0)