Skip to content
Open
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-10-11 - Missing Authorization on Admin Endpoints
**Vulnerability:** The `/api/v1/admin/convert/jobs` and associated endpoints did not implement `TenantAccessService` authorization checks, exposing internal conversion jobs.
**Learning:** Even internal or admin APIs require explicit tenant permission checks (e.g. `admin:operate`) via the central authorization layer.
**Prevention:** All administrative and debugging endpoints must be secured using `TenantAccessService.require(headers, permission)` before processing requests.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
*/
public final class TenantPermissions {

/**
* Permission required to operate on administrative endpoints.
*/
public static final String ADMIN_OPERATE = "admin:operate";

/**
* Permission required to submit a conversion job.
*/
Expand Down
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 @@ -25,25 +29,41 @@
@RestController
public class AdminController {

/**
* Conversion service for administrative operations.
*/
private final DocumentConversionService conversionService;

/**
* Service to verify tenant access.
*/
private final TenantAccessService tenantAccessService;

/**
* Creates a controller for admin operations.
*
* @param conversionService conversion service
* @param conversionServiceInjected conversion service
* @param tenantAccessServiceInjected access service
*/
public AdminController(DocumentConversionService conversionService) {
this.conversionService = conversionService;
public AdminController(
final DocumentConversionService conversionServiceInjected,
final TenantAccessService tenantAccessServiceInjected) {
this.conversionService = conversionServiceInjected;
this.tenantAccessService = tenantAccessServiceInjected;
}

/**
* Retrieves all conversion jobs, optionally filtered by dead-letter status.
*
* @param headers request headers
* @param deadLettered optional filter for dead-lettered jobs
* @return list of conversion jobs
*/
@GetMapping("/api/v1/admin/convert/jobs")
public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered) {
public AdminJobListResponse getAllJobs(
@RequestHeader final HttpHeaders headers,
@RequestParam(required = false) final Boolean deadLettered) {
tenantAccessService.require(headers, TenantPermissions.ADMIN_OPERATE);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸŸ₯ Admin permission exposes other tenants' jobs

A tenant-scoped caller with admin:operate receives every tenant's jobs through getAllJobs. The same permission allows cross-tenant deletion and retry because require never checks job ownership.

Devin Review


Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

Iterable<ConversionJob> allJobs = conversionService.getAllJobs();

if (deadLettered == null) {
Expand All @@ -62,29 +82,40 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d
/**
* Deletes a conversion job.
*
* @param headers request headers
* @param jobId conversion job identifier
* @return no content on success
*/
@DeleteMapping("/api/v1/admin/convert/jobs/{jobId}")
public ResponseEntity<Void> deleteJob(@PathVariable UUID jobId) {
public ResponseEntity<Void> deleteJob(
@RequestHeader final HttpHeaders headers,
@PathVariable final UUID jobId) {
tenantAccessService.require(headers, TenantPermissions.ADMIN_OPERATE);
conversionService.deleteJob(jobId);
return ResponseEntity.noContent().build();
}

/**
* Retries a dead-lettered conversion job.
*
* @param headers request headers
* @param jobId conversion job identifier
* @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(
@RequestHeader final HttpHeaders headers,
@PathVariable final UUID jobId) {
tenantAccessService.require(headers, TenantPermissions.ADMIN_OPERATE);
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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@

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;
Expand All @@ -23,12 +27,19 @@ class AdminControllerTest {
@BeforeEach
void setUp() {
conversionService = mock(DocumentConversionService.class);
controller = new AdminController(conversionService);
TenantAccessService tenantAccessService = new TenantAccessService();
controller = new AdminController(conversionService, tenantAccessService);
webTestClient = WebTestClient.bindToController(controller)
.controllerAdvice(new ApiExceptionHandler())
.build();
}

private void addAuth(final HttpHeaders headers) {
headers.add(TenantContext.TENANT_ID_HEADER, "tenant-a");
headers.add(TenantContext.SUBJECT_ID_HEADER, "admin-user");
headers.add(TenantContext.PERMISSIONS_HEADER, TenantPermissions.ADMIN_OPERATE);
}

@Test
void getAllJobsReturnsAllJobsWhenNoFilterProvided() {
ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L);
Expand All @@ -37,6 +48,7 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() {

webTestClient.get()
.uri("/api/v1/admin/convert/jobs")
.headers(this::addAuth)
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -55,6 +67,7 @@ void getAllJobsFiltersByDeadLetteredTrue() {

webTestClient.get()
.uri("/api/v1/admin/convert/jobs?deadLettered=true")
.headers(this::addAuth)
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -72,6 +85,7 @@ void getAllJobsFiltersByDeadLetteredFalse() {

webTestClient.get()
.uri("/api/v1/admin/convert/jobs?deadLettered=false")
.headers(this::addAuth)
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -85,6 +99,7 @@ void deleteJobReturnsNoContent() {

webTestClient.delete()
.uri("/api/v1/admin/convert/jobs/" + jobId)
.headers(this::addAuth)
.exchange()
.expectStatus().isNoContent();
}
Expand All @@ -96,6 +111,7 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() {

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.headers(this::addAuth)
.exchange()
.expectStatus().isAccepted();
}
Expand All @@ -107,6 +123,7 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() {

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.headers(this::addAuth)
.exchange()
.expectStatus().isNotFound();
}
Expand All @@ -118,7 +135,21 @@ void retryDeadLetteredReturnsConflictWhenNotEligible() {

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.headers(this::addAuth)
.exchange()
.expectStatus().isEqualTo(409);
}

@Test
void requiresAdminOperatePermission() {
webTestClient.get()
.uri("/api/v1/admin/convert/jobs")
.headers(headers -> {
headers.add(TenantContext.TENANT_ID_HEADER, "tenant-a");
headers.add(TenantContext.SUBJECT_ID_HEADER, "user-1");
headers.add(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_READ);
})
.exchange()
.expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer
.expectStatus().isForbidden();
}
}
Loading