diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e795cb9d..23a36983 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-31 - Missing authorization checks on AdminController endpoints +**Vulnerability:** Admin controller endpoints (`/api/v1/admin/convert/jobs`) lacked authorization checks, allowing unauthenticated users to read, delete, and retry all jobs. +**Learning:** Endpoints meant for internal or administrative use must still explicitly enforce authorization using `TenantAccessService`, as routing configuration alone does not provide sufficient protection. +**Prevention:** Always secure all backend API endpoints by injecting `TenantAccessService` and explicitly executing `tenantAccessService.require(headers, ...)` to enforce authorization. diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index ced5e6a3..97c12637 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 jobs. + */ + public static final String ADMIN_READ = "admin:read"; + + /** + * Permission required to perform write actions on admin endpoints. + */ + 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..f477aa66 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -15,6 +15,7 @@ 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; @@ -26,24 +27,31 @@ public class AdminController { private final DocumentConversionService conversionService; + private final TenantAccessService tenantAccessService; /** * Creates a controller for admin operations. * * @param conversionService conversion service + * @param tenantAccessService service for tenant access validation */ - 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 headers HTTP request headers containing tenant claims * @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( + @org.springframework.web.bind.annotation.RequestHeader org.springframework.http.HttpHeaders headers, + @RequestParam(required = false) Boolean deadLettered) { + tenantAccessService.require(headers, com.clearfolio.viewer.auth.TenantPermissions.ADMIN_READ); Iterable allJobs = conversionService.getAllJobs(); if (deadLettered == null) { @@ -62,11 +70,15 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d /** * Deletes a conversion job. * + * @param headers HTTP request headers containing tenant claims * @param jobId conversion job identifier * @return no content on success */ @DeleteMapping("/api/v1/admin/convert/jobs/{jobId}") - public ResponseEntity deleteJob(@PathVariable UUID jobId) { + public ResponseEntity deleteJob( + @org.springframework.web.bind.annotation.RequestHeader org.springframework.http.HttpHeaders headers, + @PathVariable UUID jobId) { + tenantAccessService.require(headers, com.clearfolio.viewer.auth.TenantPermissions.ADMIN_WRITE); conversionService.deleteJob(jobId); return ResponseEntity.noContent().build(); } @@ -74,12 +86,16 @@ public ResponseEntity deleteJob(@PathVariable UUID jobId) { /** * Retries a dead-lettered conversion job. * + * @param headers HTTP request headers containing tenant claims * @param jobId conversion job identifier * @return accepted response on success */ @PostMapping("/api/v1/admin/convert/jobs/{jobId}/retry") - public ResponseEntity retryDeadLettered(@PathVariable UUID jobId) { - RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin"); + public ResponseEntity retryDeadLettered( + @org.springframework.web.bind.annotation.RequestHeader org.springframework.http.HttpHeaders headers, + @PathVariable UUID jobId) { + com.clearfolio.viewer.auth.TenantContext context = tenantAccessService.require(headers, com.clearfolio.viewer.auth.TenantPermissions.ADMIN_WRITE); + RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, context.subjectId()); 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..18b56c98 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,15 +1,20 @@ package com.clearfolio.viewer.controller; import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import java.util.Arrays; import java.util.UUID; +import java.util.Collections; 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.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; @@ -17,13 +22,18 @@ 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); + TenantContext dummyContext = new TenantContext("dummy-tenant", "dummy-admin", Collections.emptySet()); + when(tenantAccessService.require(any(), anyString())).thenReturn(dummyContext); + + controller = new AdminController(conversionService, tenantAccessService); webTestClient = WebTestClient.bindToController(controller) .controllerAdvice(new ApiExceptionHandler()) .build(); @@ -37,6 +47,7 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() { webTestClient.get() .uri("/api/v1/admin/convert/jobs") + .header("X-Dummy", "dummy") .exchange() .expectStatus().isOk() .expectBody() @@ -55,6 +66,7 @@ void getAllJobsFiltersByDeadLetteredTrue() { webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") + .header("X-Dummy", "dummy") .exchange() .expectStatus().isOk() .expectBody() @@ -72,6 +84,7 @@ void getAllJobsFiltersByDeadLetteredFalse() { webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") + .header("X-Dummy", "dummy") .exchange() .expectStatus().isOk() .expectBody() @@ -85,6 +98,7 @@ void deleteJobReturnsNoContent() { webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) + .header("X-Dummy", "dummy") .exchange() .expectStatus().isNoContent(); } @@ -92,10 +106,11 @@ void deleteJobReturnsNoContent() { @Test void retryDeadLetteredReturnsAcceptedWhenAccepted() { UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); + when(conversionService.retryDeadLettered(jobId, "dummy-admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Dummy", "dummy") .exchange() .expectStatus().isAccepted(); } @@ -103,10 +118,11 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { @Test void retryDeadLetteredReturnsNotFoundWhenNotFound() { UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); + when(conversionService.retryDeadLettered(jobId, "dummy-admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Dummy", "dummy") .exchange() .expectStatus().isNotFound(); } @@ -114,10 +130,11 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { @Test void retryDeadLetteredReturnsConflictWhenNotEligible() { UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); + when(conversionService.retryDeadLettered(jobId, "dummy-admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Dummy", "dummy") .exchange() .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer }