diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e795cb9d..fadd74a2 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-28 - Add authentication to admin endpoint +**Vulnerability:** Admin endpoints in `AdminController.java` were missing authentication and authorization checks, allowing unauthenticated access to sensitive operations like viewing all jobs and deleting them. +**Learning:** All backend API endpoints, especially administrative ones, must enforce authentication and authorization to secure sensitive operations and prevent unauthorized access. +**Prevention:** Always inject `TenantAccessService` and execute `tenantAccessService.require(headers, TenantPermissions.[SPECIFIC_PERMISSION])` in all endpoints. diff --git a/commit_message.txt b/commit_message.txt index 32f35276..7091e7cb 100644 --- a/commit_message.txt +++ b/commit_message.txt @@ -1,4 +1,3 @@ -πŸ›‘οΈ Sentinel: [CRITICAL] 파일 μ—…λ‘œλ“œ 경둜 μ‘°μž‘(Path Traversal) 취약점 μˆ˜μ • +feat: admin μ—”λ“œν¬μΈνŠΈμ— 인증 및 인가 검증 μΆ”κ°€ -MultipartFile.getOriginalFilename()을 μ‹ λ’°ν•˜μ—¬ λ°œμƒν•  수 μžˆλŠ” 경둜 μ‘°μž‘ 취약점을 μˆ˜μ •ν–ˆμŠ΅λ‹ˆλ‹€. -StringUtils.cleanPath()λ₯Ό μ‚¬μš©ν•˜μ—¬ 경둜λ₯Ό μ •κ·œν™”ν•˜κ³  μˆœμˆ˜ν•œ 파일λͺ…λ§Œ μΆ”μΆœν•˜μ—¬ μ•…μ˜μ μΈ νŽ˜μ΄λ‘œλ“œ(예: ../../../etc/passwd.hwp)λ‘œλΆ€ν„° μ‹œμŠ€ν…œμ„ λ³΄ν˜Έν•©λ‹ˆλ‹€. +`AdminController`의 `getAllJobs`, `deleteJob`, `retryDeadLettered` μ—”λ“œν¬μΈνŠΈμ— 인증/인가가 λˆ„λ½λ˜μ–΄ 비인가 μ ‘κ·Ό 및 정보 λ…ΈμΆœ 취약점이 μ‘΄μž¬ν–ˆμŠ΅λ‹ˆλ‹€. 이λ₯Ό ν•΄κ²°ν•˜κΈ° μœ„ν•΄ `TenantAccessService`λ₯Ό μ£Όμž…ν•˜κ³ , `TenantPermissions.ADMIN_READ` 및 `TenantPermissions.ADMIN_WRITE` 검증을 μΆ”κ°€ν•˜μ—¬ λ³΄μ•ˆμ„ κ°•ν™”ν–ˆμŠ΅λ‹ˆλ‹€. diff --git a/description.txt b/description.txt index 861a67de..7666c7f1 100644 --- a/description.txt +++ b/description.txt @@ -1,5 +1,5 @@ -🚨 Severity: CRITICAL -πŸ’‘ Vulnerability: 파일 μ—…λ‘œλ“œ μ‹œ `MultipartFile.getOriginalFilename()` 값을 검증 없이 μ‚¬μš©ν•˜μ—¬ λ°œμƒν•  수 μžˆλŠ” 경둜 μ‘°μž‘(Path Traversal) 취약점 발견. -🎯 Impact: κ³΅κ²©μžκ°€ 디렉토리 탐색 λ¬Έμžμ—΄(`../`)을 ν¬ν•¨ν•œ 파일λͺ…을 μ „μ†‘ν•˜μ—¬ μ˜λ„ν•˜μ§€ μ•Šμ€ κ²½λ‘œμ— νŒŒμΌμ„ μ €μž₯ν•˜κ±°λ‚˜ μ‹œμŠ€ν…œ 파일(예: `/etc/passwd`)에 μ ‘κ·Ό/μ‘°μž‘ν•  μœ„ν—˜μ΄ 있음. -πŸ”§ Fix: `DefaultDocumentConversionService` 및 `DefaultDocumentValidationService`μ—μ„œ 파일λͺ…을 μ‚¬μš©ν•˜κΈ° μ „ `StringUtils.cleanPath()`λ₯Ό 톡해 경둜λ₯Ό μ •κ·œν™”ν•˜κ³ , λ§ˆμ§€λ§‰ `/` μ΄ν›„μ˜ μˆœμˆ˜ν•œ 파일λͺ…λ§Œ μΆ”μΆœν•˜λ„λ‘ `sanitizeFilename` λ©”μ†Œλ“œλ₯Ό μΆ”κ°€ν•˜μ—¬ μ•ˆμ „ν•˜κ²Œ μ²˜λ¦¬ν•¨. -βœ… Verification: λ‹¨μœ„ ν…ŒμŠ€νŠΈ(`submitStripsDirectoryTraversalFromOriginalFilename` 및 `stripsDirectoryTraversalFromFilename` λ“±)λ₯Ό μΆ”κ°€ν•˜μ—¬ 취약점 λ¬Έμžμ—΄μ΄ μ •μƒμ μœΌλ‘œ 제거되며 100% ν…ŒμŠ€νŠΈ 컀버리지λ₯Ό 보μž₯함. +🚨 Severity: HIGH +πŸ’‘ Vulnerability: AdminController의 κ΄€λ¦¬μž API μ—”λ“œν¬μΈνŠΈμ— 인증 및 κΆŒν•œ 검증 둜직이 λˆ„λ½λ˜μ–΄ μΈκ°€λ˜μ§€ μ•Šμ€ μ‚¬μš©μžκ°€ λͺ¨λ“  λ³€ν™˜ μž‘μ—…μ„ μ‘°νšŒν•˜κ±°λ‚˜ μ‚­μ œν•  수 μžˆλŠ” 취약점이 μ‘΄μž¬ν–ˆμŠ΅λ‹ˆλ‹€. +🎯 Impact: κ³΅κ²©μžκ°€ κΆŒν•œ 검증 없이 κ΄€λ¦¬μž API에 μ ‘κ·Όν•˜μ—¬ μ‹œμŠ€ν…œμ˜ λ―Όκ°ν•œ 데이터λ₯Ό μ—΄λžŒν•˜κ±°λ‚˜, μ§„ν–‰ μ€‘μ΄κ±°λ‚˜ μ„±κ³΅ν•œ λ³€ν™˜ μž‘μ—…μ„ λ¬΄λ‹¨μœΌλ‘œ μ‚­μ œ 및 μž¬μ‹œλ„ν•  수 μžˆμ–΄ μ‹¬κ°ν•œ 데이터 손싀 및 κΆŒν•œ μš°νšŒκ°€ κ°€λŠ₯ν–ˆμŠ΅λ‹ˆλ‹€. +πŸ”§ Fix: `TenantPermissions`에 `ADMIN_READ`와 `ADMIN_WRITE` κΆŒν•œ μƒμˆ˜λ₯Ό μΆ”κ°€ν•˜κ³ , `AdminController`의 각 μ—”λ“œν¬μΈνŠΈμ— `TenantAccessService`λ₯Ό μ£Όμž…ν•˜μ—¬ 인증 및 κΆŒν•œ 검증(`tenantAccessService.require(...)`)을 κ°•μ œν•˜λ„λ‘ νŒ¨μΉ˜ν–ˆμŠ΅λ‹ˆλ‹€. +βœ… Verification: `mvn test`λ₯Ό 톡해 λ³΄μ•ˆ 둜직 μΆ”κ°€ ν›„ κΈ°μ‘΄ ν…ŒμŠ€νŠΈ μ½”λ“œκ°€ μ μ ˆν•œ λͺ¨μ˜(Mocking)λ₯Ό 거쳐 100% ν…ŒμŠ€νŠΈ 컀버리지λ₯Ό λ§Œμ‘±ν•˜κ³  μ„±κ³΅μ μœΌλ‘œ λ™μž‘ν•¨μ„ ν™•μΈν–ˆμŠ΅λ‹ˆλ‹€. diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index ced5e6a3..d268c41b 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 write 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 412d4eb8..95441ae9 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.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 org.springframework.http.HttpHeaders; 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,31 @@ 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 * @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 +74,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 UUID jobId, + @RequestHeader HttpHeaders headers) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); conversionService.deleteJob(jobId); return ResponseEntity.noContent().build(); } @@ -75,10 +90,14 @@ 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) { + 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..3ba96555 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; @@ -9,7 +11,11 @@ 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.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,9 +42,12 @@ 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(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) + .thenReturn(new TenantContext("tenant", "subject", java.util.Set.of())); webTestClient.get() .uri("/api/v1/admin/convert/jobs") + .header("Authorization", "Bearer test") .exchange() .expectStatus().isOk() .expectBody() @@ -52,9 +63,12 @@ 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(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) + .thenReturn(new TenantContext("tenant", "subject", java.util.Set.of())); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") + .header("Authorization", "Bearer test") .exchange() .expectStatus().isOk() .expectBody() @@ -69,9 +83,12 @@ 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(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) + .thenReturn(new TenantContext("tenant", "subject", java.util.Set.of())); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") + .header("Authorization", "Bearer test") .exchange() .expectStatus().isOk() .expectBody() @@ -82,9 +99,12 @@ void getAllJobsFiltersByDeadLetteredFalse() { @Test void deleteJobReturnsNoContent() { UUID jobId = UUID.randomUUID(); + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(new TenantContext("tenant", "subject", java.util.Set.of())); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) + .header("Authorization", "Bearer test") .exchange() .expectStatus().isNoContent(); } @@ -93,9 +113,12 @@ void deleteJobReturnsNoContent() { void retryDeadLetteredReturnsAcceptedWhenAccepted() { UUID jobId = UUID.randomUUID(); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(new TenantContext("tenant", "subject", java.util.Set.of())); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("Authorization", "Bearer test") .exchange() .expectStatus().isAccepted(); } @@ -104,9 +127,12 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { void retryDeadLetteredReturnsNotFoundWhenNotFound() { UUID jobId = UUID.randomUUID(); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(new TenantContext("tenant", "subject", java.util.Set.of())); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("Authorization", "Bearer test") .exchange() .expectStatus().isNotFound(); } @@ -115,9 +141,12 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { void retryDeadLetteredReturnsConflictWhenNotEligible() { UUID jobId = UUID.randomUUID(); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(new TenantContext("tenant", "subject", java.util.Set.of())); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("Authorization", "Bearer test") .exchange() .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer }