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();