From d5c4e9b91fee9d3f59e18d109bb85ed5aa8583d9 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:54:12 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20Add=20authentication=20to=20Admin=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL 💡 Vulnerability: The AdminController endpoints (`/api/v1/admin/convert/jobs`, `/api/v1/admin/convert/jobs/{jobId}`, `/api/v1/admin/convert/jobs/{jobId}/retry`) completely lacked authentication and authorization checks. Anyone could read, delete, or retry jobs across tenants without any credentials. 🎯 Impact: Unauthenticated attackers could view sensitive metadata of all conversion jobs across all tenants, delete them causing denial of service, or arbitrarily retry dead-lettered jobs. 🔧 Fix: Injected the existing `TenantAccessService` into `AdminController`. Required a new `admin:operate` permission via `tenantAccessService.require(headers, TenantPermissions.ADMIN_OPERATE)` on all endpoints. ✅ Verification: Ran `mvn verify` ensuring all tests pass, including updated `AdminControllerTest` providing mock headers. --- .jules/sentinel.md | 5 ++++ plan.md | 12 ++++++++++ .../viewer/auth/TenantPermissions.java | 5 ++++ .../viewer/controller/AdminController.java | 23 +++++++++++++++---- .../controller/AdminControllerTest.java | 14 ++++++++++- 5 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 plan.md diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e795cb9dd..6e363572b 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. + +## 2024-10-13 - [Add Authentication to Admin Endpoint] +**Vulnerability:** Admin controller endpoints completely lacked authentication and authorization checks. +**Learning:** Found a critical vulnerability where Admin controller operations like get, retry, and delete jobs didn't verify the invoker identity via the existing `TenantAccessService`. +**Prevention:** Make sure `TenantAccessService` authorization checks (like `tenantAccessService.require(headers, ...)`) are consistently implemented for *all* newly created controllers and endpoints, especially those dealing with administrative actions. diff --git a/plan.md b/plan.md new file mode 100644 index 000000000..6511b56ce --- /dev/null +++ b/plan.md @@ -0,0 +1,12 @@ +1. **Explore `TenantPermissions.java` and `TenantAccessService.java`** + - The memory states: "Administrative endpoints must be secured using `TenantAccessService` and an explicit cross-tenant admin permission (e.g., `admin:operate`) passed via request headers to prevent unauthorized access." + - Currently, `AdminController.java` lacks any authentication or authorization checks. +2. **Update `TenantPermissions.java`** + - Add the `ADMIN_OPERATE` permission as `"admin:operate"`. +3. **Update `AdminController.java`** + - Inject `TenantAccessService` via constructor. + - For every endpoint, read the `@RequestHeader HttpHeaders headers` and check `tenantAccessService.require(headers, TenantPermissions.ADMIN_OPERATE)`. +4. **Update `AdminControllerTest.java`** + - Add the new headers to requests or mock `TenantAccessService`. +5. **Pre-commit checks** + - Ensure the required testing, format, lint, and verification processes are complete. diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index 4f9c6a685..5002bcd2f 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java @@ -55,6 +55,11 @@ public final class TenantPermissions { */ public static final String ANALYTICS_READ = "analytics:read"; + /** + * Permission required to perform administrative operations across tenants. + */ + public static final String ADMIN_OPERATE = "admin:operate"; + 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..a90d1d7af 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -11,9 +11,13 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantPermissions; import com.clearfolio.viewer.api.AdminJobListResponse; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; @@ -26,24 +30,31 @@ public class AdminController { private final DocumentConversionService conversionService; + private final TenantAccessService tenantAccessService; /** * Creates a controller for admin operations. * * @param conversionService conversion service + * @param tenantAccessService tenant access service */ - public AdminController(DocumentConversionService conversionService) { + public AdminController( + final DocumentConversionService conversionService, + final TenantAccessService tenantAccessService) { this.conversionService = conversionService; + this.tenantAccessService = tenantAccessService; } /** * 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); Iterable allJobs = conversionService.getAllJobs(); if (deadLettered == null) { @@ -62,11 +73,13 @@ 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 deleteJob(@PathVariable UUID jobId) { + public ResponseEntity deleteJob(@RequestHeader final HttpHeaders headers, @PathVariable final UUID jobId) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_OPERATE); conversionService.deleteJob(jobId); return ResponseEntity.noContent().build(); } @@ -74,11 +87,13 @@ public ResponseEntity deleteJob(@PathVariable UUID jobId) { /** * 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 retryDeadLettered(@PathVariable UUID jobId) { + public ResponseEntity 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"); diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index ad63a8015..3e83de2d8 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -7,6 +7,9 @@ import java.util.UUID; import org.junit.jupiter.api.BeforeEach; +import org.springframework.http.HttpHeaders; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantPermissions; import org.junit.jupiter.api.Test; import org.springframework.test.web.reactive.server.WebTestClient; @@ -17,13 +20,15 @@ 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(); @@ -37,6 +42,7 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() { webTestClient.get() .uri("/api/v1/admin/convert/jobs") + .header("Authorization", "Bearer token") .exchange() .expectStatus().isOk() .expectBody() @@ -55,6 +61,7 @@ void getAllJobsFiltersByDeadLetteredTrue() { webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") + .header("Authorization", "Bearer token") .exchange() .expectStatus().isOk() .expectBody() @@ -72,6 +79,7 @@ void getAllJobsFiltersByDeadLetteredFalse() { webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") + .header("Authorization", "Bearer token") .exchange() .expectStatus().isOk() .expectBody() @@ -85,6 +93,7 @@ void deleteJobReturnsNoContent() { webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) + .header("Authorization", "Bearer token") .exchange() .expectStatus().isNoContent(); } @@ -96,6 +105,7 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("Authorization", "Bearer token") .exchange() .expectStatus().isAccepted(); } @@ -107,6 +117,7 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("Authorization", "Bearer token") .exchange() .expectStatus().isNotFound(); } @@ -118,6 +129,7 @@ void retryDeadLetteredReturnsConflictWhenNotEligible() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("Authorization", "Bearer token") .exchange() .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer } From 657072910c89150d36d5e9969fa76868fb159e06 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:55:40 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20Add=20authentication=20to=20Admin=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL 💡 Vulnerability: The AdminController endpoints (`/api/v1/admin/convert/jobs`, `/api/v1/admin/convert/jobs/{jobId}`, `/api/v1/admin/convert/jobs/{jobId}/retry`) completely lacked authentication and authorization checks. Anyone could read, delete, or retry jobs across tenants without any credentials. 🎯 Impact: Unauthenticated attackers could view sensitive metadata of all conversion jobs across all tenants, delete them causing denial of service, or arbitrarily retry dead-lettered jobs. 🔧 Fix: Injected the existing `TenantAccessService` into `AdminController`. Required a new `admin:operate` permission via `tenantAccessService.require(headers, TenantPermissions.ADMIN_OPERATE)` on all endpoints. ✅ Verification: Ran `mvn verify` ensuring all tests pass, including updated `AdminControllerTest` providing mock headers. From a178d558b1d3c734b9ab60fc631a991d1a7371db Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:57:32 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20Add=20authentication=20to=20Admin=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL 💡 Vulnerability: The AdminController endpoints (`/api/v1/admin/convert/jobs`, `/api/v1/admin/convert/jobs/{jobId}`, `/api/v1/admin/convert/jobs/{jobId}/retry`) completely lacked authentication and authorization checks. Anyone could read, delete, or retry jobs across tenants without any credentials. 🎯 Impact: Unauthenticated attackers could view sensitive metadata of all conversion jobs across all tenants, delete them causing denial of service, or arbitrarily retry dead-lettered jobs. 🔧 Fix: Injected the existing `TenantAccessService` into `AdminController`. Required a new `admin:operate` permission via `tenantAccessService.require(headers, TenantPermissions.ADMIN_OPERATE)` on all endpoints. ✅ Verification: Ran `mvn verify` ensuring all tests pass, including updated `AdminControllerTest` providing mock headers. From 960f0c737d126446ebc978b2bb1de56712325dee Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:59:39 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20Add=20authentication=20to=20Admin=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL 💡 Vulnerability: The AdminController endpoints (`/api/v1/admin/convert/jobs`, `/api/v1/admin/convert/jobs/{jobId}`, `/api/v1/admin/convert/jobs/{jobId}/retry`) completely lacked authentication and authorization checks. Anyone could read, delete, or retry jobs across tenants without any credentials. 🎯 Impact: Unauthenticated attackers could view sensitive metadata of all conversion jobs across all tenants, delete them causing denial of service, or arbitrarily retry dead-lettered jobs. 🔧 Fix: Injected the existing `TenantAccessService` into `AdminController`. Required a new `admin:operate` permission via `tenantAccessService.require(headers, TenantPermissions.ADMIN_OPERATE)` on all endpoints. ✅ Verification: Ran `mvn verify` ensuring all tests pass, including updated `AdminControllerTest` providing mock headers.