fix(security): gate credential-set invitation listing to admins and drop token (#5243)

GET /api/credential-sets/[id]/invite listed every invitation row — including
the bearer token — to any org member, matching neither its sibling methods
(POST/DELETE enforce admin/owner) nor the self-scoped /invitations endpoint.
A non-privileged member could harvest a null-email invite token and self-join
the credential set via POST /api/credential-sets/invite/[token].

- Add the admin/owner role gate to GET, matching POST/DELETE on the same route
- Project explicit columns (drop token) so the secret is never returned to the
  management list; the creating admin still receives it via the create response
This commit is contained in:
Waleed
2026-06-27 12:59:05 -07:00
committed by GitHub
parent 9b66b40aab
commit ac95a27dab
3 changed files with 28 additions and 5 deletions
@@ -68,8 +68,20 @@ export const GET = withRouteHandler(
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
if (result.role !== 'admin' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
const invitations = await db
.select()
.select({
id: credentialSetInvitation.id,
credentialSetId: credentialSetInvitation.credentialSetId,
email: credentialSetInvitation.email,
status: credentialSetInvitation.status,
expiresAt: credentialSetInvitation.expiresAt,
createdAt: credentialSetInvitation.createdAt,
invitedBy: credentialSetInvitation.invitedBy,
})
.from(credentialSetInvitation)
.where(eq(credentialSetInvitation.credentialSetId, id))
+3 -3
View File
@@ -12,7 +12,7 @@ import {
type CreateCredentialSetData,
type CredentialSet,
type CredentialSetInvitation,
type CredentialSetInvitationDetail,
type CredentialSetInvitationListItem,
type CredentialSetMember,
type CredentialSetMembership,
cancelCredentialSetInvitationContract,
@@ -34,7 +34,7 @@ export type {
CreateCredentialSetData,
CredentialSet,
CredentialSetInvitation,
CredentialSetInvitationDetail,
CredentialSetInvitationListItem,
CredentialSetMember,
CredentialSetMembership,
}
@@ -251,7 +251,7 @@ export function useDeleteCredentialSet() {
}
export function useCredentialSetInvitationsDetail(credentialSetId?: string) {
return useQuery<CredentialSetInvitationDetail[]>({
return useQuery<CredentialSetInvitationListItem[]>({
queryKey: credentialSetKeys.detailInvitations(credentialSetId),
queryFn: async ({ signal }) => {
if (!credentialSetId) return []
+12 -1
View File
@@ -63,6 +63,16 @@ export const credentialSetInvitationDetailSchema = z.object({
invitedBy: z.string(),
})
/**
* Management-list view of an invitation. Omits the bearer `token` — the secret
* redeemed via the invite link — so listing a credential set's invitations never
* broadcasts it. Only the creating admin receives the token (and its `inviteUrl`)
* in the create response.
*/
export const credentialSetInvitationListItemSchema = credentialSetInvitationDetailSchema.omit({
token: true,
})
export const credentialSetInvitePreviewSchema = z.object({
credentialSetName: z.string(),
organizationName: z.string(),
@@ -92,6 +102,7 @@ export type CredentialSetMembership = z.output<typeof credentialSetMembershipSch
export type CredentialSetInvitation = z.output<typeof credentialSetInvitationSchema>
export type CredentialSetMember = z.output<typeof credentialSetMemberSchema>
export type CredentialSetInvitationDetail = z.output<typeof credentialSetInvitationDetailSchema>
export type CredentialSetInvitationListItem = z.output<typeof credentialSetInvitationListItemSchema>
export type CredentialSetInvitePreview = z.output<typeof credentialSetInvitePreviewSchema>
export const listCredentialSetsQuerySchema = z.object({
@@ -245,7 +256,7 @@ export const listCredentialSetInvitationDetailsContract = defineRouteContract({
response: {
mode: 'json',
schema: z.object({
invitations: z.array(credentialSetInvitationDetailSchema).optional(),
invitations: z.array(credentialSetInvitationListItemSchema).optional(),
}),
},
})