Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4de2d01
🛡️ Sentinel: [CRITICAL] Fix authorization bypass in admin API
seonghobae Sep 12, 2026
69902f0
🛡️ Sentinel: [CRITICAL] Fix authorization bypass in admin API
seonghobae Sep 12, 2026
2e3c54e
chore: remove one-shot source patch artifact
seonghobae Sep 12, 2026
7ffe9c0
chore: remove source replacement scratch file
seonghobae Sep 12, 2026
1e8c18e
chore: remove source search scratch file
seonghobae Sep 12, 2026
1133ce6
test(admin): enforce tenant isolation on privileged routes
seonghobae Sep 12, 2026
95df70e
fix(admin): preserve tenant boundary on privileged routes
seonghobae Sep 12, 2026
0ee5e83
test(admin): model foreign-tenant rejection in access service
seonghobae Sep 12, 2026
6f37e74
docs(security): record admin tenant-isolation boundary
seonghobae Sep 12, 2026
daa87ca
test(admin): cover retry disappearance race
seonghobae Sep 12, 2026
fbb00e4
🛡️ Sentinel: [CRITICAL] Enforce tenant isolation in admin API
seonghobae Sep 12, 2026
9b17b60
chore: remove reintroduced source patch artifact
seonghobae Sep 12, 2026
f7ae2be
chore: remove reintroduced replacement scratch file
seonghobae Sep 12, 2026
6875c64
chore: remove reintroduced search scratch file
seonghobae Sep 12, 2026
315bd07
fix(admin): preserve retry not-found race contract
seonghobae Sep 12, 2026
ea7425f
test(admin): cover tenant and retry boundary races
seonghobae Sep 12, 2026
8ebdbfd
🛡️ Sentinel: [CRITICAL] Enforce tenant isolation in admin API
seonghobae Sep 12, 2026
b6d1b93
🛡️ Sentinel: [CRITICAL] Enforce tenant isolation in admin API
seonghobae Sep 12, 2026
c54e104
test(auth): preserve 404 when retry target disappears
seonghobae Sep 12, 2026
16fb346
fix(auth): preserve not-found retry race semantics
seonghobae Sep 12, 2026
a66c8c1
🛡️ Sentinel: [CRITICAL] Enforce tenant isolation in admin API
seonghobae Sep 12, 2026
36abf15
Fix missing authorization and tenant isolation in AdminController
seonghobae Sep 13, 2026
78b5efa
Fix missing authorization and tenant isolation in AdminController
seonghobae Sep 13, 2026
067d764
Fix missing authorization and tenant isolation in AdminController
seonghobae Sep 13, 2026
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
10 changes: 10 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 10 additions & 0 deletions src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}
}
71 changes: 53 additions & 18 deletions src/main/java/com/clearfolio/viewer/controller/AdminController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ConversionJob> allJobs = conversionService.getAllJobs();

if (deadLettered == null) {
return AdminJobListResponse.from(allJobs);
}

List<ConversionJob> 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);
}
Expand All @@ -63,28 +80,46 @@ 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) {
conversionService.deleteJob(jobId);
public ResponseEntity<Void> 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();
}

/**
* 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");
if (result == RetryDeadLetterResult.NOT_FOUND) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found");
}
public ResponseEntity<Void> 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();
}
Expand Down
Loading
Loading