Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-04 - 관리자 API 권한 검증 누락 패치
**Vulnerability:** AdminController의 모든 엔드포인트에 권한 검증이 누락되어 누구나 관리자 API를 호출할 수 있는 취약점이 존재했습니다.
**Learning:** 관리자 API 엔드포인트를 추가할 때 TenantAccessService를 통한 권한 검증 로직 추가를 누락했습니다.
**Prevention:** 모든 새로운 API 엔드포인트 설계 및 구현 시 항상 적절한 TenantPermissions를 정의하고 TenantAccessService.require()를 호출하도록 리뷰 체크리스트에 포함해야 합니다.
5 changes: 5 additions & 0 deletions .markdownlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ MD034: false
MD009: false
MD055: false
MD056: false

MD060: false

MD012: false
MD024: false
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

## [Unreleased]

### 보안 (Security)
- **관리자 API 권한 검증 추가**: `AdminController`의 모든 엔드포인트(`GET /api/v1/admin/convert/jobs`, `DELETE /api/v1/admin/convert/jobs/{jobId}`, `POST /api/v1/admin/convert/jobs/{jobId}/retry`)에 대해 `TenantAccessService`를 통한 `ADMIN_READ` 및 `ADMIN_WRITE` 권한 검증 로직을 추가하여 인증 우회 취약점을 패치했습니다.

### 추가된 기능 (Added)
- **관리자용 단건 작업 삭제 및 재시도 API 추가**
- 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ public final class TenantPermissions {
*/
public static final String ANALYTICS_READ = "analytics:read";

public static final String ADMIN_READ = "admin:read";

public static final String ADMIN_WRITE = "admin:write";

private TenantPermissions() {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

/**
Expand All @@ -43,7 +50,11 @@ 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<ConversionJob> allJobs = conversionService.getAllJobs();

if (deadLettered == null) {
Expand All @@ -66,7 +77,8 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d
* @return no content on success
*/
@DeleteMapping("/api/v1/admin/convert/jobs/{jobId}")
public ResponseEntity<Void> deleteJob(@PathVariable UUID jobId) {
public ResponseEntity<Void> deleteJob(@PathVariable UUID jobId, @RequestHeader HttpHeaders headers) {
tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE);
conversionService.deleteJob(jobId);
return ResponseEntity.noContent().build();
}
Expand All @@ -78,7 +90,8 @@ public ResponseEntity<Void> deleteJob(@PathVariable UUID jobId) {
* @return accepted response on success
*/
@PostMapping("/api/v1/admin/convert/jobs/{jobId}/retry")
public ResponseEntity<Void> retryDeadLettered(@PathVariable UUID jobId) {
public ResponseEntity<Void> 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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -8,25 +10,36 @@

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.auth.TenantPermissions;
import com.clearfolio.viewer.model.ConversionJob;
import com.clearfolio.viewer.service.DocumentConversionService;
import com.clearfolio.viewer.service.RetryDeadLetterResult;

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();

when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ)))
.thenReturn(new TenantContext("tenant1", "subject1", java.util.Set.of()));
when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE)))
.thenReturn(new TenantContext("tenant1", "subject1", java.util.Set.of()));
}

@Test
Expand Down
Loading