This commit is contained in:
@@ -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,6 +170,7 @@ public class DemoController {
|
||||
* Учитывает различные прокси и заголовки
|
||||
*/
|
||||
private String getClientIp() {
|
||||
if (trustForwardedHeader) {
|
||||
String xForwardedFor = httpServletRequest.getHeader("X-Forwarded-For");
|
||||
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
|
||||
return xForwardedFor.split(",")[0].trim();
|
||||
@@ -170,6 +180,7 @@ public class DemoController {
|
||||
if (xRealIp != null && !xRealIp.isEmpty()) {
|
||||
return xRealIp;
|
||||
}
|
||||
}
|
||||
|
||||
return httpServletRequest.getRemoteAddr();
|
||||
}
|
||||
|
||||
@@ -61,8 +61,10 @@ public class DemoSession {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
expiresAt = LocalDateTime.now().plusHours(24);
|
||||
if (status == null) {
|
||||
status = DemoSessionStatus.CREATED;
|
||||
}
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
|
||||
@@ -19,6 +19,9 @@ public interface DemoSessionRepository extends JpaRepository<DemoSession, String
|
||||
@Query("SELECT d FROM DemoSession d WHERE d.expiresAt <= :now AND d.status != 'CLEANED'")
|
||||
List<DemoSession> findExpiredSessions(LocalDateTime now);
|
||||
|
||||
@Query("SELECT d FROM DemoSession d WHERE d.expiresAt > :now AND d.status != 'CLEANED'")
|
||||
List<DemoSession> findActiveSessions(LocalDateTime now);
|
||||
|
||||
@Query("SELECT COUNT(d) FROM DemoSession d WHERE d.ipAddress = :ipAddress AND d.createdAt >= :since")
|
||||
Integer countSessionsByIpAndTime(String ipAddress, LocalDateTime since);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<String> ALLOWED_IMAGE_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "bmp", "webp");
|
||||
private static final Set<String> ALLOWED_AUDIO_EXTENSIONS = Set.of("wav");
|
||||
private static final Set<String> 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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user