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
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-28 - Add authentication to admin endpoint
**Vulnerability:** Admin endpoints in `AdminController.java` were missing authentication and authorization checks, allowing unauthenticated access to sensitive operations like viewing all jobs and deleting them.
**Learning:** All backend API endpoints, especially administrative ones, must enforce authentication and authorization to secure sensitive operations and prevent unauthorized access.
**Prevention:** Always inject `TenantAccessService` and execute `tenantAccessService.require(headers, TenantPermissions.[SPECIFIC_PERMISSION])` in all endpoints.
5 changes: 2 additions & 3 deletions commit_message.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
๐Ÿ›ก๏ธ Sentinel: [CRITICAL] ํŒŒ์ผ ์—…๋กœ๋“œ ๊ฒฝ๋กœ ์กฐ์ž‘(Path Traversal) ์ทจ์•ฝ์  ์ˆ˜์ •
feat: admin ์—”๋“œํฌ์ธํŠธ์— ์ธ์ฆ ๋ฐ ์ธ๊ฐ€ ๊ฒ€์ฆ ์ถ”๊ฐ€

MultipartFile.getOriginalFilename()์„ ์‹ ๋ขฐํ•˜์—ฌ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒฝ๋กœ ์กฐ์ž‘ ์ทจ์•ฝ์ ์„ ์ˆ˜์ •ํ–ˆ์Šต๋‹ˆ๋‹ค.
StringUtils.cleanPath()๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ฒฝ๋กœ๋ฅผ ์ •๊ทœํ™”ํ•˜๊ณ  ์ˆœ์ˆ˜ํ•œ ํŒŒ์ผ๋ช…๋งŒ ์ถ”์ถœํ•˜์—ฌ ์•…์˜์ ์ธ ํŽ˜์ด๋กœ๋“œ(์˜ˆ: ../../../etc/passwd.hwp)๋กœ๋ถ€ํ„ฐ ์‹œ์Šคํ…œ์„ ๋ณดํ˜ธํ•ฉ๋‹ˆ๋‹ค.
`AdminController`์˜ `getAllJobs`, `deleteJob`, `retryDeadLettered` ์—”๋“œํฌ์ธํŠธ์— ์ธ์ฆ/์ธ๊ฐ€๊ฐ€ ๋ˆ„๋ฝ๋˜์–ด ๋น„์ธ๊ฐ€ ์ ‘๊ทผ ๋ฐ ์ •๋ณด ๋…ธ์ถœ ์ทจ์•ฝ์ ์ด ์กด์žฌํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด `TenantAccessService`๋ฅผ ์ฃผ์ž…ํ•˜๊ณ , `TenantPermissions.ADMIN_READ` ๋ฐ `TenantPermissions.ADMIN_WRITE` ๊ฒ€์ฆ์„ ์ถ”๊ฐ€ํ•˜์—ฌ ๋ณด์•ˆ์„ ๊ฐ•ํ™”ํ–ˆ์Šต๋‹ˆ๋‹ค.
10 changes: 5 additions & 5 deletions description.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
๐Ÿšจ Severity: CRITICAL
๐Ÿ’ก Vulnerability: ํŒŒ์ผ ์—…๋กœ๋“œ ์‹œ `MultipartFile.getOriginalFilename()` ๊ฐ’์„ ๊ฒ€์ฆ ์—†์ด ์‚ฌ์šฉํ•˜์—ฌ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒฝ๋กœ ์กฐ์ž‘(Path Traversal) ์ทจ์•ฝ์  ๋ฐœ๊ฒฌ.
๐ŸŽฏ Impact: ๊ณต๊ฒฉ์ž๊ฐ€ ๋””๋ ‰ํ† ๋ฆฌ ํƒ์ƒ‰ ๋ฌธ์ž์—ด(`../`)์„ ํฌํ•จํ•œ ํŒŒ์ผ๋ช…์„ ์ „์†กํ•˜์—ฌ ์˜๋„ํ•˜์ง€ ์•Š์€ ๊ฒฝ๋กœ์— ํŒŒ์ผ์„ ์ €์žฅํ•˜๊ฑฐ๋‚˜ ์‹œ์Šคํ…œ ํŒŒ์ผ(์˜ˆ: `/etc/passwd`)์— ์ ‘๊ทผ/์กฐ์ž‘ํ•  ์œ„ํ—˜์ด ์žˆ์Œ.
๐Ÿ”ง Fix: `DefaultDocumentConversionService` ๋ฐ `DefaultDocumentValidationService`์—์„œ ํŒŒ์ผ๋ช…์„ ์‚ฌ์šฉํ•˜๊ธฐ ์ „ `StringUtils.cleanPath()`๋ฅผ ํ†ตํ•ด ๊ฒฝ๋กœ๋ฅผ ์ •๊ทœํ™”ํ•˜๊ณ , ๋งˆ์ง€๋ง‰ `/` ์ดํ›„์˜ ์ˆœ์ˆ˜ํ•œ ํŒŒ์ผ๋ช…๋งŒ ์ถ”์ถœํ•˜๋„๋ก `sanitizeFilename` ๋ฉ”์†Œ๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ์•ˆ์ „ํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ•จ.
โœ… Verification: ๋‹จ์œ„ ํ…Œ์ŠคํŠธ(`submitStripsDirectoryTraversalFromOriginalFilename` ๋ฐ `stripsDirectoryTraversalFromFilename` ๋“ฑ)๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ์ทจ์•ฝ์  ๋ฌธ์ž์—ด์ด ์ •์ƒ์ ์œผ๋กœ ์ œ๊ฑฐ๋˜๋ฉฐ 100% ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€๋ฅผ ๋ณด์žฅํ•จ.
๐Ÿšจ Severity: HIGH
๐Ÿ’ก Vulnerability: AdminController์˜ ๊ด€๋ฆฌ์ž API ์—”๋“œํฌ์ธํŠธ์— ์ธ์ฆ ๋ฐ ๊ถŒํ•œ ๊ฒ€์ฆ ๋กœ์ง์ด ๋ˆ„๋ฝ๋˜์–ด ์ธ๊ฐ€๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋ชจ๋“  ๋ณ€ํ™˜ ์ž‘์—…์„ ์กฐํšŒํ•˜๊ฑฐ๋‚˜ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋Š” ์ทจ์•ฝ์ ์ด ์กด์žฌํ–ˆ์Šต๋‹ˆ๋‹ค.
๐ŸŽฏ Impact: ๊ณต๊ฒฉ์ž๊ฐ€ ๊ถŒํ•œ ๊ฒ€์ฆ ์—†์ด ๊ด€๋ฆฌ์ž API์— ์ ‘๊ทผํ•˜์—ฌ ์‹œ์Šคํ…œ์˜ ๋ฏผ๊ฐํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ์—ด๋žŒํ•˜๊ฑฐ๋‚˜, ์ง„ํ–‰ ์ค‘์ด๊ฑฐ๋‚˜ ์„ฑ๊ณตํ•œ ๋ณ€ํ™˜ ์ž‘์—…์„ ๋ฌด๋‹จ์œผ๋กœ ์‚ญ์ œ ๋ฐ ์žฌ์‹œ๋„ํ•  ์ˆ˜ ์žˆ์–ด ์‹ฌ๊ฐํ•œ ๋ฐ์ดํ„ฐ ์†์‹ค ๋ฐ ๊ถŒํ•œ ์šฐํšŒ๊ฐ€ ๊ฐ€๋Šฅํ–ˆ์Šต๋‹ˆ๋‹ค.
๐Ÿ”ง Fix: `TenantPermissions`์— `ADMIN_READ`์™€ `ADMIN_WRITE` ๊ถŒํ•œ ์ƒ์ˆ˜๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , `AdminController`์˜ ๊ฐ ์—”๋“œํฌ์ธํŠธ์— `TenantAccessService`๋ฅผ ์ฃผ์ž…ํ•˜์—ฌ ์ธ์ฆ ๋ฐ ๊ถŒํ•œ ๊ฒ€์ฆ(`tenantAccessService.require(...)`)์„ ๊ฐ•์ œํ•˜๋„๋ก ํŒจ์น˜ํ–ˆ์Šต๋‹ˆ๋‹ค.
โœ… Verification: `mvn test`๋ฅผ ํ†ตํ•ด ๋ณด์•ˆ ๋กœ์ง ์ถ”๊ฐ€ ํ›„ ๊ธฐ์กด ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๊ฐ€ ์ ์ ˆํ•œ ๋ชจ์˜(Mocking)๋ฅผ ๊ฑฐ์ณ 100% ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€๋ฅผ ๋งŒ์กฑํ•˜๊ณ  ์„ฑ๊ณต์ ์œผ๋กœ ๋™์ž‘ํ•จ์„ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค.
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 @@ -50,6 +50,16 @@ public final class TenantPermissions {
*/
public static final String ANALYTICS_READ = "analytics:read";

/**
* Permission required to read admin data.
*/
public static final String ADMIN_READ = "admin:read";

/**
* Permission required to write admin data.
*/
public static final String ADMIN_WRITE = "admin:write";

private TenantPermissions() {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@
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 org.springframework.http.HttpHeaders;

import com.clearfolio.viewer.api.AdminJobListResponse;
import com.clearfolio.viewer.auth.TenantAccessService;
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 @@ -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(DocumentConversionService conversionService, TenantAccessService tenantAccessService) {
this.conversionService = conversionService;
this.tenantAccessService = tenantAccessService;
}

/**
* 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) Boolean deadLettered,
@RequestHeader HttpHeaders headers) {
tenantAccessService.require(headers, TenantPermissions.ADMIN_READ);
Iterable<ConversionJob> allJobs = conversionService.getAllJobs();

if (deadLettered == null) {
Expand All @@ -63,10 +74,14 @@ 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) {
public ResponseEntity<Void> deleteJob(
@PathVariable UUID jobId,
@RequestHeader HttpHeaders headers) {
tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE);
conversionService.deleteJob(jobId);
return ResponseEntity.noContent().build();
}
Expand All @@ -75,10 +90,14 @@ public ResponseEntity<Void> 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<Void> retryDeadLettered(@PathVariable UUID jobId) {
public ResponseEntity<Void> retryDeadLettered(
@PathVariable UUID jobId,
@RequestHeader HttpHeaders headers) {
tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE);
RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin");
Comment on lines +100 to 101
if (result == RetryDeadLetterResult.NOT_FOUND) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.clearfolio.viewer.controller;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

Expand All @@ -9,21 +11,27 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.http.HttpHeaders;

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;

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();
Expand All @@ -34,9 +42,12 @@ 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(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));
when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ)))
.thenReturn(new TenantContext("tenant", "subject", java.util.Set.of()));

webTestClient.get()
.uri("/api/v1/admin/convert/jobs")
.header("Authorization", "Bearer test")
.exchange()
Comment on lines 49 to 51
.expectStatus().isOk()
.expectBody()
Expand All @@ -52,9 +63,12 @@ void getAllJobsFiltersByDeadLetteredTrue() {
ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L);

when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));
when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ)))
.thenReturn(new TenantContext("tenant", "subject", java.util.Set.of()));

webTestClient.get()
.uri("/api/v1/admin/convert/jobs?deadLettered=true")
.header("Authorization", "Bearer test")
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -69,9 +83,12 @@ void getAllJobsFiltersByDeadLetteredFalse() {
ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L);

when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));
when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ)))
.thenReturn(new TenantContext("tenant", "subject", java.util.Set.of()));

webTestClient.get()
.uri("/api/v1/admin/convert/jobs?deadLettered=false")
.header("Authorization", "Bearer test")
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -82,9 +99,12 @@ void getAllJobsFiltersByDeadLetteredFalse() {
@Test
void deleteJobReturnsNoContent() {
UUID jobId = UUID.randomUUID();
when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE)))
.thenReturn(new TenantContext("tenant", "subject", java.util.Set.of()));

webTestClient.delete()
.uri("/api/v1/admin/convert/jobs/" + jobId)
.header("Authorization", "Bearer test")
.exchange()
.expectStatus().isNoContent();
}
Expand All @@ -93,9 +113,12 @@ void deleteJobReturnsNoContent() {
void retryDeadLetteredReturnsAcceptedWhenAccepted() {
UUID jobId = UUID.randomUUID();
when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED);
when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE)))
.thenReturn(new TenantContext("tenant", "subject", java.util.Set.of()));

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.header("Authorization", "Bearer test")
.exchange()
.expectStatus().isAccepted();
}
Expand All @@ -104,9 +127,12 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() {
void retryDeadLetteredReturnsNotFoundWhenNotFound() {
UUID jobId = UUID.randomUUID();
when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND);
when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE)))
.thenReturn(new TenantContext("tenant", "subject", java.util.Set.of()));

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.header("Authorization", "Bearer test")
.exchange()
.expectStatus().isNotFound();
}
Expand All @@ -115,9 +141,12 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() {
void retryDeadLetteredReturnsConflictWhenNotEligible() {
UUID jobId = UUID.randomUUID();
when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE);
when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE)))
.thenReturn(new TenantContext("tenant", "subject", java.util.Set.of()));

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.header("Authorization", "Bearer test")
.exchange()
.expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer
}
Expand Down
Loading