Skip to content
Closed
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
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@
**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-02-21 - ๊ด€๋ฆฌ์ž ์—”๋“œํฌ์ธํŠธ ์ธ์ฆ ์šฐํšŒ (Admin Controller Authentication Bypass)
**Vulnerability:** `AdminController`์˜ ์—”๋“œํฌ์ธํŠธ(`/api/v1/admin/convert/jobs`, `/api/v1/admin/convert/jobs/{jobId}` ๋“ฑ)์— ์–ด๋– ํ•œ ์ธ์ฆ ๋ฐ ์ธ๊ฐ€ ๋กœ์ง๋„ ์ ์šฉ๋˜์–ด ์žˆ์ง€ ์•Š์•„, ๋ˆ„๊ตฌ๋‚˜ ๋ณ€ํ™˜ ์ž‘์—…์„ ์กฐํšŒ, ์‚ญ์ œ, ์žฌ์‹œ๋„ํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.
**Learning:** ์ปค์Šคํ…€ ๊ถŒํ•œ ๋ถ€์—ฌ ํ๋ฆ„(`TenantAccessService` ์ฃผ์ž… ๋“ฑ)์„ ์‚ฌ์šฉํ•˜๋Š” ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ๋Š” ๊ฐœ๋ฐœ์ž๊ฐ€ ๋ช…์‹œ์ ์œผ๋กœ ์„œ๋น„์Šค ํ˜ธ์ถœ์„ ๋ˆ„๋ฝํ•  ๊ฒฝ์šฐ ๊ธฐ๋ณธ์ ์œผ๋กœ ์—”๋“œํฌ์ธํŠธ๊ฐ€ '๋ชจ๋‘ ํ—ˆ์šฉ(open by default)' ์ƒํƒœ๊ฐ€ ๋˜๋Š” ์œ„ํ—˜์ด ์žˆ์Šต๋‹ˆ๋‹ค.
**Prevention:** 1) ์ƒˆ๋กœ์šด ๊ด€๋ฆฌ์ž ์ปจํŠธ๋กค๋Ÿฌ๋ฅผ ์ž‘์„ฑํ•  ๋•Œ๋Š” ๋ฐ˜๋“œ์‹œ `TenantAccessService`๋ฅผ ์ฃผ์ž…๋ฐ›์•„ ๊ฐ ์—”๋“œํฌ์ธํŠธ๋งˆ๋‹ค RBAC ๊ถŒํ•œ(`admin:read`, `admin:write`)์„ ๊ฐ•์ œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. 2) ์žฅ๊ธฐ์ ์œผ๋กœ๋Š” ์•„ํ‚คํ…์ฒ˜๊ฐ€ ํ—ˆ์šฉํ•œ๋‹ค๋ฉด ํ•„ํ„ฐ๋‚˜ ์• ๋…ธํ…Œ์ด์…˜ ๊ธฐ๋ฐ˜(์˜ˆ: `@PreAuthorize`)์˜ ์ค‘์•™์ง‘์ค‘์‹ ๋ณด์•ˆ ๋ชจ๋ธ๋กœ ์ „ํ™˜ํ•˜์—ฌ '๊ธฐ๋ณธ ๊ฑฐ๋ถ€(default-deny)' ์ •์ฑ…์„ ๊ตฌ์ถ•ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.
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 admin endpoints.
*/
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() {
}
}
63 changes: 50 additions & 13 deletions src/main/java/com/clearfolio/viewer/controller/AdminController.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@
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.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,26 +30,40 @@
@RestController
public class AdminController {

/** Document conversion service. */
private final DocumentConversionService conversionService;
/** Tenant access service. */
private final TenantAccessService tenantAccessService;

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

/**
* Retrieves all conversion jobs, optionally filtered by dead-letter status.
*
* @param headers request headers carrying 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) {
Iterable<ConversionJob> allJobs = conversionService.getAllJobs();
public AdminJobListResponse getAllJobs(
@RequestHeader final HttpHeaders headers,
@RequestParam(required = false) final Boolean deadLettered) {
TenantContext tenantContext = tenantAccessService.require(
headers, TenantPermissions.ADMIN_READ);

Iterable<ConversionJob> allJobs = conversionService.getAllJobs(
tenantContext);

if (deadLettered == null) {
return AdminJobListResponse.from(allJobs);
Expand All @@ -62,29 +81,47 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d
/**
* Deletes a conversion job.
*
* @param headers request headers carrying tenant claims
* @param jobId conversion job identifier
* @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(
@RequestHeader final HttpHeaders headers,
@PathVariable final UUID jobId) {
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 headers request headers carrying tenant claims
* @param jobId conversion job identifier
* @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(
@RequestHeader final HttpHeaders headers,
@PathVariable final UUID jobId) {
TenantContext tenantContext = tenantAccessService.require(
headers, TenantPermissions.ADMIN_WRITE);

RetryDeadLetterResult res = conversionService.retryDeadLettered(
jobId, "admin", tenantContext);
if (res == RetryDeadLetterResult.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");
if (res == RetryDeadLetterResult.NOT_ELIGIBLE) {
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 @@ -60,6 +60,32 @@ default UUID submit(MultipartFile file, PolicyOverrideRequest overrideRequest, T
*/
RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId);

/**
* Retries a dead-lettered conversion job owned by the supplied tenant.
*
* @param jobId conversion job identifier
* @param operatorId operator identifier that triggered the retry
* @param tenantContext tenant and subject claims for the retry request
* @return retry outcome, or {@link RetryDeadLetterResult#NOT_FOUND} when
* the job is missing or belongs to another tenant
*/
default RetryDeadLetterResult retryDeadLettered(
UUID jobId,
String operatorId,
TenantContext tenantContext) {
if (tenantContext == null) {
return RetryDeadLetterResult.NOT_FOUND;
}
Optional<ConversionJob> job = getJob(jobId);
if (job.isEmpty()) {
return RetryDeadLetterResult.NOT_FOUND;
}
if (!job.get().belongsToTenant(tenantContext.tenantId())) {
return RetryDeadLetterResult.NOT_FOUND;
}
return retryDeadLettered(jobId, operatorId);
}

/**
* Deletes a conversion job owned by the supplied tenant context.
*
Expand Down Expand Up @@ -95,4 +121,23 @@ default boolean deleteJob(UUID jobId, TenantContext tenantContext) {
* @return an iterable of all conversion jobs
*/
Iterable<ConversionJob> getAllJobs();

/**
* Returns only conversion jobs owned by the supplied tenant.
*
* @param tenantContext tenant and subject claims for the list request
* @return an iterable containing only jobs owned by that tenant
*/
default Iterable<ConversionJob> getAllJobs(TenantContext tenantContext) {
if (tenantContext == null) {
return java.util.List.of();
}
java.util.List<ConversionJob> ownedJobs = new java.util.ArrayList<>();
for (ConversionJob job : getAllJobs()) {
if (job.belongsToTenant(tenantContext.tenantId())) {
ownedJobs.add(job);
}
}
return ownedJobs;
}
}
Loading
Loading