From aaef312451da535fc3aa50a5595868a6376a7a8b Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:20:31 +0000 Subject: [PATCH] =?UTF-8?q?=EB=B3=B4=EC=95=88=20=ED=8C=A8=EC=B9=98:=20Admi?= =?UTF-8?q?n=20API=EC=97=90=20=EC=9D=B8=EC=A6=9D,=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EB=B0=8F=20=ED=85=8C=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=20=EA=B0=95=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdminController의 모든 엔드포인트에 인증 및 권한 제어가 누락되어 있어, 공격자가 권한 없이 모든 테넌트의 작업을 조회하고 조작할 수 있는 심각한 취약점이 존재했습니다. 이에 TenantAccessService를 주입하여 인증 및 관리자 권한을 강제하고, 테넌트 식별자를 통한 데이터 격리(job.belongsToTenant)를 수행하도록 코드를 수정했습니다. 또한 재시도 작업 시 프라이버시 보호를 위해 operatorId를 SHA-256으로 가명화 처리하였습니다. --- .jules/sentinel.md | 5 + .../viewer/controller/AdminController.java | 99 ++++++++++++++++--- .../controller/AdminControllerTest.java | 91 ++++++++++++++--- 3 files changed, 168 insertions(+), 27 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e795cb9d..626b9261 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -32,3 +32,8 @@ **Vulnerability:** The document hashing routine in `DefaultDocumentConversionService` processed file streams without enforcing any maximum size limit on the bytes read. An attacker could exploit this by uploading a maliciously large stream (or exploiting a compression bomb if unzipping), exhausting system memory, CPU, or disk space (DoS). **Learning:** Checking the declared file size (e.g., `file.getSize()`) in initial validation is not always sufficient if the input stream itself can be spoofed or dynamically expanded during reading. The actual bytes read must be verified against bounds continuously. **Prevention:** Always enforce a strict, configurable size limit (e.g., `ConversionProperties.maxUploadSizeBytes`) within the `while` loop that reads from untrusted input streams. Track `totalRead` and throw an exception immediately if the limit is exceeded. + +## 2026-08-10 - 관리자 API의 Broken Access Control 및 교차 테넌트 데이터 유출 방지 (Enforce Authentication and Tenant Isolation in Admin API) +**Vulnerability:** `AdminController`의 엔드포인트들에 인증 및 인가 검증이 누락되어 있었습니다. 공격자가 어떠한 자격 증명이나 적절한 권한 없이도 모든 테넌트의 변환 작업(Conversion Job)을 조회, 삭제, 재시도할 수 있어 Broken Access Control (BAC) 및 교차 테넌트 데이터 유출이 발생할 수 있었습니다. +**Learning:** 관리자 컨트롤러는 일반 컨트롤러와 동일하게 강력한 인증 및 테넌트 격리 메커니즘을 적용해야 합니다. 경로에 "admin"이 포함되어 있다고 해서 엔드포인트가 본질적으로 보호된다고 가정해서는 안 되며, 보안 필터나 액세스 서비스(예: `TenantAccessService`)와 명시적으로 통합해야 합니다. 또한, 작업 흐름(예: 재시도 시 `operatorId`)에 기록될 때 프라이버시를 보호하기 위해 원본 사용자 ID는 가명화(pseudonymize)되어야 합니다. +**Prevention:** 관리자 엔드포인트에는 항상 `TenantAccessService`(또는 동등한 컨텍스트 검증기)를 주입하고, 호출자가 명시적인 관리자 권한(예: `JOB_DELETE`)을 가지고 있는지 확인하며, 리소스에 접근하기 전에 소유권(`job.belongsToTenant(...)`)을 확인하여 테넌트 격리를 강제해야 합니다. diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 412d4eb8..d7dfde2a 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -10,11 +10,22 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Optional; + +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import com.clearfolio.viewer.api.AdminJobListResponse; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.auth.TenantPermissions; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; @@ -25,35 +36,51 @@ @RestController public class AdminController { + /** Hex formatter for pseudonyms. */ + private static final HexFormat HEX_FORMAT = HexFormat.of(); + + /** Service to perform document conversion jobs. */ private final DocumentConversionService conversionService; + /** Service to authorize tenants and track permissions. */ + private final TenantAccessService tenantAccessService; + /** * Creates a controller for admin operations. * - * @param conversionService conversion service + * @param conversionServiceToUse conversion service + * @param tenantAccessServiceToUse tenant access service */ - public AdminController(DocumentConversionService conversionService) { - this.conversionService = conversionService; + public AdminController( + final DocumentConversionService conversionServiceToUse, + final TenantAccessService tenantAccessServiceToUse) { + this.conversionService = conversionServiceToUse; + this.tenantAccessService = tenantAccessServiceToUse; } /** * Retrieves all conversion jobs, optionally filtered by dead-letter status. * + * @param headers request headers carrying tenant claims * @param deadLettered optional filter for dead-lettered jobs * @return list of conversion jobs */ @GetMapping("/api/v1/admin/convert/jobs") - public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered) { + public AdminJobListResponse getAllJobs( + @RequestHeader final HttpHeaders headers, + @RequestParam(required = false) final Boolean deadLettered + ) { + TenantContext context = tenantAccessService + .require(headers, TenantPermissions.JOB_READ); Iterable allJobs = conversionService.getAllJobs(); - if (deadLettered == null) { - return AdminJobListResponse.from(allJobs); - } - List filtered = new ArrayList<>(); for (ConversionJob job : allJobs) { - if (job.isDeadLettered() == deadLettered) { - filtered.add(job); + if (job.belongsToTenant(context.tenantId())) { + if (deadLettered == null + || job.isDeadLettered() == deadLettered) { + filtered.add(job); + } } } return AdminJobListResponse.from(filtered); @@ -62,11 +89,23 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d /** * Deletes a conversion job. * + * @param headers request headers carrying tenant claims * @param jobId conversion job identifier * @return no content on success */ @DeleteMapping("/api/v1/admin/convert/jobs/{jobId}") - public ResponseEntity deleteJob(@PathVariable UUID jobId) { + public ResponseEntity deleteJob( + @RequestHeader final HttpHeaders headers, + @PathVariable final UUID jobId) { + TenantContext context = tenantAccessService + .require(headers, TenantPermissions.JOB_DELETE); + 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(); } @@ -74,18 +113,48 @@ public ResponseEntity deleteJob(@PathVariable UUID jobId) { /** * Retries a dead-lettered conversion job. * + * @param headers request headers carrying tenant claims * @param jobId conversion job identifier * @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( + @RequestHeader final HttpHeaders headers, + @PathVariable final UUID jobId) { + TenantContext context = tenantAccessService + .require(headers, TenantPermissions.JOB_RETRY); + Optional job = conversionService.getJob(jobId); + if (job.isEmpty() + || !job.get().belongsToTenant(context.tenantId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "job not found"); + } + + String pseudonymizedOperatorId = hashSubjectId(context.subjectId()); + RetryDeadLetterResult result = conversionService + .retryDeadLettered(jobId, pseudonymizedOperatorId); if (result == RetryDeadLetterResult.NOT_FOUND) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "job not found"); } if (result == RetryDeadLetterResult.NOT_ELIGIBLE) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "job is not eligible for retry"); + throw new ResponseStatusException(HttpStatus.CONFLICT, + "job is not eligible for retry"); } return ResponseEntity.accepted().build(); } + + private String hashSubjectId(final String subjectId) { + if (subjectId == null) { + return "absent"; + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest( + subjectId.getBytes(StandardCharsets.UTF_8)); + return HEX_FORMAT.formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } } diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index ad63a801..55f23f23 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,15 +1,23 @@ package com.clearfolio.viewer.controller; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.Arrays; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; import org.springframework.test.web.reactive.server.WebTestClient; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.auth.TenantPermissions; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; @@ -17,26 +25,42 @@ class AdminControllerTest { private DocumentConversionService conversionService; + private TenantAccessService tenantAccessService; private WebTestClient webTestClient; private AdminController controller; @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 mockTenantContext(String permission) { + TenantContext context = new TenantContext("tenant-a", "user-1", null); + when(tenantAccessService.require(any(HttpHeaders.class), 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); - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + mockTenantContext(TenantPermissions.JOB_READ); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "tenant-a", "user-1", + "a.pdf", "application/pdf", "hash-a", 100L, 3); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "tenant-a", "user-1", + "b.pdf", "application/pdf", "hash-b", 100L, 3); + ConversionJob job3 = new ConversionJob(UUID.randomUUID(), "tenant-b", "user-1", + "c.pdf", "application/pdf", "hash-c", 100L, 3); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2, job3)); webTestClient.get() .uri("/api/v1/admin/convert/jobs") + .header(TenantContext.TENANT_ID_HEADER, "tenant-a") + .header(TenantContext.SUBJECT_ID_HEADER, "user-1") + .header(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_READ) .exchange() .expectStatus().isOk() .expectBody() @@ -47,14 +71,20 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() { @Test void getAllJobsFiltersByDeadLetteredTrue() { - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); + mockTenantContext(TenantPermissions.JOB_READ); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "tenant-a", "user-1", + "a.pdf", "application/pdf", "hash-a", 100L, 3); job1.markDeadLettered("failed"); - ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "tenant-a", "user-1", + "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, "tenant-a") + .header(TenantContext.SUBJECT_ID_HEADER, "user-1") + .header(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_READ) .exchange() .expectStatus().isOk() .expectBody() @@ -64,14 +94,20 @@ void getAllJobsFiltersByDeadLetteredTrue() { @Test void getAllJobsFiltersByDeadLetteredFalse() { - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); + mockTenantContext(TenantPermissions.JOB_READ); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "tenant-a", "user-1", + "a.pdf", "application/pdf", "hash-a", 100L, 3); job1.markDeadLettered("failed"); - ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "tenant-a", "user-1", + "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, "tenant-a") + .header(TenantContext.SUBJECT_ID_HEADER, "user-1") + .header(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_READ) .exchange() .expectStatus().isOk() .expectBody() @@ -81,44 +117,75 @@ void getAllJobsFiltersByDeadLetteredFalse() { @Test void deleteJobReturnsNoContent() { + mockTenantContext(TenantPermissions.JOB_DELETE); UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "tenant-a", "user-1", + "a.pdf", "application/pdf", "hash", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) + .header(TenantContext.TENANT_ID_HEADER, "tenant-a") + .header(TenantContext.SUBJECT_ID_HEADER, "user-1") + .header(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_DELETE) .exchange() .expectStatus().isNoContent(); } @Test void retryDeadLetteredReturnsAcceptedWhenAccepted() { + mockTenantContext(TenantPermissions.JOB_RETRY); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); + ConversionJob job = new ConversionJob(jobId, "tenant-a", "user-1", + "a.pdf", "application/pdf", "hash", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + when(conversionService.retryDeadLettered(eq(jobId), anyString())) + .thenReturn(RetryDeadLetterResult.ACCEPTED); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header(TenantContext.TENANT_ID_HEADER, "tenant-a") + .header(TenantContext.SUBJECT_ID_HEADER, "user-1") + .header(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_RETRY) .exchange() .expectStatus().isAccepted(); } @Test void retryDeadLetteredReturnsNotFoundWhenNotFound() { + mockTenantContext(TenantPermissions.JOB_RETRY); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); + ConversionJob job = new ConversionJob(jobId, "tenant-a", "user-1", + "a.pdf", "application/pdf", "hash", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + when(conversionService.retryDeadLettered(eq(jobId), anyString())) + .thenReturn(RetryDeadLetterResult.NOT_FOUND); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header(TenantContext.TENANT_ID_HEADER, "tenant-a") + .header(TenantContext.SUBJECT_ID_HEADER, "user-1") + .header(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_RETRY) .exchange() .expectStatus().isNotFound(); } @Test void retryDeadLetteredReturnsConflictWhenNotEligible() { + mockTenantContext(TenantPermissions.JOB_RETRY); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); + ConversionJob job = new ConversionJob(jobId, "tenant-a", "user-1", + "a.pdf", "application/pdf", "hash", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + when(conversionService.retryDeadLettered(eq(jobId), anyString())) + .thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header(TenantContext.TENANT_ID_HEADER, "tenant-a") + .header(TenantContext.SUBJECT_ID_HEADER, "user-1") + .header(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_RETRY) .exchange() - .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer + .expectStatus().isEqualTo(409); } }