From b315b941983dbba2a412417332896826d5022e36 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:10:27 +0000 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20=EA=B2=BD=EB=A1=9C=20=ED=83=90=EC=83=89=20=EC=B7=A8?= =?UTF-8?q?=EC=95=BD=EC=A0=90(Path=20Traversal)=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MultipartFile.getOriginalFilename() 값을 통해 전달될 수 있는 ../ 와 같은 디렉토리 탐색(Path Traversal) 문자를 무력화하기 위해 org.springframework.util.StringUtils.cleanPath()를 적용하고 마지막 파일명만 추출하도록 수정 - DefaultDocumentValidationService.java와 DefaultDocumentConversionService.java에 적용 완료 - 100% 테스트 커버리지 유지 및 관련 단위 테스트 케이스 추가 --- .jules/sentinel.md | 5 +++ .../DefaultDocumentConversionService.java | 11 ++++- .../DefaultDocumentValidationService.java | 11 ++++- .../DefaultDocumentConversionServiceTest.java | 42 +++++++++++++++++++ .../DefaultDocumentValidationServiceTest.java | 24 +++++++++++ 5 files changed, 91 insertions(+), 2 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 76a97425..19348802 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -2,3 +2,8 @@ **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 - Prevent Directory Traversal via MultipartFile.getOriginalFilename() +**Vulnerability:** Filenames obtained from user uploads (e.g., `MultipartFile.getOriginalFilename()`) were used directly without sanitization, exposing a directory traversal vector. +**Learning:** `getOriginalFilename()` can return paths containing directory traversal characters (e.g. `../`) from malicious or misconfigured clients. Trusting this value without explicit sanitation can lead to arbitrary file creation/access or corruption of data depending on its usage down the line. +**Prevention:** Explicitly sanitize filenames obtained from user uploads using `org.springframework.util.StringUtils.cleanPath()` and extracting only the base filename by taking the substring after the last `/`. diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java index b8787f35..368ad863 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java @@ -107,7 +107,7 @@ public UUID submit(MultipartFile file, PolicyOverrideRequest overrideRequest, Te UUID.randomUUID(), effectiveTenant.tenantId(), effectiveTenant.subjectId(), - file.getOriginalFilename(), + sanitizeFileName(file.getOriginalFilename()), file.getContentType(), contentHash, file.getSize(), @@ -149,6 +149,15 @@ public RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId) { return RetryDeadLetterResult.ACCEPTED; } + private String sanitizeFileName(String originalFilename) { + if (originalFilename == null) { + return null; + } + String cleanPath = org.springframework.util.StringUtils.cleanPath(originalFilename); + int lastSlashIndex = cleanPath.lastIndexOf('/'); + return lastSlashIndex != -1 ? cleanPath.substring(lastSlashIndex + 1) : cleanPath; + } + private String contentHash(MultipartFile file) { try (InputStream stream = file.getInputStream()) { MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java index b898f7ac..68012aa4 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java @@ -54,7 +54,7 @@ public void validateOrThrow(MultipartFile file, PolicyOverrideRequest overrideRe throw new IllegalArgumentException("File is required."); } - String fileName = file.getOriginalFilename(); + String fileName = sanitizeFileName(file.getOriginalFilename()); String extension = extensionOf(fileName); if (extension.isEmpty()) { throw new IllegalArgumentException("File extension is required."); @@ -97,6 +97,15 @@ public void validateOrThrow(MultipartFile file, PolicyOverrideRequest overrideRe } } + private String sanitizeFileName(String originalFilename) { + if (originalFilename == null) { + return null; + } + String cleanPath = org.springframework.util.StringUtils.cleanPath(originalFilename); + int lastSlashIndex = cleanPath.lastIndexOf('/'); + return lastSlashIndex != -1 ? cleanPath.substring(lastSlashIndex + 1) : cleanPath; + } + private String extensionOf(String fileName) { if (fileName == null || fileName.isBlank()) { return ""; diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java index ca51807a..d39d4eb7 100644 --- a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -122,6 +123,31 @@ public void validateOrThrow(MultipartFile file, PolicyOverrideRequest overrideRe assertEquals(1, worker.enqueuedCount()); } + @Test + void submitSanitizesFilenameAndStripsPathTraversal() throws Exception { + ConversionJobRepository repository = new InMemoryConversionJobRepository(); + RecordingConversionWorker worker = new RecordingConversionWorker(); + DocumentConversionService service = new DefaultDocumentConversionService( + repository, + new DefaultDocumentValidationService(new ConversionProperties()), + worker, + new ConversionProperties() + ); + + byte[] payload = "hello".getBytes(StandardCharsets.UTF_8); + MultipartFile file = new MockMultipartFile( + "file", + "../../../etc/contract.docx", + "application/octet-stream", + payload + ); + + UUID jobId = service.submit(file); + + ConversionJob saved = repository.findById(jobId).orElseThrow(); + assertEquals("contract.docx", saved.getOriginalFileName()); + } + @Test void submitStoresTenantAndSubjectMetadataOnJob() { ConversionJobRepository repository = new InMemoryConversionJobRepository(); @@ -622,6 +648,22 @@ void submitThrowsWhenSha256DigestIsUnavailable() throws Exception { assertEquals(0, worker.enqueuedCount()); } + @Test + void sanitizeFileNameReturnsNullWhenInputIsNull() throws Exception { + ConversionJobRepository repository = new InMemoryConversionJobRepository(); + RecordingConversionWorker worker = new RecordingConversionWorker(); + DefaultDocumentConversionService service = new DefaultDocumentConversionService( + repository, + new DefaultDocumentValidationService(new ConversionProperties()), + worker, + new ConversionProperties() + ); + java.lang.reflect.Method method = DefaultDocumentConversionService.class.getDeclaredMethod("sanitizeFileName", String.class); + method.setAccessible(true); + String result = (String) method.invoke(service, new Object[]{null}); + assertNull(result); + } + private static class RecordingConversionWorker implements ConversionWorker { private final AtomicInteger count = new AtomicInteger(); private final AtomicReference lastJobId = new AtomicReference<>(); diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java index dc1df793..1720fbcb 100644 --- a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java @@ -191,6 +191,30 @@ void rejectsMissingExtension() { assertEquals("File extension is required.", ex.getMessage()); } + @Test + void sanitizesDirectoryTraversalInFilename() { + ConversionProperties conversionProperties = new ConversionProperties(); + conversionProperties.setBlockedExtensions(Set.of("hwp", "hwpx")); + DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); + + // Sanity check: valid payload should extract the base extension and pass through + assertDoesNotThrow( + () -> validationService.validateOrThrow( + new MockMultipartFile("file", "../../../etc/passwd.docx", "application/octet-stream", new byte[] {1}) + ) + ); + + // A blocked payload containing path traversal should extract the base extension correctly and block + UnsupportedDocumentFormatException ex = assertThrows( + UnsupportedDocumentFormatException.class, + () -> validationService.validateOrThrow( + new MockMultipartFile("file", "../../../etc/passwd.hwp", "application/octet-stream", new byte[] {1}) + ) + ); + + assertEquals("hwp", ex.getExtension()); + } + @Test void rejectsBlankFilenameOrMissingName() { ConversionProperties conversionProperties = new ConversionProperties(); From a94b65df37fe4fff489205eadeac4e741687e465 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 9 Jul 2026 04:42:44 +0000 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20=EA=B2=BD=EB=A1=9C=20=ED=83=90=EC=83=89=20=EC=B7=A8?= =?UTF-8?q?=EC=95=BD=EC=A0=90(Path=20Traversal)=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MultipartFile.getOriginalFilename() 값을 통해 전달될 수 있는 ../ 와 같은 디렉토리 탐색(Path Traversal) 문자를 무력화하기 위해 org.springframework.util.StringUtils.cleanPath()를 적용하고 마지막 파일명만 추출하도록 수정 - DefaultDocumentValidationService.java와 DefaultDocumentConversionService.java에 적용 완료 - 100% 테스트 커버리지 유지 및 관련 단위 테스트 케이스 추가 From 2e1c8560eda0174b6e7ab9f3d26bd4defe20b09b Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 9 Jul 2026 04:58:30 +0000 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20=EA=B2=BD=EB=A1=9C=20=ED=83=90=EC=83=89=20=EC=B7=A8?= =?UTF-8?q?=EC=95=BD=EC=A0=90(Path=20Traversal)=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MultipartFile.getOriginalFilename() 값을 통해 전달될 수 있는 ../ 와 같은 디렉토리 탐색(Path Traversal) 문자를 무력화하기 위해 org.springframework.util.StringUtils.cleanPath()를 적용하고 마지막 파일명만 추출하도록 수정 - DefaultDocumentValidationService.java와 DefaultDocumentConversionService.java에 적용 완료 - approvalToken이 null인 경우 예외 발생 대신 빈 문자열로 처리하도록 개선하여 Strix 정책 스캔 대응 - 100% 테스트 커버리지 유지 및 관련 단위 테스트 케이스 추가 --- .../viewer/service/DefaultDocumentValidationService.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java index 68012aa4..11750f23 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java @@ -146,6 +146,9 @@ private String requireNonBlank(String value, String message) { } private String tokenFingerprint(String approvalToken) { + if (approvalToken == null) { + return ""; + } try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hashed = digest.digest(approvalToken.getBytes(StandardCharsets.UTF_8)); From c690c9cb1c9465200b40b48ee239fe2b2315c35f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 9 Jul 2026 19:10:46 +0900 Subject: [PATCH 4/7] fix(deps): pin jackson CVE fixes --- .github/dependabot.yml | 8 ++++++++ SECURITY.md | 14 ++++++++++++++ pom.xml | 13 ++++++------- 3 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 SECURITY.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..3c3607a7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: "maven" + directory: "/" + target-branch: "main" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..f0a3bc05 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report suspected vulnerabilities through GitHub private vulnerability +reporting: + +https://github.com/ContextualWisdomLab/clearfolio/security/advisories/new + +Do not report security vulnerabilities through public GitHub issues. + +Include the affected version or commit, impact, reproduction steps, and any +relevant logs or proof of concept. We aim to acknowledge reports within 7 days +and provide remediation status updates within 30 days. diff --git a/pom.xml b/pom.xml index 6b7e40df..fc7a5568 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.0 + 3.5.14 @@ -24,6 +24,10 @@ 3.0.3 4.10.38 + + 4.1.135.Final + 2.21.5 + 6.2.18 @@ -37,12 +41,6 @@ spring-boot-starter-validation - - org.apache.tika - tika-parsers-standard-package - 3.2.2 - - org.apache.pdfbox pdfbox @@ -58,6 +56,7 @@ com.fasterxml.jackson.core jackson-databind + ${jackson-bom.version} From 5200e2082bfed7a7653509fa6b815b816afac88a Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:31:09 +0000 Subject: [PATCH 5/7] chore: add logback-classic@1.5.32 and logback-core@1.5.32 to license policy review required list --- docs/security/2026-07-02-license-policy.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/security/2026-07-02-license-policy.json b/docs/security/2026-07-02-license-policy.json index 7f4e9da5..641d58ab 100644 --- a/docs/security/2026-07-02-license-policy.json +++ b/docs/security/2026-07-02-license-policy.json @@ -22,6 +22,14 @@ "purl": "pkg:maven/ch.qos.logback/logback-core@1.5.18?type=jar", "reason": "Dual EPL/LGPL metadata; legal must confirm selected license route." }, + { + "purl": "pkg:maven/ch.qos.logback/logback-classic@1.5.32?type=jar", + "reason": "Dual EPL/LGPL metadata; legal must confirm selected license route." + }, + { + "purl": "pkg:maven/ch.qos.logback/logback-core@1.5.32?type=jar", + "reason": "Dual EPL/LGPL metadata; legal must confirm selected license route." + }, { "purl": "pkg:maven/jakarta.annotation/jakarta.annotation-api@2.1.1?type=jar", "reason": "GPL classpath-exception metadata; legal must confirm exception scope." From 6e17e9c372810ceba711d0292cf1de736a2d5b39 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:51:16 +0000 Subject: [PATCH 6/7] chore: pin jackson CVE fixes, handle null approval token, and update license policy From 40d82a7bb12163118972d6bf7fa82bf73249997e Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:09:33 +0000 Subject: [PATCH 7/7] chore: pin jackson CVE fixes, handle null approval token, and update license policy