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
42 changes: 8 additions & 34 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -1,34 +1,8 @@
## 2026-06-30 - Prevent DOM-based XSS in Viewer JS
**Vulnerability:** Untrusted paths from API responses were directly assigned to `a.href` and used in `iframe` generation, which allows execution of malicious URIs like `javascript:` or `data:`.
**Learning:** Even when avoiding `innerHTML`, directly setting URL-like strings to DOM attributes without protocol validation introduces XSS vectors. The payload can be executed when the link is clicked or the iframe is loaded.
**Prevention:** Implement an `isSafeUrl` verification function to ensure the protocol is strictly `http:` or `https:` (using `new URL()`) before assigning untrusted inputs to DOM attributes like `href` or `src`.

## 2026-07-08 - ํŒŒ์ผ ์—…๋กœ๋“œ ์‹œ ๊ฒฝ๋กœ ์กฐ์ž‘(Path Traversal) ์ทจ์•ฝ์  ๋ฐฉ์ง€
**Vulnerability:** ํด๋ผ์ด์–ธํŠธ์—์„œ ์ „์†ก๋œ `MultipartFile.getOriginalFilename()`์„ ๊ฒ€์ฆ ์—†์ด ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์–ด ๊ณต๊ฒฉ์ž๊ฐ€ `../../../etc/passwd.hwp` ๊ฐ™์€ ํŒŒ์ผ๋ช…์œผ๋กœ ๊ฒฝ๋กœ๋ฅผ ์กฐ์ž‘ํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.
**Learning:** ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ „์†กํ•œ ํŒŒ์ผ๋ช…์€ ์‹ ๋ขฐํ•  ์ˆ˜ ์—†๋Š” ์ž…๋ ฅ๊ฐ’์ž…๋‹ˆ๋‹ค. ๊ฒฝ๋กœ ํƒ์ƒ‰ ๋ฌธ์ž์—ด์ด ํฌํ•จ๋  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ด๋ฅผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•  ๊ฒฝ์šฐ ์˜๋„์น˜ ์•Š์€ ๋””๋ ‰ํ† ๋ฆฌ์— ํŒŒ์ผ์ด ์ €์žฅ๋˜๊ฑฐ๋‚˜ ์‹œ์Šคํ…œ ํŒŒ์ผ์ด ์กฐ์ž‘๋˜๋Š” ๋“ฑ์˜ ์‹ฌ๊ฐํ•œ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
**Prevention:** ์‚ฌ์šฉ์ž๋กœ๋ถ€ํ„ฐ ์ž…๋ ฅ๋ฐ›์€ ํŒŒ์ผ๋ช…์€ ํ•ญ์ƒ ๋ช…์‹œ์ ์œผ๋กœ ์‚ด๊ท (sanitize)ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. `org.springframework.util.StringUtils.cleanPath()`๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ฒฝ๋กœ๋ฅผ ์ •๊ทœํ™”ํ•˜๊ณ , ๋งˆ์ง€๋ง‰ `/` ์ดํ›„์˜ ์ˆœ์ˆ˜ํ•œ ํŒŒ์ผ๋ช…๋งŒ ์ถ”์ถœํ•˜์—ฌ ์‚ฌ์šฉํ•˜๋Š” ๋ฐฉ์‹์„ ์ ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

## 2026-07-02 - Cryptographic Signature Verification Bypass in Policy Override
**Vulnerability:** The document validation service logged the presence of policy override parameters (approverId, approvalToken) but failed to actually verify the cryptographic signature of the token against a shared secret. This allowed an attacker to bypass file extension restrictions (e.g., uploading blocked `.hwp` files) by sending any arbitrary token.
**Learning:** Checking for the presence of security tokens is insufficient if the token payload and signature are not cryptographically validated against a trusted secret. The absence of this check created a critical authorization bypass.
**Prevention:** Always verify cryptographic signatures (using constant-time comparison like `MessageDigest.isEqual`) for any policy override or authorization token before granting the elevated privilege or bypassing a security control.

## 2026-07-08 - Length Extension and Canonicalization Vulnerability in Hash Payloads
**Vulnerability:** The HMAC-SHA256 signature payload for policy overrides was constructed by simply concatenating strings: `approverId + ":" + extension`. This allowed attackers to craft ambiguous inputs if they embedded the delimiter `:` inside their payload, potentially bypassing validation via canonicalization or length extension attacks.
**Learning:** Simple string concatenation is insecure when generating cryptographic hashes or signatures for multiple inputs. Attackers can shift delimiters to produce identical payloads for entirely different logical inputs.
**Prevention:** Always use length-prefixing or unambiguous delimiters (such as JSON structure or specific serialization formats) when combining multiple inputs for cryptographic hashing. For example, use `approverId.length() + ":" + approverId + extension` to strictly define the boundaries of each field.

