From 765bdf9f9e904e2c53fac72f11ba1425bb2374a1 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Apr 2026 03:13:11 +0200 Subject: [PATCH] fix(security): restrict file.replaceFile to admins + team members only Replace was previously accepted from anyone with a relationship to the project: jury (assignment), mentor (mentorAssignment), or team member. That allowed jurors and mentors to swap a team's submission, with the attacker-supplied bucket+objectKey pointing at any object they had uploaded elsewhere. Now only admins and the team itself (submitter or TeamMember) can replace files. Jurors and mentors remain read-only on submissions. The legitimate UI flow (team-lead replacing files from the applicant dashboard) is unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/routers/file.ts | 41 ++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/src/server/routers/file.ts b/src/server/routers/file.ts index a97d7e8..2075873 100644 --- a/src/server/routers/file.ts +++ b/src/server/routers/file.ts @@ -517,32 +517,25 @@ export const fileRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role) + // Replace is a write operation on the team's submission. Only admins + // and the team itself (submitter or team member) may replace files — + // jurors and mentors are read-only on project files even though they + // can see them. Observers/Award masters are also read-only. + const isAdmin = ctx.user.role === 'SUPER_ADMIN' || ctx.user.role === 'PROGRAM_ADMIN' - if (!isAdminOrObserver) { - // Check user has access to the project (assigned or team member) - const [assignment, mentorAssignment, teamMembership] = await Promise.all([ - ctx.prisma.assignment.findFirst({ - where: { userId: ctx.user.id, projectId: input.projectId }, - select: { id: true }, - }), - ctx.prisma.mentorAssignment.findFirst({ - where: { mentorId: ctx.user.id, projectId: input.projectId }, - select: { id: true }, - }), - ctx.prisma.project.findFirst({ - where: { - id: input.projectId, - OR: [ - { submittedByUserId: ctx.user.id }, - { teamMembers: { some: { userId: ctx.user.id } } }, - ], - }, - select: { id: true }, - }), - ]) + if (!isAdmin) { + const teamMembership = await ctx.prisma.project.findFirst({ + where: { + id: input.projectId, + OR: [ + { submittedByUserId: ctx.user.id }, + { teamMembers: { some: { userId: ctx.user.id } } }, + ], + }, + select: { id: true }, + }) - if (!assignment && !mentorAssignment && !teamMembership) { + if (!teamMembership) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to replace files for this project',