diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts index 5f821fe2db5..55bec3b9ab8 100644 --- a/apps/sim/blocks/blocks/credential-group.ts +++ b/apps/sim/blocks/blocks/credential-group.ts @@ -21,14 +21,13 @@ const CREDENTIAL_GROUP_CANONICAL_GROUP = { advancedIds: ['manualCredentialGroup'], } as const satisfies CanonicalGroup -async function fetchCachedCredentialGroups(signal?: AbortSignal) { +async function fetchCachedCredentialGroups() { const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId if (!workspaceId) return [] return getQueryClient().fetchQuery({ queryKey: credentialGroupKeys.list(workspaceId), - queryFn: ({ signal: querySignal }) => - fetchCredentialGroupList(workspaceId, signal ?? querySignal), + queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal), staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, }) } @@ -170,8 +169,8 @@ export const CredentialGroupBlock: BlockConfig = { .map((group) => ({ label: group.name, id: group.id })) .sort((a, b) => a.label.localeCompare(b.label)) }, - fetchOptionById: async (_blockId: string, optionId: string, signal?: AbortSignal) => { - const groups = await fetchCachedCredentialGroups(signal) + fetchOptionById: async (_blockId: string, optionId: string) => { + const groups = await fetchCachedCredentialGroups() const group = groups.find((candidate) => candidate.id === optionId) return group ? { label: group.name, id: group.id } : null }, @@ -220,10 +219,10 @@ export const CredentialGroupBlock: BlockConfig = { }) .sort((a, b) => a.label.localeCompare(b.label)) }, - fetchOptionById: async (blockId: string, optionId: string, signal?: AbortSignal) => { + fetchOptionById: async (blockId: string, optionId: string) => { const credentialGroupId = resolveCredentialGroupIdForBlock(blockId) if (!credentialGroupId) return null - const groups = await fetchCachedCredentialGroups(signal) + const groups = await fetchCachedCredentialGroups() const group = groups.find((candidate) => candidate.id === credentialGroupId) const option = group?.options.find( (candidate) => diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts index fc9bf7dc522..937e4a819bb 100644 --- a/apps/sim/lib/api/contracts/credential-groups.ts +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -5,6 +5,7 @@ import { CREDENTIAL_GROUP_PROVIDER_IDS, CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, } from '@/lib/credential-groups/providers' +import { CREDENTIAL_GROUP_ENROLLMENT_CURSOR_MAX_LENGTH } from '@/lib/credential-groups/types' export const credentialGroupProviderSchema = z.enum(CREDENTIAL_GROUP_PROVIDER_IDS) export const credentialGroupStatusSchema = z.enum(['active', 'disabled']) @@ -169,7 +170,11 @@ export const slackCredentialGroupConfigurationCallbackQuerySchema = credentialGroupOAuthCallbackQuerySchema export const credentialGroupEnrollmentListQuerySchema = z.object({ - cursor: z.string().min(1, 'Enrollment cursor cannot be empty').max(128).optional(), + cursor: z + .string() + .min(1, 'Enrollment cursor cannot be empty') + .max(CREDENTIAL_GROUP_ENROLLMENT_CURSOR_MAX_LENGTH) + .optional(), limit: z.coerce.number().int().min(1).max(100).default(50), }) diff --git a/apps/sim/lib/credential-groups/enrollments.test.ts b/apps/sim/lib/credential-groups/enrollments.test.ts index b7696e2ee69..67fd419fd5e 100644 --- a/apps/sim/lib/credential-groups/enrollments.test.ts +++ b/apps/sim/lib/credential-groups/enrollments.test.ts @@ -4,6 +4,7 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { inArray } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { credentialGroupEnrollmentListQuerySchema } from '@/lib/api/contracts/credential-groups' const { adapter } = vi.hoisted(() => ({ adapter: { @@ -151,6 +152,9 @@ describe('listCredentialGroupEnrollments', () => { statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'], }) if (!firstPage.nextCursor) throw new Error('Expected a next enrollment cursor') + expect( + credentialGroupEnrollmentListQuerySchema.parse({ cursor: firstPage.nextCursor }).cursor + ).toBe(firstPage.nextCursor) const result = await listCredentialGroupEnrollments( 'workspace-1', 'group-1', @@ -171,6 +175,70 @@ describe('listCredentialGroupEnrollments', () => { ]) }) + it('rejects a cursor replayed against another credential group or filter', async () => { + const remainingEnrollment = { + ...ENROLLMENT, + id: 'enrollment-2', + invitedAt: new Date('2026-08-10T12:00:00.000Z'), + } + dbChainMockFns.limit + .mockResolvedValueOnce([{ options: [] }]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }, { enrollment: remainingEnrollment }]) + .mockResolvedValueOnce([{ options: [] }]) + .mockResolvedValueOnce([{ options: [] }]) + + const filters = { statuses: ['invited' as const, 'completed' as const] } + const firstPage = await listCredentialGroupEnrollments( + 'workspace-1', + 'group-1', + 1, + undefined, + filters + ) + if (!firstPage.nextCursor) throw new Error('Expected a next enrollment cursor') + + await expect( + listCredentialGroupEnrollments('workspace-1', 'group-2', 50, firstPage.nextCursor, filters) + ).rejects.toMatchObject({ message: 'Enrollment cursor is invalid', status: 400 }) + await expect( + listCredentialGroupEnrollments('workspace-1', 'group-1', 50, firstPage.nextCursor, { + statuses: ['completed'], + }) + ).rejects.toMatchObject({ message: 'Enrollment cursor is invalid', status: 400 }) + }) + + it('rejects a cursor whose signed boundary was modified', async () => { + const remainingEnrollment = { + ...ENROLLMENT, + id: 'enrollment-2', + invitedAt: new Date('2026-08-10T12:00:00.000Z'), + } + dbChainMockFns.limit + .mockResolvedValueOnce([{ options: [] }]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }, { enrollment: remainingEnrollment }]) + .mockResolvedValueOnce([{ options: [] }]) + + const firstPage = await listCredentialGroupEnrollments('workspace-1', 'group-1', 1) + if (!firstPage.nextCursor) throw new Error('Expected a next enrollment cursor') + const [encoded, signature] = firstPage.nextCursor.split('.') + if (!encoded || !signature) throw new Error('Expected a signed enrollment cursor') + const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as Record< + string, + unknown + > + payload.id = 'fabricated-boundary' + const modifiedEncoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + + await expect( + listCredentialGroupEnrollments( + 'workspace-1', + 'group-1', + 50, + `${modifiedEncoded}.${signature}` + ) + ).rejects.toMatchObject({ message: 'Enrollment cursor is invalid', status: 400 }) + }) + it('rejects a malformed enrollment cursor', async () => { dbChainMockFns.limit.mockResolvedValueOnce([{ options: [] }]) diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index c3403131c01..fe7cfdb12af 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -7,7 +7,9 @@ import { user, workspace, } from '@sim/db/schema' +import { safeCompare } from '@sim/security/compare' import { sha256Hex } from '@sim/security/hash' +import { hmacSha256Hex } from '@sim/security/hmac' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { normalizeEmail, truncate } from '@sim/utils/string' @@ -15,6 +17,7 @@ import { and, count, desc, eq, inArray, lt, or, sql } from 'drizzle-orm' import { renderCredentialGroupInvitationEmail } from '@/components/emails/credential-groups/render' import { getCredentialGroupInvitationSubject } from '@/components/emails/subjects' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' @@ -134,26 +137,41 @@ export class CredentialGroupEnrollmentError extends Error { } interface CredentialGroupEnrollmentCursor { + v: 1 id: string invitedAt: Date } function encodeCredentialGroupEnrollmentCursor( - enrollment: Pick + enrollment: Pick, + scope: string ): string { - return Buffer.from( - JSON.stringify({ id: enrollment.id, invitedAt: enrollment.invitedAt.toISOString() }) + const encoded = Buffer.from( + JSON.stringify({ v: 1, id: enrollment.id, invitedAt: enrollment.invitedAt.toISOString() }) ).toString('base64url') + return `${encoded}.${hmacSha256Hex(`${encoded}.${scope}`, env.BETTER_AUTH_SECRET)}` } -function decodeCredentialGroupEnrollmentCursor(cursor: string): CredentialGroupEnrollmentCursor { +function decodeCredentialGroupEnrollmentCursor( + cursor: string, + scope: string +): CredentialGroupEnrollmentCursor { try { - const decoded: unknown = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) + const [encoded, signature, extra] = cursor.split('.') + if (!encoded || !signature || extra !== undefined) { + throw new Error('Cursor token is malformed') + } + const expectedSignature = hmacSha256Hex(`${encoded}.${scope}`, env.BETTER_AUTH_SECRET) + if (!safeCompare(signature, expectedSignature)) { + throw new Error('Cursor signature is invalid') + } + const decoded: unknown = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) { throw new Error('Cursor payload must be an object') } - const { id, invitedAt: invitedAtValue } = decoded as Record + const { v, id, invitedAt: invitedAtValue } = decoded as Record if ( + v !== 1 || typeof id !== 'string' || !id.trim() || id !== id.trim() || @@ -166,12 +184,27 @@ function decodeCredentialGroupEnrollmentCursor(cursor: string): CredentialGroupE if (Number.isNaN(invitedAt.getTime()) || invitedAt.toISOString() !== invitedAtValue) { throw new Error('Cursor timestamp is invalid') } - return { id, invitedAt } + return { v, id, invitedAt } } catch { throw new CredentialGroupEnrollmentError('Enrollment cursor is invalid', 400) } } +function credentialGroupEnrollmentCursorScope( + workspaceId: string, + groupId: string, + filters: ListCredentialGroupEnrollmentFilters +): string { + return sha256Hex( + JSON.stringify({ + workspaceId, + groupId, + email: filters.email || null, + statuses: [...new Set(filters.statuses ?? [])].sort(), + }) + ) +} + function hashInvitationToken(token: string): string { return sha256Hex(token) } @@ -482,7 +515,10 @@ export async function listCredentialGroupEnrollments( .filter((option) => option.status === 'active') .map((option) => option.id) - const cursorPosition = cursor ? decodeCredentialGroupEnrollmentCursor(cursor) : undefined + const cursorScope = credentialGroupEnrollmentCursorScope(workspaceId, groupId, filters) + const cursorPosition = cursor + ? decodeCredentialGroupEnrollmentCursor(cursor, cursorScope) + : undefined const rows = await db .select({ enrollment: credentialGroupEnrollment }) @@ -564,7 +600,7 @@ export async function listCredentialGroupEnrollments( connections: connectionsByEnrollment.get(enrollment.id) ?? [], })), nextCursor: nextCursorEnrollment - ? encodeCredentialGroupEnrollmentCursor(nextCursorEnrollment) + ? encodeCredentialGroupEnrollmentCursor(nextCursorEnrollment, cursorScope) : null, } } diff --git a/apps/sim/lib/credential-groups/types.ts b/apps/sim/lib/credential-groups/types.ts index a9c39dc5ebe..6c80f3a30f7 100644 --- a/apps/sim/lib/credential-groups/types.ts +++ b/apps/sim/lib/credential-groups/types.ts @@ -1,5 +1,7 @@ import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' +export const CREDENTIAL_GROUP_ENROLLMENT_CURSOR_MAX_LENGTH = 256 + interface CredentialGroupOptionInputBase { label: string required: boolean