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
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: 2
updates:
- package-ecosystem: "maven"
directory: "/"
target-branch: "main"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/`.
14 changes: 14 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions docs/security/2026-07-02-license-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
13 changes: 6 additions & 7 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version>
<version>3.5.14</version>
<relativePath/>
</parent>

Expand All @@ -24,6 +24,10 @@
<argLine></argLine>
<pdfbox.version>3.0.3</pdfbox.version>
<pdfjs.dist.version>4.10.38</pdfjs.dist.version>
<!-- Security overrides: pin transitive deps to CVE-fixed versions (managed by Spring Boot BOM) -->
<netty.version>4.1.135.Final</netty.version>
<jackson-bom.version>2.21.5</jackson-bom.version>
<spring-framework.version>6.2.18</spring-framework.version>
</properties>

<dependencies>
Expand All @@ -37,12 +41,6 @@
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parsers-standard-package</artifactId>
<version>3.2.2</version>
</dependency>

<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
Expand All @@ -58,6 +56,7 @@
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson-bom.version}</version>
</dependency>

<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -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 "";
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<UUID> lastJobId = new AtomicReference<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading