diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e795cb9dd..7e3e14a3b 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -32,3 +32,13 @@ **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-13 - Missing Authentication on Admin Endpoints +**Vulnerability:** Admin API endpoints in `AdminController` did not require any tenant claims or authorization checks, allowing unauthenticated or unauthorized users to perform privileged actions like fetching all jobs or deleting jobs. +**Learning:** All administrative endpoints must explicitly enforce authorization regardless of network boundaries, as bypassing these controls can lead to complete data exposure or loss. +**Prevention:** Implement rigorous permission checks (e.g., using `tenantAccessService.require(headers, TenantPermissions.ADMIN_READ)`) on all admin endpoints. + +## 2026-07-13 - Missing Tenant Isolation in Admin Endpoints +**Vulnerability:** Even if the Admin API enforces a permission check, the current implementation retrieved and returned jobs from *all* tenants, bypassing data isolation. Moreover, deletion and retries could manipulate objects belonging to other tenants. +**Learning:** Enforcing endpoint permissions alone does not enforce multi-tenant isolation boundaries. Business operations within privileged endpoints must still explicitly restrict visibility and state mutations to resources belonging to the authenticated tenant. +**Prevention:** Always verify tenant ownership within privileged APIs (e.g., using `belongsToTenant()` during list operations or `requireSameTenant` prior to updates) to maintain strict multitenancy constraints. diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index 4f9c6a685..819edaf8c 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java @@ -55,6 +55,16 @@ public final class TenantPermissions { */ public static final String ANALYTICS_READ = "analytics:read"; + /** + * Permission required to read administrative resources. + */ + public static final String ADMIN_READ = "admin:read"; + + /** + * Permission required to execute administrative actions. + */ + 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 412d4eb86..454073314 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -10,11 +10,16 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.http.HttpHeaders; +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; @@ -25,36 +30,48 @@ @RestController public class AdminController { + /** Conversion service. */ private final DocumentConversionService conversionService; + /** Tenant access service. */ + private final TenantAccessService tenantAccessService; /** * Creates a controller for admin operations. * - * @param conversionService conversion service + * @param service conversion service + * @param accessService tenant access service */ - public AdminController(DocumentConversionService conversionService) { - this.conversionService = conversionService; + public AdminController( + final DocumentConversionService service, + final TenantAccessService accessService) { + this.conversionService = service; + this.tenantAccessService = accessService; } /** * 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 tenantContext = tenantAccessService.require( + headers, TenantPermissions.ADMIN_READ); Iterable allJobs = conversionService.getAllJobs(); - if (deadLettered == null) { - return AdminJobListResponse.from(allJobs); - } - List filtered = new ArrayList<>(); for (ConversionJob job : allJobs) { - if (job.isDeadLettered() == deadLettered) { - filtered.add(job); + if (!job.belongsToTenant(tenantContext.tenantId())) { + continue; } + if (deadLettered != null && job.isDeadLettered() != deadLettered) { + continue; + } + filtered.add(job); } return AdminJobListResponse.from(filtered); } @@ -63,11 +80,19 @@ 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 deleteJob(@PathVariable UUID jobId) { - conversionService.deleteJob(jobId); + public ResponseEntity deleteJob( + @PathVariable final UUID jobId, + @RequestHeader final HttpHeaders headers) { + TenantContext tenantContext = tenantAccessService.require( + headers, TenantPermissions.ADMIN_WRITE); + if (!conversionService.deleteJob(jobId, tenantContext)) { + throw new ResponseStatusException( + HttpStatus.NOT_FOUND, "job not found"); + } return ResponseEntity.noContent().build(); } @@ -75,16 +100,26 @@ public ResponseEntity 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 retryDeadLettered(@PathVariable UUID jobId) { - RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin"); - if (result == RetryDeadLetterResult.NOT_FOUND) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); - } + public ResponseEntity retryDeadLettered( + @PathVariable final UUID jobId, + @RequestHeader final HttpHeaders headers) { + TenantContext tenantContext = tenantAccessService.require( + headers, TenantPermissions.ADMIN_WRITE); + + ConversionJob job = conversionService.getJob(jobId) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, "job not found")); + tenantAccessService.requireSameTenant(tenantContext, job); + + RetryDeadLetterResult result = conversionService + .retryDeadLettered(jobId, "admin"); 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(); } diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index ad63a8015..2ffca68a2 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -4,35 +4,64 @@ import static org.mockito.Mockito.when; import java.util.Arrays; +import java.util.Optional; +import java.util.Set; import java.util.UUID; +import org.springframework.http.HttpHeaders; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import org.mockito.ArgumentMatchers; + 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; +import static org.mockito.Mockito.doThrow; + class AdminControllerTest { private DocumentConversionService conversionService; + private TenantAccessService tenantAccessService; private WebTestClient webTestClient; private AdminController controller; + private TenantContext tenantContext; @BeforeEach void setUp() { conversionService = mock(DocumentConversionService.class); - controller = new AdminController(conversionService); + tenantAccessService = mock(TenantAccessService.class); + tenantContext = new TenantContext("tenant", "subject", Set.of(TenantPermissions.ADMIN_READ, TenantPermissions.ADMIN_WRITE)); + controller = new AdminController(conversionService, tenantAccessService); webTestClient = WebTestClient.bindToController(controller) .controllerAdvice(new ApiExceptionHandler()) .build(); } + @Test + void getAllJobsReturnsForbiddenWhenNoPermission() { + when(tenantAccessService.require(ArgumentMatchers.any(HttpHeaders.class), ArgumentMatchers.eq(TenantPermissions.ADMIN_READ))) + .thenThrow(new ResponseStatusException(HttpStatus.FORBIDDEN)); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs") + .exchange() + .expectStatus().isForbidden(); + } + @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); + when(tenantAccessService.require(ArgumentMatchers.any(HttpHeaders.class), ArgumentMatchers.eq(TenantPermissions.ADMIN_READ))) + .thenReturn(tenantContext); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), tenantContext.tenantId(), tenantContext.subjectId(), "a.pdf", "application/pdf", "hash-a", 100L, 3); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), tenantContext.tenantId(), tenantContext.subjectId(), "b.pdf", "application/pdf", "hash-b", 100L, 3); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); webTestClient.get() @@ -47,9 +76,11 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() { @Test void getAllJobsFiltersByDeadLetteredTrue() { - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); + when(tenantAccessService.require(ArgumentMatchers.any(HttpHeaders.class), ArgumentMatchers.eq(TenantPermissions.ADMIN_READ))) + .thenReturn(tenantContext); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), tenantContext.tenantId(), tenantContext.subjectId(), "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(), tenantContext.tenantId(), tenantContext.subjectId(), "b.pdf", "application/pdf", "hash-b", 100L, 3); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); @@ -64,9 +95,11 @@ void getAllJobsFiltersByDeadLetteredTrue() { @Test void getAllJobsFiltersByDeadLetteredFalse() { - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); + when(tenantAccessService.require(ArgumentMatchers.any(HttpHeaders.class), ArgumentMatchers.eq(TenantPermissions.ADMIN_READ))) + .thenReturn(tenantContext); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), tenantContext.tenantId(), tenantContext.subjectId(), "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(), tenantContext.tenantId(), tenantContext.subjectId(), "b.pdf", "application/pdf", "hash-b", 100L, 3); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); @@ -79,9 +112,123 @@ void getAllJobsFiltersByDeadLetteredFalse() { .jsonPath("$.jobs[0].fileName").isEqualTo("b.pdf"); } + @Test + void getAllJobsHidesForeignTenantJobAndFiltersDeadLettered() { + when(tenantAccessService.require(ArgumentMatchers.any(HttpHeaders.class), ArgumentMatchers.eq(TenantPermissions.ADMIN_READ))) + .thenReturn(tenantContext); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), tenantContext.tenantId(), tenantContext.subjectId(), "a.pdf", "application/pdf", "hash-a", 100L, 3); + job1.markDeadLettered("failed"); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), tenantContext.tenantId(), tenantContext.subjectId(), "b.pdf", "application/pdf", "hash-b", 100L, 3); + ConversionJob job3 = new ConversionJob(UUID.randomUUID(), "other", "other", "c.pdf", "application/pdf", "hash-c", 100L, 3); + job3.markDeadLettered("failed"); + ConversionJob job4 = new ConversionJob(UUID.randomUUID(), tenantContext.tenantId(), tenantContext.subjectId(), "d.pdf", "application/pdf", "hash-d", 100L, 3); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2, job3, job4)); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs?deadLettered=false") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.jobs.length()").isEqualTo(2) + .jsonPath("$.jobs[0].fileName").isEqualTo("b.pdf") + .jsonPath("$.jobs[1].fileName").isEqualTo("d.pdf"); + } + + @Test + void getAllJobsHidesForeignTenantJobAndFiltersDeadLetteredNull() { + when(tenantAccessService.require(ArgumentMatchers.any(HttpHeaders.class), ArgumentMatchers.eq(TenantPermissions.ADMIN_READ))) + .thenReturn(tenantContext); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), tenantContext.tenantId(), tenantContext.subjectId(), "a.pdf", "application/pdf", "hash-a", 100L, 3); + job1.markDeadLettered("failed"); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), tenantContext.tenantId(), tenantContext.subjectId(), "b.pdf", "application/pdf", "hash-b", 100L, 3); + ConversionJob job3 = new ConversionJob(UUID.randomUUID(), "other", "other", "c.pdf", "application/pdf", "hash-c", 100L, 3); + job3.markDeadLettered("failed"); + ConversionJob job4 = new ConversionJob(UUID.randomUUID(), tenantContext.tenantId(), tenantContext.subjectId(), "d.pdf", "application/pdf", "hash-d", 100L, 3); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2, job3, job4)); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.jobs.length()").isEqualTo(3) + .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf") + .jsonPath("$.jobs[1].fileName").isEqualTo("b.pdf") + .jsonPath("$.jobs[2].fileName").isEqualTo("d.pdf"); + } + + @Test + void getAllJobsHidesForeignTenantJobAndFiltersDeadLettered2() { + when(tenantAccessService.require(ArgumentMatchers.any(HttpHeaders.class), ArgumentMatchers.eq(TenantPermissions.ADMIN_READ))) + .thenReturn(tenantContext); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), tenantContext.tenantId(), tenantContext.subjectId(), "a.pdf", "application/pdf", "hash-a", 100L, 3); + job1.markDeadLettered("failed"); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), tenantContext.tenantId(), tenantContext.subjectId(), "b.pdf", "application/pdf", "hash-b", 100L, 3); + ConversionJob job3 = new ConversionJob(UUID.randomUUID(), "other", "other", "c.pdf", "application/pdf", "hash-c", 100L, 3); + job3.markDeadLettered("failed"); + ConversionJob job4 = new ConversionJob(UUID.randomUUID(), tenantContext.tenantId(), tenantContext.subjectId(), "d.pdf", "application/pdf", "hash-d", 100L, 3); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2, job3, job4)); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs?deadLettered=true") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.jobs.length()").isEqualTo(1) + .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf"); + } + + @Test + void getAllJobsHidesForeignTenantJob() { + when(tenantAccessService.require(ArgumentMatchers.any(HttpHeaders.class), ArgumentMatchers.eq(TenantPermissions.ADMIN_READ))) + .thenReturn(tenantContext); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), tenantContext.tenantId(), tenantContext.subjectId(), "a.pdf", "application/pdf", "hash-a", 100L, 3); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "other-tenant", "other-subject", "b.pdf", "application/pdf", "hash-b", 100L, 3); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.jobs.length()").isEqualTo(1) + .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf"); + } + + @Test + void deleteJobHidesForeignTenantJob() { + when(tenantAccessService.require(ArgumentMatchers.any(HttpHeaders.class), ArgumentMatchers.eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(tenantContext); + UUID jobId = UUID.randomUUID(); + when(conversionService.deleteJob(jobId, tenantContext)).thenReturn(false); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .exchange() + .expectStatus().isNotFound(); + } + + @Test + void retryDeadLetteredHidesForeignTenantJob() { + when(tenantAccessService.require(ArgumentMatchers.any(HttpHeaders.class), ArgumentMatchers.eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(tenantContext); + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, tenantContext.tenantId(), tenantContext.subjectId(), "a.pdf", "application/pdf", "hash", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + doThrow(new ResponseStatusException(HttpStatus.NOT_FOUND)).when(tenantAccessService).requireSameTenant(tenantContext, job); + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .exchange() + .expectStatus().isNotFound(); + } + @Test void deleteJobReturnsNoContent() { + when(tenantAccessService.require(ArgumentMatchers.any(HttpHeaders.class), ArgumentMatchers.eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(tenantContext); UUID jobId = UUID.randomUUID(); + when(conversionService.deleteJob(jobId, tenantContext)).thenReturn(true); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) @@ -91,7 +238,11 @@ void deleteJobReturnsNoContent() { @Test void retryDeadLetteredReturnsAcceptedWhenAccepted() { + when(tenantAccessService.require(ArgumentMatchers.any(HttpHeaders.class), ArgumentMatchers.eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(tenantContext); UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, tenantContext.tenantId(), tenantContext.subjectId(), "a.pdf", "application/pdf", "hash", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); webTestClient.post() @@ -102,8 +253,10 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { @Test void retryDeadLetteredReturnsNotFoundWhenNotFound() { + when(tenantAccessService.require(ArgumentMatchers.any(HttpHeaders.class), ArgumentMatchers.eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(tenantContext); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); + when(conversionService.getJob(jobId)).thenReturn(Optional.empty()); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") @@ -113,7 +266,11 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { @Test void retryDeadLetteredReturnsConflictWhenNotEligible() { + when(tenantAccessService.require(ArgumentMatchers.any(HttpHeaders.class), ArgumentMatchers.eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(tenantContext); UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, tenantContext.tenantId(), tenantContext.subjectId(), "a.pdf", "application/pdf", "hash", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); webTestClient.post()