feat: Sprint 13 — Production Hardening (security fixes, CI gate, rate limiting, tests)
Deploy to TrueNAS / deploy (push) Failing after 12s

This commit is contained in:
Patrick Plate
2026-06-18 16:08:05 +02:00
parent 279487067e
commit f9a87efb7a
17 changed files with 1962 additions and 107 deletions
@@ -5,6 +5,7 @@ import de.cannamanage.domain.enums.AuditEventType;
import de.cannamanage.domain.enums.DocumentAccessLevel;
import de.cannamanage.domain.enums.DocumentCategory;
import de.cannamanage.service.repository.DocumentRepository;
import org.apache.commons.io.FilenameUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -198,13 +199,11 @@ public class DocumentService {
if (original == null || original.isBlank()) {
return UUID.randomUUID().toString();
}
// Strip path components — keep only the basename
String name;
try {
name = Paths.get(original).getFileName().toString();
} catch (RuntimeException e) {
// Invalid path on this platform — fall back to a random name
return UUID.randomUUID().toString();
// Strip path components using commons-io — handles both Unix and Windows separators
// regardless of the current platform (unlike Paths.get which is platform-dependent)
String name = FilenameUtils.getName(original);
if (name == null || name.isBlank()) {
return "document";
}
// Remove control characters and path-/shell-/Windows-reserved characters
name = name.replaceAll("[\\x00-\\x1F\\x7F/\\\\:*?\"<>|]", "_");
@@ -214,7 +213,7 @@ public class DocumentService {
}
// Ensure not empty after sanitization
if (name.isBlank() || ".".equals(name) || "..".equals(name)) {
return UUID.randomUUID().toString();
return "document";
}
return name;
}
@@ -267,6 +267,83 @@ class DocumentServiceTest {
}
}
// --- Additional security tests for Sprint 13 ---
@Test
void testUploadDocument_sanitizesPathTraversal_toBasename() throws IOException {
// Verify that "../../etc/passwd" is stripped to just "passwd"
MultipartFile file = mockValidFile("../../etc/passwd", "application/pdf", 512);
when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0));
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null);
filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null);
Document result = documentService.uploadDocument(
clubId, "Path Traversal Test", DocumentCategory.SONSTIGES,
DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy);
assertThat(result.getFilename()).isEqualTo("passwd");
}
}
@Test
void testUploadDocument_nullFilename_usesFallback() throws IOException {
MultipartFile file = mockValidFile(null, "application/pdf", 512);
when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0));
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null);
filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null);
Document result = documentService.uploadDocument(
clubId, "Null Filename", DocumentCategory.SONSTIGES,
DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy);
// Null filename should produce a non-blank fallback (UUID)
assertThat(result.getFilename()).isNotBlank();
assertThat(result.getFilename()).doesNotContain("..");
assertThat(result.getFilename()).doesNotContain("/");
}
}
@Test
void testUploadDocument_normalFilename_preserved() throws IOException {
MultipartFile file = mockValidFile("report.pdf", "application/pdf", 1024);
when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0));
try (MockedStatic<Files> filesMock = mockStatic(Files.class)) {
filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null);
filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null);
Document result = documentService.uploadDocument(
clubId, "Normal File", DocumentCategory.PROTOKOLL,
DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy);
assertThat(result.getFilename()).isEqualTo("report.pdf");
}
}
@Test
void testDownloadDocument_documentNotFound_throwsException() {
UUID nonExistentId = UUID.randomUUID();
when(documentRepository.findById(nonExistentId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> documentService.downloadDocument(nonExistentId))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("not found");
}
@Test
void testDeleteDocument_documentNotFound_throwsException() {
UUID nonExistentId = UUID.randomUUID();
when(documentRepository.findById(nonExistentId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> documentService.deleteDocument(nonExistentId, uploadedBy, clubId))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("not found");
}
// --- Helpers ---
private MultipartFile mockValidFile(String filename, String contentType, long size) {