fix(security): file storage authorization hardening

Three separate issues in the file storage layer:

1. IDOR via client-controlled object key in applicant.saveFileMetadata
   and file.replaceFile. Both procedures accepted `bucket` and `objectKey`
   from the client and stored them on a new ProjectFile row attached to
   the caller's own project. Because file.getDownloadUrl authorizes via
   `findFirst({ bucket, objectKey })` -> projectId, an attacker could
   bind another team's storage object to their own project row and then
   download the foreign object through the legitimate authorization
   path. Now both procedures require `bucket === BUCKET_NAME` and the
   `objectKey` to start with the project's sanitized title prefix
   (matches the prefix that generateObjectKey produces server-side).

   New helper `objectKeyBelongsToProject` exported from src/lib/minio.ts;
   `sanitizePath` is now exported as well so the helper can reuse it.

2. Missing per-round scope on file.getBulkDownloadUrls. The single-file
   getDownloadUrl restricts a juror to files in rounds with sortOrder
   <= their assigned round, but the bulk variant only checked that an
   Assignment row existed for the project. A juror assigned only to
   EVALUATION could pull URLs for LIVE_FINAL/DELIBERATION confidential
   files via this endpoint. Now applies the same per-round filter when
   the caller's access to the project is jury-only (mentors / team
   members / award jurors retain unrestricted access, matching
   getDownloadUrl semantics).

3. Same omission on the standalone /api/files/bulk-download REST route.
   Same fix applied there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-29 03:30:00 +02:00
parent 89e637843a
commit 9d0beed02f
4 changed files with 136 additions and 18 deletions

View File

@@ -2,7 +2,7 @@ import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl, generateObjectKey, BUCKET_NAME } from '@/lib/minio'
import { getPresignedUrl, generateObjectKey, BUCKET_NAME, objectKeyBelongsToProject } from '@/lib/minio'
import { generateLogoKey, createStorageProvider, type StorageProviderType } from '@/lib/storage'
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
@@ -408,6 +408,24 @@ export const applicantRouter = router({
})
}
// Verify the client-supplied objectKey actually belongs to this project's
// sanitized prefix. Without this, a user could pass another team's
// bucket+objectKey here, attach it to their own project row, and then
// download the foreign object via file.getDownloadUrl (which authorizes
// by bucket+objectKey -> projectId).
if (input.bucket !== BUCKET_NAME) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid file location',
})
}
if (!objectKeyBelongsToProject(input.objectKey, project.title)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid file location for this project',
})
}
// Block rejected projects
if (await isProjectRejected(ctx.prisma, input.projectId)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. File changes are no longer permitted.' })