From 5b660e0b171511b90410972954d4fdc9c5e3559b Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:30:04 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITICAL]?= =?UTF-8?q?=20Admin=20Controller=20=EC=9D=B8=EC=A6=9D=20=EC=9A=B0=ED=9A=8C?= =?UTF-8?q?=20=EC=B7=A8=EC=95=BD=EC=A0=90=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `TenantPermissions.java`에 `ADMIN_READ` 및 `ADMIN_WRITE` 권한 추가 * `AdminController.java`에 `TenantAccessService` 주입 및 각 엔드포인트에 인증/인가 로직(`tenantAccessService.require(...)`) 적용 * `AdminControllerTest.java`에서 `TenantAccessService`를 모킹하고 모든 테스트 요청에 적절한 헤더를 추가하도록 테스트 코드 수정 * 발견된 취약점 내용 및 예방 방안을 `.jules/sentinel.md` 저널에 기록 --- .jules/sentinel.md | 4 +++ .../viewer/auth/TenantPermissions.java | 11 ++++++ .../viewer/controller/AdminController.java | 24 ++++++++++--- .../controller/AdminControllerTest.java | 34 +++++++++++++++++-- 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e795cb9dd..35d5d1c61 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -32,3 +32,7 @@ **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-04 - Missing Authentication on Admin Endpoints +**Vulnerability:** AdminController endpoints for listing, deleting, and retrying jobs lacked TenantAccessService authorization checks, allowing unauthenticated access. +**Learning:** The controller was created without injecting the standard TenantAccessService, bypassing the access controls used in other controllers. +**Prevention:** Always enforce authentication and authorization on backend API endpoints by injecting TenantAccessService and executing tenantAccessService.require() with specific permissions. diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index ced5e6a3d..7dc360174 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java @@ -50,6 +50,17 @@ 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 modify admin data. + */ + 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 412d4eb86..bb9684e43 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -15,6 +15,10 @@ import org.springframework.web.server.ResponseStatusException; import com.clearfolio.viewer.api.AdminJobListResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.RequestHeader; +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,14 +30,17 @@ 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; } /** @@ -43,7 +50,10 @@ public AdminController(DocumentConversionService conversionService) { * @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) { @@ -66,7 +76,10 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d * @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(); } @@ -78,7 +91,10 @@ public ResponseEntity deleteJob(@PathVariable UUID jobId) { * @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 ad63a8015..e295631c2 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -2,6 +2,8 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import java.util.Arrays; import java.util.UUID; @@ -9,21 +11,36 @@ 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; @BeforeEach void setUp() { conversionService = mock(DocumentConversionService.class); - controller = new AdminController(conversionService); + tenantAccessService = mock(TenantAccessService.class); + + // Mock TenantAccessService behavior for tests + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_READ))).thenReturn( + new TenantContext(TenantContext.DEMO_TENANT_ID, TenantContext.DEMO_SUBJECT_ID, null) + ); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE))).thenReturn( + new TenantContext(TenantContext.DEMO_TENANT_ID, TenantContext.DEMO_SUBJECT_ID, null) + ); + + controller = new AdminController(conversionService, tenantAccessService); webTestClient = WebTestClient.bindToController(controller) .controllerAdvice(new ApiExceptionHandler()) .build(); @@ -37,6 +54,7 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() { webTestClient.get() .uri("/api/v1/admin/convert/jobs") + .headers(headers -> addAuth(headers, TenantPermissions.ADMIN_READ)) .exchange() .expectStatus().isOk() .expectBody() @@ -55,6 +73,7 @@ void getAllJobsFiltersByDeadLetteredTrue() { webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") + .headers(headers -> addAuth(headers, TenantPermissions.ADMIN_READ)) .exchange() .expectStatus().isOk() .expectBody() @@ -72,6 +91,7 @@ void getAllJobsFiltersByDeadLetteredFalse() { webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") + .headers(headers -> addAuth(headers, TenantPermissions.ADMIN_READ)) .exchange() .expectStatus().isOk() .expectBody() @@ -85,6 +105,7 @@ void deleteJobReturnsNoContent() { webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) + .headers(headers -> addAuth(headers, TenantPermissions.ADMIN_WRITE)) .exchange() .expectStatus().isNoContent(); } @@ -96,6 +117,7 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .headers(headers -> addAuth(headers, TenantPermissions.ADMIN_WRITE)) .exchange() .expectStatus().isAccepted(); } @@ -107,6 +129,7 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .headers(headers -> addAuth(headers, TenantPermissions.ADMIN_WRITE)) .exchange() .expectStatus().isNotFound(); } @@ -118,7 +141,14 @@ void retryDeadLetteredReturnsConflictWhenNotEligible() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .headers(headers -> addAuth(headers, TenantPermissions.ADMIN_WRITE)) .exchange() .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer } -} + + private static void addAuth(HttpHeaders headers, String permissions) { + headers.add(TenantContext.TENANT_ID_HEADER, TenantContext.DEMO_TENANT_ID); + headers.add(TenantContext.SUBJECT_ID_HEADER, TenantContext.DEMO_SUBJECT_ID); + headers.add(TenantContext.PERMISSIONS_HEADER, permissions); + } +} \ No newline at end of file