From 51f54d97b2cd0c2ced66b5a44af108964987fe49 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:20:03 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITICAL]?= =?UTF-8?q?=20Fix=20=EB=88=84=EB=9D=BD=EB=90=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AdminController`의 엔드포인트가 인증되지 않은 상태로 노출되는 취약점 해결 - `TenantAccessService`를 주입받아 각 엔드포인트에 인증 검증 로직 추가 - `TenantPermissions`에 `ADMIN_READ`, `ADMIN_WRITE` 권한 상수를 추가하고 검증 시 사용 - `AdminControllerTest`를 업데이트하여 100% 테스트 커버리지 유지 및 동작 검증 완료 --- .jules/sentinel.md | 5 ++++ .../viewer/auth/TenantPermissions.java | 10 ++++++++ .../viewer/controller/AdminController.java | 25 ++++++++++++++++--- .../controller/AdminControllerTest.java | 24 +++++++++++++++++- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e795cb9d..e063978f 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-07-17 - Missing Authentication on Admin Endpoints +**Vulnerability:** The `AdminController` endpoints (`/api/v1/admin/convert/jobs`, `DELETE /api/v1/admin/convert/jobs/{jobId}`, `POST /api/v1/admin/convert/jobs/{jobId}/retry`) were entirely lacking authentication and authorization checks, allowing unauthenticated users to access and modify administrative resources. +**Learning:** Adding a new controller without properly injecting and wiring the `TenantAccessService` to enforce permission boundaries creates a critical, unauthenticated backdoor into the application. +**Prevention:** All backend API endpoints, especially administrative ones, must enforce authentication and authorization by injecting `TenantAccessService` and executing `tenantAccessService.require(headers, TenantPermissions.[SPECIFIC_PERMISSION])` (e.g., `ADMIN_READ`, `ADMIN_WRITE`) to secure sensitive operations and prevent bypasses. diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index ced5e6a3..0cdfa066 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java @@ -50,6 +50,16 @@ public final class TenantPermissions { */ public static final String ANALYTICS_READ = "analytics:read"; + /** + * Permission required to read admin data. + */ + public static final String ADMIN_READ = "admin:read"; + + /** + * Permission required to execute admin actions. + */ + public static final String ADMIN_WRITE = "admin:write"; + 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..0b3e686a 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -10,11 +10,15 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +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.TenantPermissions; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; @@ -26,24 +30,33 @@ public class AdminController { private final DocumentConversionService conversionService; + private final TenantAccessService tenantAccessService; /** * Creates a controller for admin operations. * * @param conversionService conversion service + * @param tenantAccessService tenant access service */ - 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 carrying tenant claims * @return list of conversion jobs */ @GetMapping("/api/v1/admin/convert/jobs") - public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered) { + public AdminJobListResponse getAllJobs( + @RequestParam(required = false) Boolean deadLettered, + @RequestHeader HttpHeaders headers) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_READ); Iterable allJobs = conversionService.getAllJobs(); if (deadLettered == null) { @@ -63,10 +76,12 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d * Deletes a conversion job. * * @param jobId conversion job identifier + * @param headers request headers carrying tenant claims * @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 HttpHeaders headers) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); conversionService.deleteJob(jobId); return ResponseEntity.noContent().build(); } @@ -75,10 +90,12 @@ public ResponseEntity deleteJob(@PathVariable UUID jobId) { * Retries a dead-lettered conversion job. * * @param jobId conversion job identifier + * @param headers request headers carrying tenant claims * @return accepted response on success */ @PostMapping("/api/v1/admin/convert/jobs/{jobId}/retry") - public ResponseEntity retryDeadLettered(@PathVariable UUID jobId) { + public ResponseEntity retryDeadLettered(@PathVariable UUID jobId, @RequestHeader HttpHeaders headers) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin"); 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 ad63a801..889f6a98 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -6,10 +6,16 @@ import java.util.Arrays; import java.util.UUID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; 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,13 +23,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,6 +42,8 @@ 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(TenantPermissions.ADMIN_READ))) + .thenReturn(new TenantContext("test-tenant", "test-subject", java.util.Set.of())); webTestClient.get() .uri("/api/v1/admin/convert/jobs") @@ -52,6 +62,8 @@ 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(TenantPermissions.ADMIN_READ))) + .thenReturn(new TenantContext("test-tenant", "test-subject", java.util.Set.of())); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") @@ -69,6 +81,8 @@ 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(TenantPermissions.ADMIN_READ))) + .thenReturn(new TenantContext("test-tenant", "test-subject", java.util.Set.of())); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") @@ -82,6 +96,8 @@ void getAllJobsFiltersByDeadLetteredFalse() { @Test void deleteJobReturnsNoContent() { UUID jobId = UUID.randomUUID(); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(new TenantContext("test-tenant", "test-subject", java.util.Set.of())); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) @@ -92,6 +108,8 @@ void deleteJobReturnsNoContent() { @Test void retryDeadLetteredReturnsAcceptedWhenAccepted() { UUID jobId = UUID.randomUUID(); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(new TenantContext("test-tenant", "test-subject", java.util.Set.of())); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); webTestClient.post() @@ -103,6 +121,8 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { @Test void retryDeadLetteredReturnsNotFoundWhenNotFound() { UUID jobId = UUID.randomUUID(); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(new TenantContext("test-tenant", "test-subject", java.util.Set.of())); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); webTestClient.post() @@ -114,6 +134,8 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { @Test void retryDeadLetteredReturnsConflictWhenNotEligible() { UUID jobId = UUID.randomUUID(); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(new TenantContext("test-tenant", "test-subject", java.util.Set.of())); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); webTestClient.post()