diff --git a/apps/backend/apps/client/src/submission/submission.service.ts b/apps/backend/apps/client/src/submission/submission.service.ts index 2fe6c2b206..024dd8a2f7 100644 --- a/apps/backend/apps/client/src/submission/submission.service.ts +++ b/apps/backend/apps/client/src/submission/submission.service.ts @@ -1125,11 +1125,29 @@ export class SubmissionService { where: { id, problemId, - contestId, + // contestId가 null인 경우 해당 필드를 필터에서 제외한다. + // null로 그대로 넘기면 Prisma가 WHERE contest_id IS NULL 조건을 생성하여 + // 대회 종료 후 공개된 문제에서 과거 대회 제출 기록을 조회할 수 없게 되는 버그가 발생한다. + // assignmentId는 null 여부 자체가 보안상 의미가 있으므로 필터를 유지한다. + contestId: contestId ?? undefined, assignmentId }, select: { userId: true, + contest: { + select: { + startTime: true, + endTime: true, + isJudgeResultVisible: true + } + }, + assignment: { + select: { + startTime: true, + endTime: true, + isJudgeResultVisible: true + } + }, user: { select: { username: true @@ -1153,6 +1171,15 @@ export class SubmissionService { throw new EntityNotExistException('Submission') } + // contestId/assignmentId가 요청에 포함되지 않았지만 실제 submission에 contest/assignment가 있는 경우 + // (대회 종료 후 공개된 문제에서 과거 대회 제출 기록을 조회하는 케이스) + // submission의 릴레이션 데이터를 직접 사용하여 진행 중인 대회의 타인 제출 열람을 차단한다. + // contestRecord를 별도로 조회하지 않으므로, 대회 비참가자도 보안 검사를 우회할 수 없다. + if (!contest && submission.contest) { + contest = submission.contest + isJudgeResultVisible = submission.contest.isJudgeResultVisible + } + // 본인이나 관리자가 아닐 경우 if ( submission.userId !== userId && diff --git a/apps/backend/apps/client/src/submission/test/submission.service.spec.ts b/apps/backend/apps/client/src/submission/test/submission.service.spec.ts index 14ad02e6bb..dc0bd46443 100644 --- a/apps/backend/apps/client/src/submission/test/submission.service.spec.ts +++ b/apps/backend/apps/client/src/submission/test/submission.service.spec.ts @@ -603,6 +603,113 @@ describe('SubmissionService', () => { }) ).to.be.rejectedWith(ForbiddenAccessException) }) + + it('should return own past-contest submission when accessed from public problem page (contestId=null)', async () => { + // 대회 종료 후 공개된 문제에서 본인이 대회 당시 제출한 submission을 조회하는 케이스 + // submission의 contest 릴레이션을 통해 대회 정보를 직접 가져옴 + const endedContest = { + ...mockContest, + startTime: new Date(Date.now() - 20000), + endTime: new Date(Date.now() - 10000), // 이미 종료된 대회 + isJudgeResultVisible: true + } + const testcaseResult = submissionResults.map((result) => { + return { + ...result, + cpuTime: + result.cpuTime || result.cpuTime === BigInt(0) + ? result.cpuTime.toString() + : null + } + }) + + db.problem.findFirst.resolves(problems[0]) + db.submission.findFirst.resolves({ + ...submissions[0], + contest: endedContest, // 대회 당시 제출이므로 contest 릴레이션이 존재 + user: { username: 'username' }, + submissionResult: submissionResults + }) + + expect( + await service.getSubmission({ + id: submissions[0].id, + problemId: problems[0].id, + userId: submissions[0].userId, // 본인 조회 + userRole: Role.User, + contestId: null, // 공개 문제 페이지에서 contestId 없이 호출 + assignmentId: null + }) + ).to.deep.equal({ + problemId: problems[0].id, + username: 'username', + code: submissions[0].code.map((snippet) => snippet.text).join('\n'), + language: submissions[0].language, + createTime: submissions[0].createTime, + result: submissions[0].result, + testcaseResult + }) + }) + + it('should block viewing other users submission from ongoing contest via public page', async () => { + // contestId=null로 공개 엔드포인트를 호출하더라도 + // submission의 contest가 진행 중인 대회이면 타인 제출 열람을 차단해야 함 + // submission의 릴레이션 데이터를 직접 사용하므로 contestRecord 유무와 무관하게 차단됨 + const ongoingContest = { + ...mockContest, + startTime: new Date(Date.now() - 10000), + endTime: new Date(Date.now() + 10000) + } + + db.problem.findFirst.resolves(problems[0]) + db.submission.findFirst.resolves({ + ...submissions[0], + contest: ongoingContest, // 진행 중인 대회의 릴레이션 데이터 + userId: 2 // 타인의 submission + }) + + await expect( + service.getSubmission({ + id: submissions[0].id, + problemId: problems[0].id, + userId: 1, // 다른 유저가 조회 시도 + userRole: Role.User, + contestId: null, // contestId 없이 공개 엔드포인트 호출 + assignmentId: null + }) + ).to.be.rejectedWith(ForbiddenAccessException) + }) + + it('should block non-participant from viewing submission of ongoing contest via public page', async () => { + // 대회에 참가하지 않은 유저가 공개 엔드포인트를 통해 + // 진행 중인 대회의 타인 제출물을 조회 시도하는 케이스 + // contestRecord가 없어도 submission.contest 릴레이션으로 차단되어야 함 + const ongoingContest = { + ...mockContest, + startTime: new Date(Date.now() - 10000), + endTime: new Date(Date.now() + 10000) + } + + db.problem.findFirst.resolves(problems[0]) + db.submission.findFirst.resolves({ + ...submissions[0], + contest: ongoingContest, // 진행 중인 대회의 릴레이션 데이터 + userId: 2 // 타인의 submission + }) + // 비참가자이므로 contestRecord가 없음 + db.contestRecord.findUnique.resolves(null) + + await expect( + service.getSubmission({ + id: submissions[0].id, + problemId: problems[0].id, + userId: 1, // 대회에 참가하지 않은 유저 + userRole: Role.User, + contestId: null, + assignmentId: null + }) + ).to.be.rejectedWith(ForbiddenAccessException) + }) }) describe('getContestSubmissions', () => {