diff --git a/src/main/java/ru/soune/nocopy/controller/demo/DemoController.java b/src/main/java/ru/soune/nocopy/controller/demo/DemoController.java index e9d41f5..caa22ed 100644 --- a/src/main/java/ru/soune/nocopy/controller/demo/DemoController.java +++ b/src/main/java/ru/soune/nocopy/controller/demo/DemoController.java @@ -3,6 +3,7 @@ package ru.soune.nocopy.controller.demo; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -19,6 +20,14 @@ public class DemoController { private final DemoSessionService demoSessionService; private final HttpServletRequest httpServletRequest; + /** + * X-Forwarded-For/X-Real-IP — клиентские заголовки, их можно подделать в запросе. + * Доверять им можно, только если приложение стоит за доверенным reverse-proxy/LB, + * который сам их проставляет и не пропускает клиентские значения напрямую. + */ + @Value("${demo.trust-forwarded-header:false}") + private boolean trustForwardedHeader; + /** * Загрузка файла для демонстрации * Требуется файл в формате multipart/form-data @@ -161,14 +170,16 @@ public class DemoController { * Учитывает различные прокси и заголовки */ private String getClientIp() { - String xForwardedFor = httpServletRequest.getHeader("X-Forwarded-For"); - if (xForwardedFor != null && !xForwardedFor.isEmpty()) { - return xForwardedFor.split(",")[0].trim(); - } + if (trustForwardedHeader) { + String xForwardedFor = httpServletRequest.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isEmpty()) { + return xForwardedFor.split(",")[0].trim(); + } - String xRealIp = httpServletRequest.getHeader("X-Real-IP"); - if (xRealIp != null && !xRealIp.isEmpty()) { - return xRealIp; + String xRealIp = httpServletRequest.getHeader("X-Real-IP"); + if (xRealIp != null && !xRealIp.isEmpty()) { + return xRealIp; + } } return httpServletRequest.getRemoteAddr(); diff --git a/src/main/java/ru/soune/nocopy/entity/demo/DemoSession.java b/src/main/java/ru/soune/nocopy/entity/demo/DemoSession.java index 28ba304..8c5344c 100644 --- a/src/main/java/ru/soune/nocopy/entity/demo/DemoSession.java +++ b/src/main/java/ru/soune/nocopy/entity/demo/DemoSession.java @@ -61,7 +61,9 @@ public class DemoSession { createdAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(); expiresAt = LocalDateTime.now().plusHours(24); - status = DemoSessionStatus.CREATED; + if (status == null) { + status = DemoSessionStatus.CREATED; + } } @PreUpdate diff --git a/src/main/java/ru/soune/nocopy/repository/DemoSessionRepository.java b/src/main/java/ru/soune/nocopy/repository/DemoSessionRepository.java index 739ea22..fc55a75 100644 --- a/src/main/java/ru/soune/nocopy/repository/DemoSessionRepository.java +++ b/src/main/java/ru/soune/nocopy/repository/DemoSessionRepository.java @@ -19,6 +19,9 @@ public interface DemoSessionRepository extends JpaRepository findExpiredSessions(LocalDateTime now); + @Query("SELECT d FROM DemoSession d WHERE d.expiresAt > :now AND d.status != 'CLEANED'") + List findActiveSessions(LocalDateTime now); + @Query("SELECT COUNT(d) FROM DemoSession d WHERE d.ipAddress = :ipAddress AND d.createdAt >= :since") Integer countSessionsByIpAndTime(String ipAddress, LocalDateTime since); diff --git a/src/main/java/ru/soune/nocopy/service/demo/DemoCleanupService.java b/src/main/java/ru/soune/nocopy/service/demo/DemoCleanupService.java index e71a056..6ca3cc5 100644 --- a/src/main/java/ru/soune/nocopy/service/demo/DemoCleanupService.java +++ b/src/main/java/ru/soune/nocopy/service/demo/DemoCleanupService.java @@ -126,10 +126,7 @@ public class DemoCleanupService { */ public long getActiveDemoSessionsCount() { LocalDateTime now = LocalDateTime.now(); - return demoSessionRepository.findExpiredSessions(now) - .stream() - .filter(s -> !s.getStatus().equals(DemoSessionStatus.CLEANED)) - .count(); + return demoSessionRepository.findActiveSessions(now).size(); } /** @@ -138,9 +135,8 @@ public class DemoCleanupService { */ public long getActiveDemoDataSize() { LocalDateTime now = LocalDateTime.now(); - return demoSessionRepository.findExpiredSessions(now) + return demoSessionRepository.findActiveSessions(now) .stream() - .filter(s -> !s.getStatus().equals(DemoSessionStatus.CLEANED)) .mapToLong(DemoSession::getFileSize) .sum(); } diff --git a/src/main/java/ru/soune/nocopy/service/demo/DemoSessionService.java b/src/main/java/ru/soune/nocopy/service/demo/DemoSessionService.java index 9c34b41..71314fc 100644 --- a/src/main/java/ru/soune/nocopy/service/demo/DemoSessionService.java +++ b/src/main/java/ru/soune/nocopy/service/demo/DemoSessionService.java @@ -21,7 +21,6 @@ import ru.soune.nocopy.repository.SimilarImageProjection; import ru.soune.nocopy.service.FileSimilarityService; import ru.soune.nocopy.service.ImageHashService; import ru.soune.nocopy.service.file.cloud.CloudStorageService; -import ru.soune.nocopy.util.FileUtil; import java.io.*; import java.nio.file.Files; @@ -44,7 +43,9 @@ public class DemoSessionService { @Lazy private final NoCopyFileService noCopyFileService; - private final FileUtil fileUtil; + private static final Set ALLOWED_IMAGE_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "bmp", "webp"); + private static final Set ALLOWED_AUDIO_EXTENSIONS = Set.of("wav"); + private static final Set ALLOWED_DOCUMENT_EXTENSIONS = Set.of("pdf", "doc", "docx"); @Value("${demo.max-file-size:52428800}") // 50 MB default private Long maxFileSize; @@ -67,15 +68,17 @@ public class DemoSessionService { validateDemoUpload(file, ipAddress); + String safeFileName = sanitizeFileName(file.getOriginalFilename()); + DemoSession session = DemoSession.builder() .sessionId(UUID.randomUUID().toString()) .ipAddress(ipAddress) - .fileName(file.getOriginalFilename()) + .fileName(safeFileName) .fileSize(file.getSize()) .fileType(detectFileType(file.getContentType())) .mimeType(file.getContentType()) .status(DemoSessionStatus.CREATED) - .s3Path(demoS3Path + UUID.randomUUID() + "/" + file.getOriginalFilename()) + .s3Path(demoS3Path + UUID.randomUUID() + "/" + safeFileName) .build(); String tempFilePath = null; @@ -247,9 +250,33 @@ public class DemoSessionService { throw new RuntimeException("File type not supported for demo"); } + // Content-Type присылается клиентом и может быть подделан, поэтому дополнительно + // сверяем расширение файла с ожидаемым для заявленной категории + String detectedType = detectFileType(contentType); + String extension = getFileExtension(file.getOriginalFilename()); + if (!isExtensionAllowedForType(detectedType, extension)) { + throw new RuntimeException("File extension does not match declared file type"); + } + log.info("Validation passed for IP: {}", ipAddress); } + private String getFileExtension(String fileName) { + if (fileName == null) return ""; + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex == fileName.length() - 1) return ""; + return fileName.substring(dotIndex + 1).toLowerCase(); + } + + private boolean isExtensionAllowedForType(String fileType, String extension) { + return switch (fileType) { + case "image" -> ALLOWED_IMAGE_EXTENSIONS.contains(extension); + case "audio" -> ALLOWED_AUDIO_EXTENSIONS.contains(extension); + case "document" -> ALLOWED_DOCUMENT_EXTENSIONS.contains(extension); + default -> false; + }; + } + private String detectFileType(String mimeType) { if (mimeType == null) return "unknown"; if (mimeType.contains("image")) return "image"; @@ -301,7 +328,7 @@ public class DemoSessionService { Path tempDir = Paths.get(basePath).resolve("temp").resolve("demo").resolve(sessionId); Files.createDirectories(tempDir); - String fileName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file"; + String fileName = sanitizeFileName(file.getOriginalFilename()); Path tempFilePath = tempDir.resolve(fileName); file.transferTo(tempFilePath.toFile()); @@ -310,6 +337,24 @@ public class DemoSessionService { return tempFilePath.toString(); } + /** + * Убирает любые компоненты пути из имени файла, чтобы исключить path traversal + * (например, "../../etc/passwd" или абсолютные пути) при записи временного файла + * и формировании ключа в S3. + */ + private String sanitizeFileName(String rawFileName) { + String name = (rawFileName == null || rawFileName.isBlank()) ? "file" : rawFileName; + name = name.replace('\\', '/'); + int lastSlash = name.lastIndexOf('/'); + if (lastSlash >= 0) { + name = name.substring(lastSlash + 1); + } + if (name.isBlank() || name.equals(".") || name.equals("..")) { + name = "file"; + } + return name; + } + private void performProtectionCheck(String tempFilePath, String fileType) throws IOException, DuplicateImageException { File file = new File(tempFilePath);