## 2026-07-11 - XSS ์ทจ์•ฝ์  ์ œ๊ฑฐ (`innerHTML` ์‚ฌ์šฉ ๊ต์ฒด)
**Vulnerability:** `innerHTML`์„ ํ†ตํ•œ ๋™์  DOM ์กฐ์ž‘์œผ๋กœ ์ธํ•ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” DOM ๊ธฐ๋ฐ˜ XSS(Cross-Site Scripting) ์ทจ์•ฝ์ ์ด ๋ฐœ๊ฒฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.
**Learning:** ๋กœ๋”ฉ ์ƒํƒœ๋ฅผ ํ‘œ์‹œํ•˜๊ธฐ ์œ„ํ•ด ๋ฒ„ํŠผ ๋‚ด๋ถ€์˜ ํ…์ŠคํŠธ์™€ DOM ๋…ธ๋“œ๋ฅผ ์ž„์‹œ๋กœ ๋ณ€๊ฒฝํ•˜๊ณ  ๋ณต๊ตฌํ•˜๋Š” ๊ณผ์ •์—์„œ `innerHTML`์„ ์ฝ๊ณ  ์“ฐ๋Š” ๋ฐฉ์‹์€ ์•ˆ์ „ํ•˜์ง€ ์•Š์œผ๋ฉฐ ์ •์  ๋ณด์•ˆ ์Šค์บ๋„ˆ์—์„œ ๋†’์€ ์œ„ํ—˜์œผ๋กœ ๋ถ„๋ฅ˜๋ฉ๋‹ˆ๋‹ค.
**Prevention:** ํ…์ŠคํŠธ๋‚˜ ๋…ธ๋“œ ์ƒํƒœ๋ฅผ ์—…๋ฐ์ดํŠธํ•  ๋•Œ๋Š” ๋ฐ˜๋“œ์‹œ `Array.from(el.childNodes)`๋กœ ์ž์‹ ๋…ธ๋“œ๋ฅผ ์ €์žฅํ•˜๊ณ , `el.replaceChildren(...initialChildren)`์„ ํ†ตํ•ด ๋ณต๊ตฌํ•˜์—ฌ ์•ˆ์ „ํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

## 2026-07-11 - ํŒŒ์ผ ์ด๋ฆ„์˜ ๋„ ๋ฐ”์ดํŠธ ์ทจ์•ฝ์  ํŒจ์น˜
**Vulnerability:** ํŒŒ์ผ ์—…๋กœ๋“œ ์‹œ ํŒŒ์ผ ์ด๋ฆ„์— ๋„ ๋ฐ”์ดํŠธ(`\u0000`)๋ฅผ ํฌํ•จํ•  ๊ฒฝ์šฐ, `java.nio.file.Path.of` ๋ฉ”์„œ๋“œ์—์„œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์—ฌ ๋ฐฑ์—”๋“œ ๊ฒ€์ฆ ๋กœ์ง์ด ์šฐํšŒ๋˜๊ฑฐ๋‚˜ ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์„œ๋น„์Šค ๊ฑฐ๋ถ€(DoS) ์ƒํƒœ๊ฐ€ ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
**Learning:** ํŒŒ์ผ ๊ฒฝ๋กœ ๋˜๋Š” ํ™•์žฅ์ž ๊ฒ€์ฆ์—์„œ ๋„ ๋ฐ”์ดํŠธ๊ฐ€ ํฌํ•จ๋œ ๊ฒฝ์šฐ ์ž˜๋ผ๋‚ด๊ธฐ(truncation) ๊ณต๊ฒฉ์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด ๋‹จ์ˆœํžˆ ์ œ๊ฑฐ(sanitize)ํ•˜๋Š” ๊ฒƒ๋ณด๋‹ค ์ฆ‰์‹œ ์˜ˆ์™ธ๋ฅผ ๋ฐœ์ƒ์‹œ์ผœ ์ž…๋ ฅ๊ฐ’์„ ๋ช…์‹œ์ ์œผ๋กœ ๊ฑฐ๋ถ€ํ•˜๋Š” ๊ฒƒ์ด ํ›จ์”ฌ ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.
**Prevention:** ํŒŒ์ผ ์ด๋ฆ„ ๋ฐ ๊ฒฝ๋กœ๋ฅผ ๋‹ค๋ฃจ๋Š” ๋ชจ๋“  ์ž…๋ ฅ๊ฐ’์— ๋Œ€ํ•ด ์‚ฌ์ „์— ๋„ ๋ฐ”์ดํŠธ๋ฅผ ๊ฒ€์‚ฌํ•˜๊ณ , ๋ฐœ๊ฒฌ ์‹œ `IllegalArgumentException`๊ณผ ๊ฐ™์€ ์˜ˆ์™ธ๋ฅผ ๋˜์ ธ ์ฆ‰๊ฐ ์ฐจ๋‹จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

