From 46b92168d20be0d594e1c25bc194a517352f938e Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:45:41 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITICAL]?= =?UTF-8?q?=20Fix=20auth=20bypass=20in=20admin=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 엔드포인트(AdminController)에 접근 제어가 누락되어 익명 사용자가 접근할 수 있던 취약점을 수정했습니다. TenantAccessService를 주입하고 모든 관련 엔드포인트에 `ADMIN` 권한을 요구하도록 변경했습니다. --- .jules/sentinel.md | 5 ++ .../viewer/auth/TenantPermissions.java | 5 ++ .../viewer/auth/TenantPermissions.java.orig | 60 +++++++++++++++++++ .../viewer/controller/AdminController.java | 47 ++++++++++++--- .../controller/AdminControllerTest.java | 34 ++++++++++- 5 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java.orig diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e795cb9dd..e31560536 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-25 - 관리자 엔드포인트 인증 누락 방지 +**Vulnerability:** 작업 관리를 위한 관리자 엔드포인트에 인증 및 권한 부여 검사가 누락되어 익명 접근이 가능했습니다. +**Learning:** 내부 또는 관리자 엔드포인트는 네트워크 수준의 격리를 가정하여 글로벌 보안 필터 적용 시 누락되기 쉽습니다. +**Prevention:** 항상 기본적으로 모든 컨트롤러와 엔드포인트에 접근 제어(예: TenantAccessService)를 주입하고 강제해야 합니다. diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index 4f9c6a685..804e99420 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 for cross-tenant administrative operations. + */ + public static final String ADMIN = "admin"; + private TenantPermissions() { } } diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java.orig b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java.orig new file mode 100644 index 000000000..4f9c6a685 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java.orig @@ -0,0 +1,60 @@ +package com.clearfolio.viewer.auth; + +/** + * Permission names enforced by Clearfolio API endpoints. + */ +public final class TenantPermissions { + + /** + * Permission required to submit a conversion job. + */ + public static final String JOB_CREATE = "job:create"; + + /** + * Permission required to read conversion job status. + */ + public static final String JOB_READ = "job:read"; + + /** + * Permission required to delete a conversion job. + */ + public static final String JOB_DELETE = "job:delete"; + + /** + * Permission required to retry a dead-lettered conversion job. + */ + public static final String JOB_RETRY = "job:retry"; + + /** + * Permission required to read viewer bootstrap payloads. + */ + public static final String VIEWER_READ = "viewer:read"; + + /** + * Permission required to read generated document artifact bytes. + */ + public static final String ARTIFACT_READ = "artifact:read"; + + /** + * Permission required to create signed artifact links. + */ + public static final String ARTIFACT_LINK_CREATE = "artifact-link:create"; + + /** + * Permission required to revoke signed artifact links. + */ + public static final String ARTIFACT_LINK_REVOKE = "artifact-link:revoke"; + + /** + * Permission required to read audit evidence. + */ + public static final String AUDIT_READ = "audit:read"; + + /** + * Permission required to read buyer-demo analytics. + */ + public static final String ANALYTICS_READ = "analytics:read"; + + 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..148a3abe1 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,25 +29,37 @@ @RestController public class AdminController { + /** Conversion service. */ private final DocumentConversionService conversionService; + /** Tenant access service. */ + private final TenantAccessService tenantAccessService; + /** * Creates a controller for admin operations. * - * @param conversionService conversion service + * @param conversionSvc conversion service + * @param tenantAccessSvc tenant access service */ - public AdminController(DocumentConversionService conversionService) { - this.conversionService = conversionService; + public AdminController( + final DocumentConversionService conversionSvc, + final TenantAccessService tenantAccessSvc) { + this.conversionService = conversionSvc; + this.tenantAccessService = tenantAccessSvc; } /** * 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) { + public AdminJobListResponse getAllJobs( + @RequestParam(required = false) final Boolean deadLettered, + @RequestHeader final HttpHeaders headers) { + tenantAccessService.require(headers, TenantPermissions.ADMIN); Iterable allJobs = conversionService.getAllJobs(); if (deadLettered == null) { @@ -63,10 +79,14 @@ 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 final UUID jobId, + @RequestHeader final HttpHeaders headers) { + tenantAccessService.require(headers, TenantPermissions.ADMIN); conversionService.deleteJob(jobId); return ResponseEntity.noContent().build(); } @@ -75,16 +95,25 @@ 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) { + tenantAccessService.require(headers, TenantPermissions.ADMIN); + RetryDeadLetterResult result = + conversionService.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 ad63a8015..6083eb054 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,15 +1,24 @@ 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 java.util.Arrays; +import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.server.ResponseStatusException; +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 +26,18 @@ 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); + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN))) + .thenReturn(new TenantContext("admin-tenant", "admin-user", Set.of(TenantPermissions.ADMIN))); + + controller = new AdminController(conversionService, tenantAccessService); webTestClient = WebTestClient.bindToController(controller) .controllerAdvice(new ApiExceptionHandler()) .build(); @@ -37,6 +51,7 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() { webTestClient.get() .uri("/api/v1/admin/convert/jobs") + .header("X-Clearfolio-Tenant-Id", "admin-tenant") .exchange() .expectStatus().isOk() .expectBody() @@ -55,6 +70,7 @@ void getAllJobsFiltersByDeadLetteredTrue() { webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") + .header("X-Clearfolio-Tenant-Id", "admin-tenant") .exchange() .expectStatus().isOk() .expectBody() @@ -72,6 +88,7 @@ void getAllJobsFiltersByDeadLetteredFalse() { webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") + .header("X-Clearfolio-Tenant-Id", "admin-tenant") .exchange() .expectStatus().isOk() .expectBody() @@ -85,6 +102,7 @@ void deleteJobReturnsNoContent() { webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) + .header("X-Clearfolio-Tenant-Id", "admin-tenant") .exchange() .expectStatus().isNoContent(); } @@ -96,6 +114,7 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Clearfolio-Tenant-Id", "admin-tenant") .exchange() .expectStatus().isAccepted(); } @@ -107,6 +126,7 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Clearfolio-Tenant-Id", "admin-tenant") .exchange() .expectStatus().isNotFound(); } @@ -118,7 +138,19 @@ void retryDeadLetteredReturnsConflictWhenNotEligible() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Clearfolio-Tenant-Id", "admin-tenant") .exchange() .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer } + + @Test + void getAllJobsRejectsMissingAdminPermission() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN))) + .thenThrow(new ResponseStatusException(HttpStatus.FORBIDDEN, "missing permission: admin")); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs") + .exchange() + .expectStatus().isForbidden(); + } }