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:
@@ -30,21 +30,21 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Authorization: must be admin or assigned jury/mentor for this project
|
||||
const isAdmin = userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
|
||||
|
||||
// Per-round scope: jurors may only pull URLs for files in rounds with
|
||||
// sortOrder <= their assigned round. Mirrors file.getDownloadUrl. Without
|
||||
// this, a juror assigned to EVALUATION could bulk-download LIVE_FINAL
|
||||
// confidential files via this endpoint.
|
||||
let priorRoundIds: string[] | null = null
|
||||
|
||||
if (!isAdmin) {
|
||||
// Check if user is assigned as jury
|
||||
const juryAssignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
where: { userId, projectId },
|
||||
select: { id: true, roundId: true },
|
||||
})
|
||||
|
||||
// Check if user is assigned as mentor
|
||||
const mentorAssignment = await prisma.mentorAssignment.findFirst({
|
||||
where: {
|
||||
mentorId: userId,
|
||||
projectId,
|
||||
},
|
||||
where: { mentorId: userId, projectId },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!juryAssignment && !mentorAssignment) {
|
||||
@@ -53,14 +53,41 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Apply the per-round filter only when access is jury-only.
|
||||
if (juryAssignment && !mentorAssignment) {
|
||||
const assignedRound = await prisma.round.findUnique({
|
||||
where: { id: juryAssignment.roundId },
|
||||
select: { competitionId: true, sortOrder: true },
|
||||
})
|
||||
if (assignedRound) {
|
||||
const priorOrCurrent = await prisma.round.findMany({
|
||||
where: {
|
||||
competitionId: assignedRound.competitionId,
|
||||
sortOrder: { lte: assignedRound.sortOrder },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
priorRoundIds = priorOrCurrent.map((r) => r.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch file metadata from DB
|
||||
const fileWhere: Record<string, unknown> = {
|
||||
id: { in: fileIds },
|
||||
projectId,
|
||||
}
|
||||
if (priorRoundIds !== null) {
|
||||
fileWhere.OR = [
|
||||
{ requirement: { roundId: { in: priorRoundIds } } },
|
||||
{ requirementId: null, roundId: { in: priorRoundIds } },
|
||||
{ requirementId: null, roundId: null },
|
||||
]
|
||||
}
|
||||
|
||||
const files = await prisma.projectFile.findMany({
|
||||
where: {
|
||||
id: { in: fileIds },
|
||||
projectId,
|
||||
},
|
||||
where: fileWhere,
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
|
||||
Reference in New Issue
Block a user