## 2026-07-12 - Prevent DoS Resource Exhaustion in Stream Hashing
**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-22 - Missing Authentication on Admin Endpoints
**Vulnerability:** The `AdminController` endpoints (`/api/v1/admin/convert/jobs`, `/api/v1/admin/convert/jobs/{jobId}`, `/api/v1/admin/convert/jobs/{jobId}/retry`) lacked authentication and authorization checks, allowing unauthenticated users to read, delete, and retry all conversion jobs.
**Learning:** Spring controllers must explicitly enforce security policies, even if they are placed under an `/admin` path. The existence of an admin path does not automatically protect it.
**Prevention:** Always inject `TenantAccessService` and invoke `tenantAccessService.require(...)` for every controller method to ensure permissions are checked before processing the request. Add corresponding unit tests that explicitly check for these authorization controls.
## 2026-07-22 - Missing Authentication on Admin Endpoints
**Vulnerability:** The `AdminController` endpoints (`/api/v1/admin/convert/jobs`, `/api/v1/admin/convert/jobs/{jobId}`, `/api/v1/admin/convert/jobs/{jobId}/retry`) lacked authentication and authorization checks, allowing unauthenticated users to read, delete, and retry all conversion jobs.
**Learning:** Spring controllers must explicitly enforce security policies, even if they are placed under an `/admin` path. The existence of an admin path does not automatically protect it.
**Prevention:** Always inject `TenantAccessService` and invoke `tenantAccessService.require(...)` for every controller method to ensure permissions are checked before processing the request. Add corresponding unit tests that explicitly check for these authorization controls.
Comment on lines +5 to +8
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/modify admin data.
*/
public static final String ADMIN_WRITE = "admin:write";

private TenantPermissions() {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@
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.RequestParam;
import org.springframework.web.bind.annotation.RequestHeader;
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.TenantPermissions;
import com.clearfolio.viewer.model.ConversionJob;
import com.clearfolio.viewer.service.DocumentConversionService;
import com.clearfolio.viewer.service.RetryDeadLetterResult;
Expand All @@ -26,14 +30,17 @@
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;
}

/**
Expand All @@ -43,7 +50,8 @@ public AdminController(DocumentConversionService conversionService) {
* @return list of conversion jobs
Comment on lines 46 to 50
*/
@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 @@ -66,7 +74,8 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d
* @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 @@ -78,7 +87,8 @@ public ResponseEntity<Void> deleteJob(@PathVariable UUID jobId) {
* @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");
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
Expand Up @@ -2,31 +2,39 @@

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

import java.util.Arrays;
import java.util.Set;
import java.util.UUID;

import org.springframework.http.HttpHeaders;
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.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();
when(tenantAccessService.require(any(), any())).thenReturn(new TenantContext("t1", "s1", Set.of("admin:read", "admin:write")));
Comment on lines +33 to +37
}

@Test
Expand All @@ -37,6 +45,7 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() {

webTestClient.get()
.uri("/api/v1/admin/convert/jobs")
.header(HttpHeaders.AUTHORIZATION, "Bearer test-token")
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -55,6 +64,7 @@ void getAllJobsFiltersByDeadLetteredTrue() {

webTestClient.get()
.uri("/api/v1/admin/convert/jobs?deadLettered=true")
.header(HttpHeaders.AUTHORIZATION, "Bearer test-token")
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -72,6 +82,7 @@ void getAllJobsFiltersByDeadLetteredFalse() {

webTestClient.get()
.uri("/api/v1/admin/convert/jobs?deadLettered=false")
.header(HttpHeaders.AUTHORIZATION, "Bearer test-token")
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -85,6 +96,7 @@ void deleteJobReturnsNoContent() {

webTestClient.delete()
.uri("/api/v1/admin/convert/jobs/" + jobId)
.header(HttpHeaders.AUTHORIZATION, "Bearer test-token")
.exchange()
.expectStatus().isNoContent();
}
Expand All @@ -96,6 +108,7 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() {

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.header(HttpHeaders.AUTHORIZATION, "Bearer test-token")
.exchange()
.expectStatus().isAccepted();
}
Expand All @@ -107,6 +120,7 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() {

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.header(HttpHeaders.AUTHORIZATION, "Bearer test-token")
.exchange()
.expectStatus().isNotFound();
}
Expand All @@ -118,6 +132,7 @@ void retryDeadLetteredReturnsConflictWhenNotEligible() {

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