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/.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/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/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."
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}
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..11750f23 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 "";
@@ -137,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));
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();