Skip to content

Commit a9aeb9a

Browse files
fix(credential-groups): harden cursor and cache isolation
1 parent cfb99e1 commit a9aeb9a

3 files changed

Lines changed: 115 additions & 16 deletions

File tree

apps/sim/blocks/blocks/credential-group.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,13 @@ const CREDENTIAL_GROUP_CANONICAL_GROUP = {
2121
advancedIds: ['manualCredentialGroup'],
2222
} as const satisfies CanonicalGroup
2323

24-
async function fetchCachedCredentialGroups(signal?: AbortSignal) {
24+
async function fetchCachedCredentialGroups() {
2525
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
2626
if (!workspaceId) return []
2727

2828
return getQueryClient().fetchQuery({
2929
queryKey: credentialGroupKeys.list(workspaceId),
30-
queryFn: ({ signal: querySignal }) =>
31-
fetchCredentialGroupList(workspaceId, signal ?? querySignal),
30+
queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal),
3231
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
3332
})
3433
}
@@ -170,8 +169,8 @@ export const CredentialGroupBlock: BlockConfig<CredentialGroupBlockOutput> = {
170169
.map((group) => ({ label: group.name, id: group.id }))
171170
.sort((a, b) => a.label.localeCompare(b.label))
172171
},
173-
fetchOptionById: async (_blockId: string, optionId: string, signal?: AbortSignal) => {
174-
const groups = await fetchCachedCredentialGroups(signal)
172+
fetchOptionById: async (_blockId: string, optionId: string) => {
173+
const groups = await fetchCachedCredentialGroups()
175174
const group = groups.find((candidate) => candidate.id === optionId)
176175
return group ? { label: group.name, id: group.id } : null
177176
},
@@ -220,10 +219,10 @@ export const CredentialGroupBlock: BlockConfig<CredentialGroupBlockOutput> = {
220219
})
221220
.sort((a, b) => a.label.localeCompare(b.label))
222221
},
223-
fetchOptionById: async (blockId: string, optionId: string, signal?: AbortSignal) => {
222+
fetchOptionById: async (blockId: string, optionId: string) => {
224223
const credentialGroupId = resolveCredentialGroupIdForBlock(blockId)
225224
if (!credentialGroupId) return null
226-
const groups = await fetchCachedCredentialGroups(signal)
225+
const groups = await fetchCachedCredentialGroups()
227226
const group = groups.find((candidate) => candidate.id === credentialGroupId)
228227
const option = group?.options.find(
229228
(candidate) =>

apps/sim/lib/credential-groups/enrollments.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,70 @@ describe('listCredentialGroupEnrollments', () => {
171171
])
172172
})
173173

174+
it('rejects a cursor replayed against another credential group or filter', async () => {
175+
const remainingEnrollment = {
176+
...ENROLLMENT,
177+
id: 'enrollment-2',
178+
invitedAt: new Date('2026-08-10T12:00:00.000Z'),
179+
}
180+
dbChainMockFns.limit
181+
.mockResolvedValueOnce([{ options: [] }])
182+
.mockResolvedValueOnce([{ enrollment: ENROLLMENT }, { enrollment: remainingEnrollment }])
183+
.mockResolvedValueOnce([{ options: [] }])
184+
.mockResolvedValueOnce([{ options: [] }])
185+
186+
const filters = { statuses: ['invited' as const, 'completed' as const] }
187+
const firstPage = await listCredentialGroupEnrollments(
188+
'workspace-1',
189+
'group-1',
190+
1,
191+
undefined,
192+
filters
193+
)
194+
if (!firstPage.nextCursor) throw new Error('Expected a next enrollment cursor')
195+
196+
await expect(
197+
listCredentialGroupEnrollments('workspace-1', 'group-2', 50, firstPage.nextCursor, filters)
198+
).rejects.toMatchObject({ message: 'Enrollment cursor is invalid', status: 400 })
199+
await expect(
200+
listCredentialGroupEnrollments('workspace-1', 'group-1', 50, firstPage.nextCursor, {
201+
statuses: ['completed'],
202+
})
203+
).rejects.toMatchObject({ message: 'Enrollment cursor is invalid', status: 400 })
204+
})
205+
206+
it('rejects a cursor whose signed boundary was modified', async () => {
207+
const remainingEnrollment = {
208+
...ENROLLMENT,
209+
id: 'enrollment-2',
210+
invitedAt: new Date('2026-08-10T12:00:00.000Z'),
211+
}
212+
dbChainMockFns.limit
213+
.mockResolvedValueOnce([{ options: [] }])
214+
.mockResolvedValueOnce([{ enrollment: ENROLLMENT }, { enrollment: remainingEnrollment }])
215+
.mockResolvedValueOnce([{ options: [] }])
216+
217+
const firstPage = await listCredentialGroupEnrollments('workspace-1', 'group-1', 1)
218+
if (!firstPage.nextCursor) throw new Error('Expected a next enrollment cursor')
219+
const [encoded, signature] = firstPage.nextCursor.split('.')
220+
if (!encoded || !signature) throw new Error('Expected a signed enrollment cursor')
221+
const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as Record<
222+
string,
223+
unknown
224+
>
225+
payload.id = 'fabricated-boundary'
226+
const modifiedEncoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
227+
228+
await expect(
229+
listCredentialGroupEnrollments(
230+
'workspace-1',
231+
'group-1',
232+
50,
233+
`${modifiedEncoded}.${signature}`
234+
)
235+
).rejects.toMatchObject({ message: 'Enrollment cursor is invalid', status: 400 })
236+
})
237+
174238
it('rejects a malformed enrollment cursor', async () => {
175239
dbChainMockFns.limit.mockResolvedValueOnce([{ options: [] }])
176240

apps/sim/lib/credential-groups/enrollments.ts

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@ import {
77
user,
88
workspace,
99
} from '@sim/db/schema'
10+
import { safeCompare } from '@sim/security/compare'
1011
import { sha256Hex } from '@sim/security/hash'
12+
import { hmacSha256Hex } from '@sim/security/hmac'
1113
import { getErrorMessage } from '@sim/utils/errors'
1214
import { generateId } from '@sim/utils/id'
1315
import { normalizeEmail, truncate } from '@sim/utils/string'
1416
import { and, count, desc, eq, inArray, lt, or, sql } from 'drizzle-orm'
1517
import { renderCredentialGroupInvitationEmail } from '@/components/emails/credential-groups/render'
1618
import { getCredentialGroupInvitationSubject } from '@/components/emails/subjects'
1719
import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
20+
import { env } from '@/lib/core/config/env'
1821
import { getBaseUrl } from '@/lib/core/utils/urls'
1922
import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability'
2023
import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry'
@@ -134,26 +137,41 @@ export class CredentialGroupEnrollmentError extends Error {
134137
}
135138

136139
interface CredentialGroupEnrollmentCursor {
140+
v: 1
137141
id: string
138142
invitedAt: Date
139143
}
140144

141145
function encodeCredentialGroupEnrollmentCursor(
142-
enrollment: Pick<EnrollmentRow, 'id' | 'invitedAt'>
146+
enrollment: Pick<EnrollmentRow, 'id' | 'invitedAt'>,
147+
scope: string
143148
): string {
144-
return Buffer.from(
145-
JSON.stringify({ id: enrollment.id, invitedAt: enrollment.invitedAt.toISOString() })
149+
const encoded = Buffer.from(
150+
JSON.stringify({ v: 1, id: enrollment.id, invitedAt: enrollment.invitedAt.toISOString() })
146151
).toString('base64url')
152+
return `${encoded}.${hmacSha256Hex(`${encoded}.${scope}`, env.BETTER_AUTH_SECRET)}`
147153
}
148154

149-
function decodeCredentialGroupEnrollmentCursor(cursor: string): CredentialGroupEnrollmentCursor {
155+
function decodeCredentialGroupEnrollmentCursor(
156+
cursor: string,
157+
scope: string
158+
): CredentialGroupEnrollmentCursor {
150159
try {
151-
const decoded: unknown = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
160+
const [encoded, signature, extra] = cursor.split('.')
161+
if (!encoded || !signature || extra !== undefined) {
162+
throw new Error('Cursor token is malformed')
163+
}
164+
const expectedSignature = hmacSha256Hex(`${encoded}.${scope}`, env.BETTER_AUTH_SECRET)
165+
if (!safeCompare(signature, expectedSignature)) {
166+
throw new Error('Cursor signature is invalid')
167+
}
168+
const decoded: unknown = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'))
152169
if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) {
153170
throw new Error('Cursor payload must be an object')
154171
}
155-
const { id, invitedAt: invitedAtValue } = decoded as Record<string, unknown>
172+
const { v, id, invitedAt: invitedAtValue } = decoded as Record<string, unknown>
156173
if (
174+
v !== 1 ||
157175
typeof id !== 'string' ||
158176
!id.trim() ||
159177
id !== id.trim() ||
@@ -166,12 +184,27 @@ function decodeCredentialGroupEnrollmentCursor(cursor: string): CredentialGroupE
166184
if (Number.isNaN(invitedAt.getTime()) || invitedAt.toISOString() !== invitedAtValue) {
167185
throw new Error('Cursor timestamp is invalid')
168186
}
169-
return { id, invitedAt }
187+
return { v, id, invitedAt }
170188
} catch {
171189
throw new CredentialGroupEnrollmentError('Enrollment cursor is invalid', 400)
172190
}
173191
}
174192

193+
function credentialGroupEnrollmentCursorScope(
194+
workspaceId: string,
195+
groupId: string,
196+
filters: ListCredentialGroupEnrollmentFilters
197+
): string {
198+
return sha256Hex(
199+
JSON.stringify({
200+
workspaceId,
201+
groupId,
202+
email: filters.email || null,
203+
statuses: [...new Set(filters.statuses ?? [])].sort(),
204+
})
205+
)
206+
}
207+
175208
function hashInvitationToken(token: string): string {
176209
return sha256Hex(token)
177210
}
@@ -482,7 +515,10 @@ export async function listCredentialGroupEnrollments(
482515
.filter((option) => option.status === 'active')
483516
.map((option) => option.id)
484517

485-
const cursorPosition = cursor ? decodeCredentialGroupEnrollmentCursor(cursor) : undefined
518+
const cursorScope = credentialGroupEnrollmentCursorScope(workspaceId, groupId, filters)
519+
const cursorPosition = cursor
520+
? decodeCredentialGroupEnrollmentCursor(cursor, cursorScope)
521+
: undefined
486522

487523
const rows = await db
488524
.select({ enrollment: credentialGroupEnrollment })
@@ -564,7 +600,7 @@ export async function listCredentialGroupEnrollments(
564600
connections: connectionsByEnrollment.get(enrollment.id) ?? [],
565601
})),
566602
nextCursor: nextCursorEnrollment
567-
? encodeCredentialGroupEnrollmentCursor(nextCursorEnrollment)
603+
? encodeCredentialGroupEnrollmentCursor(nextCursorEnrollment, cursorScope)
568604
: null,
569605
}
570606
}

0 commit comments

Comments
 (0)