feat: Sprint 13 — Production Hardening (security fixes, CI gate, rate limiting, tests)
Deploy to TrueNAS / deploy (push) Failing after 12s
Deploy to TrueNAS / deploy (push) Failing after 12s
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user