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
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.

## 2024-06-20 - Missing Authentication on Admin Endpoints
**Vulnerability:** The endpoints in AdminController were exposed publicly without enforcing any access controls, potentially allowing unauthenticated clients to read or manipulate conversion jobs.
**Learning:** Administrative endpoints require explicit security enforcement (like `TenantAccessService`) ensuring only authorized clients bearing proper permission claims (e.g., `admin:operate`) can invoke them.
**Prevention:** Establish and enforce a secure-by-default policy for all administrative interfaces and routinely audit internal controllers to verify access service invocations.
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@
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.model.ConversionJob;
import com.clearfolio.viewer.service.DocumentConversionService;
import com.clearfolio.viewer.service.RetryDeadLetterResult;
Expand All @@ -25,25 +28,41 @@
@RestController
public class AdminController {

/**
* Service handling document conversions.
*/
private final DocumentConversionService conversionService;

/**
* Service for validating tenant permissions.
*/
private final TenantAccessService tenantAccessService;

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

/**
* 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, "admin:operate");
Iterable<ConversionJob> allJobs = conversionService.getAllJobs();

if (deadLettered == null) {
Expand All @@ -63,10 +82,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<Void> deleteJob(@PathVariable UUID jobId) {
public ResponseEntity<Void> deleteJob(
@PathVariable final UUID jobId,
@RequestHeader final HttpHeaders headers) {
tenantAccessService.require(headers, "admin:operate");
conversionService.deleteJob(jobId);
return ResponseEntity.noContent().build();
}
Expand All @@ -75,16 +98,23 @@ public ResponseEntity<Void> 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<Void> retryDeadLettered(@PathVariable UUID jobId) {
RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin");
public ResponseEntity<Void> retryDeadLettered(
@PathVariable final UUID jobId,
@RequestHeader final HttpHeaders headers) {
tenantAccessService.require(headers, "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 @@ -10,6 +10,8 @@
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.model.ConversionJob;
import com.clearfolio.viewer.service.DocumentConversionService;
import com.clearfolio.viewer.service.RetryDeadLetterResult;
Expand All @@ -19,11 +21,13 @@ class AdminControllerTest {
private DocumentConversionService conversionService;
private WebTestClient webTestClient;
private AdminController controller;
private TenantAccessService tenantAccessService;

@BeforeEach
void setUp() {
conversionService = mock(DocumentConversionService.class);
controller = new AdminController(conversionService);
tenantAccessService = new TenantAccessService();
controller = new AdminController(conversionService, tenantAccessService);
webTestClient = WebTestClient.bindToController(controller)
.controllerAdvice(new ApiExceptionHandler())
.build();
Expand All @@ -37,6 +41,9 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() {

webTestClient.get()
.uri("/api/v1/admin/convert/jobs")
.header(TenantContext.TENANT_ID_HEADER, "tenant1")
.header(TenantContext.SUBJECT_ID_HEADER, "user1")
.header(TenantContext.PERMISSIONS_HEADER, "admin:operate")
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -55,6 +62,9 @@ void getAllJobsFiltersByDeadLetteredTrue() {

webTestClient.get()
.uri("/api/v1/admin/convert/jobs?deadLettered=true")
.header(TenantContext.TENANT_ID_HEADER, "tenant1")
.header(TenantContext.SUBJECT_ID_HEADER, "user1")
.header(TenantContext.PERMISSIONS_HEADER, "admin:operate")
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -72,6 +82,9 @@ void getAllJobsFiltersByDeadLetteredFalse() {

webTestClient.get()
.uri("/api/v1/admin/convert/jobs?deadLettered=false")
.header(TenantContext.TENANT_ID_HEADER, "tenant1")
.header(TenantContext.SUBJECT_ID_HEADER, "user1")
.header(TenantContext.PERMISSIONS_HEADER, "admin:operate")
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -85,6 +98,9 @@ void deleteJobReturnsNoContent() {

webTestClient.delete()
.uri("/api/v1/admin/convert/jobs/" + jobId)
.header(TenantContext.TENANT_ID_HEADER, "tenant1")
.header(TenantContext.SUBJECT_ID_HEADER, "user1")
.header(TenantContext.PERMISSIONS_HEADER, "admin:operate")
.exchange()
.expectStatus().isNoContent();
}
Expand All @@ -96,6 +112,9 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() {

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.header(TenantContext.TENANT_ID_HEADER, "tenant1")
.header(TenantContext.SUBJECT_ID_HEADER, "user1")
.header(TenantContext.PERMISSIONS_HEADER, "admin:operate")
.exchange()
.expectStatus().isAccepted();
}
Expand All @@ -107,6 +126,9 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() {

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.header(TenantContext.TENANT_ID_HEADER, "tenant1")
.header(TenantContext.SUBJECT_ID_HEADER, "user1")
.header(TenantContext.PERMISSIONS_HEADER, "admin:operate")
.exchange()
.expectStatus().isNotFound();
}
Expand All @@ -118,7 +140,10 @@ void retryDeadLetteredReturnsConflictWhenNotEligible() {

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