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
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-07-31 - Admin Endpoint Auth Bypass
**Vulnerability:** AdminController 에 인증이 누락되어 누구나 관리자 작업을 수행할 수 있었습니다.
**Learning:** 모든 컨트롤러 엔드포인트는 TenantAccessService 를 통해 명시적으로 권한을 검사해야 합니다.
**Prevention:** API 엔드포인트 작성 시 항상 @RequestHeader HttpHeaders 를 받고 tenantAccessService.require() 를 호출하도록 합니다.
10 changes: 10 additions & 0 deletions src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ public final class TenantPermissions {
*/
public static final String ANALYTICS_READ = "analytics:read";

/**
* Permission required to perform read operations on admin endpoints.
*/
public static final String ADMIN_READ = "admin:read";

/**
* Permission required to perform write operations on admin endpoints.
*/
public static final String ADMIN_WRITE = "admin:write";

private TenantPermissions() {
}
}
72 changes: 57 additions & 15 deletions src/main/java/com/clearfolio/viewer/controller/AdminController.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,25 @@

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
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.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;
Expand All @@ -25,34 +31,49 @@
@RestController
public class AdminController {

/**
* Document 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) {
TenantContext context = tenantAccessService.require(
headers, TenantPermissions.ADMIN_READ);
Iterable<ConversionJob> allJobs = conversionService.getAllJobs();

if (deadLettered == null) {
return AdminJobListResponse.from(allJobs);
}

List<ConversionJob> filtered = new ArrayList<>();
for (ConversionJob job : allJobs) {
if (job.isDeadLettered() == deadLettered) {
if (job.belongsToTenant(context.tenantId())
&& (deadLettered == null
|| job.isDeadLettered() == deadLettered)) {
filtered.add(job);
}
}
Expand All @@ -63,28 +84,49 @@ 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<Void> deleteJob(@PathVariable UUID jobId) {
conversionService.deleteJob(jobId);
public ResponseEntity<Void> deleteJob(
@PathVariable final UUID jobId,
@RequestHeader final HttpHeaders headers) {
TenantContext context = tenantAccessService.require(
headers, TenantPermissions.ADMIN_WRITE);
if (!conversionService.deleteJob(jobId, context)) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "job not found");
}
return ResponseEntity.noContent().build();
}

/**
* 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<Void> retryDeadLettered(@PathVariable UUID jobId) {
RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin");
public ResponseEntity<Void> retryDeadLettered(
@PathVariable final UUID jobId,
@RequestHeader final HttpHeaders headers) {
TenantContext context = tenantAccessService.require(
headers, TenantPermissions.ADMIN_WRITE);
Optional<ConversionJob> job = conversionService.getJob(jobId);
if (job.isEmpty() || !job.get().belongsToTenant(context.tenantId())) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "job not found");
}
RetryDeadLetterResult result = conversionService.retryDeadLettered(
jobId, context.subjectId());
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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,54 @@
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.Optional;
import java.util.Set;
import java.util.UUID;

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;

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

@Test
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);
ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "admin", "admin", "a.pdf", "application/pdf", "hash-a", 100L, 3);
ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "admin", "admin", "b.pdf", "application/pdf", "hash-b", 100L, 3);
when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));
when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_READ)))
.thenReturn(new TenantContext("admin", "admin", Set.of(TenantPermissions.ADMIN_READ)));

webTestClient.get()
.uri("/api/v1/admin/convert/jobs")
.header(TenantContext.TENANT_ID_HEADER, "admin")
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -47,14 +59,17 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() {

@Test
void getAllJobsFiltersByDeadLetteredTrue() {
ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L);
ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "admin", "admin", "a.pdf", "application/pdf", "hash-a", 100L, 3);
job1.markDeadLettered("failed");
ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L);
ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "admin", "admin", "b.pdf", "application/pdf", "hash-b", 100L, 3);

when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));
when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_READ)))
.thenReturn(new TenantContext("admin", "admin", Set.of(TenantPermissions.ADMIN_READ)));

webTestClient.get()
.uri("/api/v1/admin/convert/jobs?deadLettered=true")
.header(TenantContext.TENANT_ID_HEADER, "admin")
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -64,14 +79,17 @@ void getAllJobsFiltersByDeadLetteredTrue() {

@Test
void getAllJobsFiltersByDeadLetteredFalse() {
ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L);
ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "admin", "admin", "a.pdf", "application/pdf", "hash-a", 100L, 3);
job1.markDeadLettered("failed");
ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L);
ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "admin", "admin", "b.pdf", "application/pdf", "hash-b", 100L, 3);

when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));
when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_READ)))
.thenReturn(new TenantContext("admin", "admin", Set.of(TenantPermissions.ADMIN_READ)));

webTestClient.get()
.uri("/api/v1/admin/convert/jobs?deadLettered=false")
.header(TenantContext.TENANT_ID_HEADER, "admin")
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -82,42 +100,58 @@ void getAllJobsFiltersByDeadLetteredFalse() {
@Test
void deleteJobReturnsNoContent() {
UUID jobId = UUID.randomUUID();
when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE)))
.thenReturn(new TenantContext("admin", "admin", Set.of(TenantPermissions.ADMIN_WRITE)));
when(conversionService.deleteJob(eq(jobId), any(TenantContext.class))).thenReturn(true);

webTestClient.delete()
.uri("/api/v1/admin/convert/jobs/" + jobId)
.header(TenantContext.TENANT_ID_HEADER, "admin")
.exchange()
.expectStatus().isNoContent();
}

@Test
void retryDeadLetteredReturnsAcceptedWhenAccepted() {
UUID jobId = UUID.randomUUID();
when(conversionService.getJob(jobId)).thenReturn(Optional.of(new ConversionJob(jobId, "admin", "admin", "doc", "pdf", "h", 1L, 3)));
when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED);
when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE)))
.thenReturn(new TenantContext("admin", "admin", Set.of(TenantPermissions.ADMIN_WRITE)));

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.header(TenantContext.TENANT_ID_HEADER, "admin")
.exchange()
.expectStatus().isAccepted();
}

@Test
void retryDeadLetteredReturnsNotFoundWhenNotFound() {
UUID jobId = UUID.randomUUID();
when(conversionService.getJob(jobId)).thenReturn(Optional.of(new ConversionJob(jobId, "admin", "admin", "doc", "pdf", "h", 1L, 3)));
when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND);
when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE)))
.thenReturn(new TenantContext("admin", "admin", Set.of(TenantPermissions.ADMIN_WRITE)));

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.header(TenantContext.TENANT_ID_HEADER, "admin")
.exchange()
.expectStatus().isNotFound();
}

@Test
void retryDeadLetteredReturnsConflictWhenNotEligible() {
UUID jobId = UUID.randomUUID();
when(conversionService.getJob(jobId)).thenReturn(Optional.of(new ConversionJob(jobId, "admin", "admin", "doc", "pdf", "h", 1L, 3)));
when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE);
when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE)))
.thenReturn(new TenantContext("admin", "admin", Set.of(TenantPermissions.ADMIN_WRITE)));

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.header(TenantContext.TENANT_ID_HEADER, "admin")
.exchange()
.expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer
}
Expand Down
Loading