From 577b637b8bab18f9ea392d7c738339106daeffc4 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:29:05 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITICAL]?= =?UTF-8?q?=20AdminController=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=9A=B0=ED=9A=8C=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL πŸ’‘ Vulnerability: AdminController의 μ—”λ“œν¬μΈνŠΈ(/api/v1/admin/convert/jobs λ“±)에 인증 및 κΆŒν•œ 확인 둜직이 λˆ„λ½λ˜μ–΄, μΈμ¦λ˜μ§€ μ•Šμ€ μ‚¬μš©μžκ°€ κ΄€λ¦¬μž κΈ°λŠ₯에 μ ‘κ·Όν•˜κ±°λ‚˜ λ‹€λ₯Έ ν…Œλ„ŒνŠΈμ˜ 데이터λ₯Ό 쑰회 및 μ‚­μ œν•  수 μžˆμ—ˆμŠ΅λ‹ˆλ‹€. λ˜ν•œ, μ‹€νŒ¨ν•œ μž‘μ—… μž¬μ‹œλ„ μ‹œ 'admin'μ΄λΌλŠ” ν•˜λ“œμ½”λ”©λœ μ‹λ³„μžκ°€ μ‚¬μš©λ˜κ³  μžˆμ—ˆμŠ΅λ‹ˆλ‹€. 🎯 Impact: μ•…μ˜μ μΈ μ‚¬μš©μžκ°€ λ‹€λ₯Έ 고객(Tenant)의 λ³€ν™˜ μž‘μ—… 내역을 μ‘°νšŒν•˜κ±°λ‚˜ μ‚­μ œν•  수 μžˆμ–΄ μ‹¬κ°ν•œ 데이터 유좜 및 무결성 훼손이 λ°œμƒν•  수 μžˆμŠ΅λ‹ˆλ‹€. πŸ”§ Fix: TenantAccessServiceλ₯Ό λ„μž…ν•˜μ—¬ λͺ¨λ“  AdminController μ—”λ“œν¬μΈνŠΈμ— 인증(HTTP Headers 기반)을 μš”κ΅¬ν•˜κ³ , TenantPermissions(JOB_READ, JOB_DELETE, JOB_RETRY) κΆŒν•œμ„ κ²€μ¦ν•˜λ„λ‘ μˆ˜μ •ν–ˆμŠ΅λ‹ˆλ‹€. λ˜ν•œ, μž‘μ—… 데이터에 μ ‘κ·Ό μ‹œ ν•΄λ‹Ή μž‘μ—…μ΄ ν˜„μž¬ 인증된 ν…Œλ„ŒνŠΈμ˜ μ†Œμœ μΈμ§€ κ²€μ¦ν•˜λŠ” 둜직(belongsToTenant)을 μΆ”κ°€ν•˜κ³ , μž¬μ‹œλ„ μ‹œ ν•˜λ“œμ½”λ”©λœ 'admin' λŒ€μ‹  μ‚¬μš©μž μ‹λ³„μžλ₯Ό SHA-256으둜 ν•΄μ‹±ν•˜μ—¬ 읡λͺ…ν™”λœ 운영자 ID둜 μ „λ‹¬ν•˜λ„λ‘ κ°œμ„ ν–ˆμŠ΅λ‹ˆλ‹€. βœ… Verification: mvn clean testλ₯Ό μ‹€ν–‰ν•˜μ—¬ κΈ°μ‘΄ 및 μƒˆλ‘­κ²Œ μΆ”κ°€λœ κΆŒν•œ 기반 ν…ŒμŠ€νŠΈ μ½”λ“œ(getAllJobsFiltersByTenantId, deleteJobReturnsNotFoundWhenNotOwned λ“±)κ°€ λͺ¨λ‘ ν†΅κ³Όν•˜λŠ”μ§€ ν™•μΈν–ˆμŠ΅λ‹ˆλ‹€. --- .../viewer/controller/AdminController.java | 63 ++++++-- .../controller/AdminControllerTest.java | 139 ++++++++++++++++-- 2 files changed, 178 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 412d4eb86..15c289948 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -13,11 +13,13 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.bind.annotation.RequestHeader; import com.clearfolio.viewer.api.AdminJobListResponse; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; +import com.clearfolio.viewer.auth.TenantAccessService; /** * Controller for admin-specific endpoints. @@ -25,34 +27,44 @@ @RestController public class AdminController { + private static final java.util.HexFormat HEX_FORMAT = java.util.HexFormat.of(); + private final DocumentConversionService conversionService; + private final TenantAccessService tenantAccessService; /** * Creates a controller for admin operations. * * @param conversionService conversion service + * @param tenantAccessService tenant access service for authorization */ - public AdminController(DocumentConversionService conversionService) { + public AdminController(DocumentConversionService conversionService, TenantAccessService tenantAccessService) { this.conversionService = conversionService; + this.tenantAccessService = tenantAccessService; } /** * Retrieves all conversion jobs, optionally filtered by dead-letter status. * * @param deadLettered optional filter for dead-lettered jobs + * @param headers request headers * @return list of conversion jobs */ @GetMapping("/api/v1/admin/convert/jobs") - public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered) { - Iterable allJobs = conversionService.getAllJobs(); + public AdminJobListResponse getAllJobs( + @RequestParam(required = false) Boolean deadLettered, + @RequestHeader org.springframework.http.HttpHeaders headers) { - if (deadLettered == null) { - return AdminJobListResponse.from(allJobs); - } + com.clearfolio.viewer.auth.TenantContext context = tenantAccessService.require(headers, com.clearfolio.viewer.auth.TenantPermissions.JOB_READ); + + Iterable allJobs = conversionService.getAllJobs(); List filtered = new ArrayList<>(); for (ConversionJob job : allJobs) { - if (job.isDeadLettered() == deadLettered) { + if (!job.belongsToTenant(context.tenantId())) { + continue; + } + if (deadLettered == null || job.isDeadLettered() == deadLettered) { filtered.add(job); } } @@ -63,10 +75,21 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d * Deletes a conversion job. * * @param jobId conversion job identifier + * @param headers request headers * @return no content on success */ @DeleteMapping("/api/v1/admin/convert/jobs/{jobId}") - public ResponseEntity deleteJob(@PathVariable UUID jobId) { + public ResponseEntity deleteJob( + @PathVariable UUID jobId, + @RequestHeader org.springframework.http.HttpHeaders headers) { + + com.clearfolio.viewer.auth.TenantContext context = tenantAccessService.require(headers, com.clearfolio.viewer.auth.TenantPermissions.JOB_DELETE); + java.util.Optional job = conversionService.getJob(jobId); + + if (job.isEmpty() || !job.get().belongsToTenant(context.tenantId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); + } + conversionService.deleteJob(jobId); return ResponseEntity.noContent().build(); } @@ -75,11 +98,31 @@ public ResponseEntity deleteJob(@PathVariable UUID jobId) { * Retries a dead-lettered conversion job. * * @param jobId conversion job identifier + * @param headers request headers * @return accepted response on success */ @PostMapping("/api/v1/admin/convert/jobs/{jobId}/retry") - public ResponseEntity retryDeadLettered(@PathVariable UUID jobId) { - RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin"); + public ResponseEntity retryDeadLettered( + @PathVariable UUID jobId, + @RequestHeader org.springframework.http.HttpHeaders headers) { + + com.clearfolio.viewer.auth.TenantContext context = tenantAccessService.require(headers, com.clearfolio.viewer.auth.TenantPermissions.JOB_RETRY); + java.util.Optional job = conversionService.getJob(jobId); + + if (job.isEmpty() || !job.get().belongsToTenant(context.tenantId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); + } + + java.security.MessageDigest digest; + try { + digest = java.security.MessageDigest.getInstance("SHA-256"); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + byte[] hash = digest.digest(context.subjectId().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String operatorHash = HEX_FORMAT.formatHex(hash); + + RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, operatorHash); if (result == RetryDeadLetterResult.NOT_FOUND) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); } diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index ad63a8015..021b234bd 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,42 +1,65 @@ package com.clearfolio.viewer.controller; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; import java.util.Arrays; +import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.http.HttpHeaders; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.auth.TenantPermissions; class AdminControllerTest { private DocumentConversionService conversionService; + private TenantAccessService tenantAccessService; private WebTestClient webTestClient; private AdminController controller; + private static final String TEST_TENANT = "test-tenant"; + private static final String OTHER_TENANT = "other-tenant"; + private static final String TEST_SUBJECT = "test-operator"; + @BeforeEach void setUp() { conversionService = mock(DocumentConversionService.class); - controller = new AdminController(conversionService); + tenantAccessService = mock(TenantAccessService.class); + controller = new AdminController(conversionService, tenantAccessService); webTestClient = WebTestClient.bindToController(controller) .controllerAdvice(new ApiExceptionHandler()) .build(); } + private void mockContext(String permission, String tenantId) { + TenantContext context = new TenantContext(tenantId, TEST_SUBJECT, Set.of(permission)); + when(tenantAccessService.require(any(), eq(permission))).thenReturn(context); + } + @Test void getAllJobsReturnsAllJobsWhenNoFilterProvided() { - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); - ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); + mockContext(TenantPermissions.JOB_READ, TEST_TENANT); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), TEST_TENANT, "sub", "b.pdf", "application/pdf", "hash-b", 100L, 3); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); webTestClient.get() .uri("/api/v1/admin/convert/jobs") + .header(TenantContext.TENANT_ID_HEADER, TEST_TENANT) .exchange() .expectStatus().isOk() .expectBody() @@ -47,14 +70,17 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() { @Test void getAllJobsFiltersByDeadLetteredTrue() { - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); + mockContext(TenantPermissions.JOB_READ, TEST_TENANT); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3); + job1.markFailed("err"); job1.markDeadLettered("failed"); - ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), TEST_TENANT, "sub", "b.pdf", "application/pdf", "hash-b", 100L, 3); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") + .header(TenantContext.TENANT_ID_HEADER, TEST_TENANT) .exchange() .expectStatus().isOk() .expectBody() @@ -64,14 +90,17 @@ void getAllJobsFiltersByDeadLetteredTrue() { @Test void getAllJobsFiltersByDeadLetteredFalse() { - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); + mockContext(TenantPermissions.JOB_READ, TEST_TENANT); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3); + job1.markFailed("err"); job1.markDeadLettered("failed"); - ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), TEST_TENANT, "sub", "b.pdf", "application/pdf", "hash-b", 100L, 3); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") + .header(TenantContext.TENANT_ID_HEADER, TEST_TENANT) .exchange() .expectStatus().isOk() .expectBody() @@ -79,46 +108,128 @@ void getAllJobsFiltersByDeadLetteredFalse() { .jsonPath("$.jobs[0].fileName").isEqualTo("b.pdf"); } + @Test + void getAllJobsFiltersByTenantId() { + mockContext(TenantPermissions.JOB_READ, TEST_TENANT); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), OTHER_TENANT, "sub", "b.pdf", "application/pdf", "hash-b", 100L, 3); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs") + .header(TenantContext.TENANT_ID_HEADER, TEST_TENANT) + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.jobs.length()").isEqualTo(1) + .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf"); + } + @Test void deleteJobReturnsNoContent() { + mockContext(TenantPermissions.JOB_DELETE, TEST_TENANT); UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) + .header(TenantContext.TENANT_ID_HEADER, TEST_TENANT) .exchange() .expectStatus().isNoContent(); + + verify(conversionService).deleteJob(jobId); + } + + @Test + void deleteJobReturnsNotFoundWhenNotOwned() { + mockContext(TenantPermissions.JOB_DELETE, TEST_TENANT); + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, OTHER_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .header(TenantContext.TENANT_ID_HEADER, TEST_TENANT) + .exchange() + .expectStatus().isNotFound(); + + verify(conversionService, never()).deleteJob(jobId); } @Test - void retryDeadLetteredReturnsAcceptedWhenAccepted() { + void retryDeadLetteredReturnsAcceptedWhenAccepted() throws Exception { + mockContext(TenantPermissions.JOB_RETRY, TEST_TENANT); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); + ConversionJob job = new ConversionJob(jobId, TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(TEST_SUBJECT.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String expectedHash = java.util.HexFormat.of().formatHex(hash); + + when(conversionService.retryDeadLettered(jobId, expectedHash)).thenReturn(RetryDeadLetterResult.ACCEPTED); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header(TenantContext.TENANT_ID_HEADER, TEST_TENANT) .exchange() .expectStatus().isAccepted(); } @Test - void retryDeadLetteredReturnsNotFoundWhenNotFound() { + void retryDeadLetteredReturnsNotFoundWhenNotFound() throws Exception { + mockContext(TenantPermissions.JOB_RETRY, TEST_TENANT); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); + ConversionJob job = new ConversionJob(jobId, TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(TEST_SUBJECT.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String expectedHash = java.util.HexFormat.of().formatHex(hash); + + when(conversionService.retryDeadLettered(jobId, expectedHash)).thenReturn(RetryDeadLetterResult.NOT_FOUND); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header(TenantContext.TENANT_ID_HEADER, TEST_TENANT) .exchange() .expectStatus().isNotFound(); } @Test - void retryDeadLetteredReturnsConflictWhenNotEligible() { + void retryDeadLetteredReturnsConflictWhenNotEligible() throws Exception { + mockContext(TenantPermissions.JOB_RETRY, TEST_TENANT); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); + ConversionJob job = new ConversionJob(jobId, TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(TEST_SUBJECT.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String expectedHash = java.util.HexFormat.of().formatHex(hash); + + when(conversionService.retryDeadLettered(jobId, expectedHash)).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header(TenantContext.TENANT_ID_HEADER, TEST_TENANT) .exchange() - .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer + .expectStatus().isEqualTo(409); + } + + @Test + void retryDeadLetteredReturnsNotFoundWhenNotOwned() { + mockContext(TenantPermissions.JOB_RETRY, TEST_TENANT); + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, OTHER_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header(TenantContext.TENANT_ID_HEADER, TEST_TENANT) + .exchange() + .expectStatus().isNotFound(); + + verify(conversionService, never()).retryDeadLettered(any(), any()); } }