This commit is contained in:
@@ -3,6 +3,7 @@ package ru.soune.nocopy.controller.demo;
|
|||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -19,6 +20,14 @@ public class DemoController {
|
|||||||
private final DemoSessionService demoSessionService;
|
private final DemoSessionService demoSessionService;
|
||||||
private final HttpServletRequest httpServletRequest;
|
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
|
* Требуется файл в формате multipart/form-data
|
||||||
@@ -161,14 +170,16 @@ public class DemoController {
|
|||||||
* Учитывает различные прокси и заголовки
|
* Учитывает различные прокси и заголовки
|
||||||
*/
|
*/
|
||||||
private String getClientIp() {
|
private String getClientIp() {
|
||||||
String xForwardedFor = httpServletRequest.getHeader("X-Forwarded-For");
|
if (trustForwardedHeader) {
|
||||||
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
|
String xForwardedFor = httpServletRequest.getHeader("X-Forwarded-For");
|
||||||
return xForwardedFor.split(",")[0].trim();
|
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
|
||||||
}
|
return xForwardedFor.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
String xRealIp = httpServletRequest.getHeader("X-Real-IP");
|
String xRealIp = httpServletRequest.getHeader("X-Real-IP");
|
||||||
if (xRealIp != null && !xRealIp.isEmpty()) {
|
if (xRealIp != null && !xRealIp.isEmpty()) {
|
||||||
return xRealIp;
|
return xRealIp;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return httpServletRequest.getRemoteAddr();
|
return httpServletRequest.getRemoteAddr();
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ public class DemoSession {
|
|||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now();
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
expiresAt = LocalDateTime.now().plusHours(24);
|
expiresAt = LocalDateTime.now().plusHours(24);
|
||||||
status = DemoSessionStatus.CREATED;
|
if (status == null) {
|
||||||
|
status = DemoSessionStatus.CREATED;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@@ -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'")
|
@Query("SELECT d FROM DemoSession d WHERE d.expiresAt <= :now AND d.status != 'CLEANED'")
|
||||||
List<DemoSession> findExpiredSessions(LocalDateTime now);
|
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")
|
@Query("SELECT COUNT(d) FROM DemoSession d WHERE d.ipAddress = :ipAddress AND d.createdAt >= :since")
|
||||||
Integer countSessionsByIpAndTime(String ipAddress, LocalDateTime since);
|
Integer countSessionsByIpAndTime(String ipAddress, LocalDateTime since);
|
||||||
|
|
||||||
|
|||||||
@@ -126,10 +126,7 @@ public class DemoCleanupService {
|
|||||||
*/
|
*/
|
||||||
public long getActiveDemoSessionsCount() {
|
public long getActiveDemoSessionsCount() {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
return demoSessionRepository.findExpiredSessions(now)
|
return demoSessionRepository.findActiveSessions(now).size();
|
||||||
.stream()
|
|
||||||
.filter(s -> !s.getStatus().equals(DemoSessionStatus.CLEANED))
|
|
||||||
.count();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,9 +135,8 @@ public class DemoCleanupService {
|
|||||||
*/
|
*/
|
||||||
public long getActiveDemoDataSize() {
|
public long getActiveDemoDataSize() {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
return demoSessionRepository.findExpiredSessions(now)
|
return demoSessionRepository.findActiveSessions(now)
|
||||||
.stream()
|
.stream()
|
||||||
.filter(s -> !s.getStatus().equals(DemoSessionStatus.CLEANED))
|
|
||||||
.mapToLong(DemoSession::getFileSize)
|
.mapToLong(DemoSession::getFileSize)
|
||||||
.sum();
|
.sum();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import ru.soune.nocopy.repository.SimilarImageProjection;
|
|||||||
import ru.soune.nocopy.service.FileSimilarityService;
|
import ru.soune.nocopy.service.FileSimilarityService;
|
||||||
import ru.soune.nocopy.service.ImageHashService;
|
import ru.soune.nocopy.service.ImageHashService;
|
||||||
import ru.soune.nocopy.service.file.cloud.CloudStorageService;
|
import ru.soune.nocopy.service.file.cloud.CloudStorageService;
|
||||||
import ru.soune.nocopy.util.FileUtil;
|
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@@ -44,7 +43,9 @@ public class DemoSessionService {
|
|||||||
@Lazy
|
@Lazy
|
||||||
private final NoCopyFileService noCopyFileService;
|
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
|
@Value("${demo.max-file-size:52428800}") // 50 MB default
|
||||||
private Long maxFileSize;
|
private Long maxFileSize;
|
||||||
@@ -67,15 +68,17 @@ public class DemoSessionService {
|
|||||||
|
|
||||||
validateDemoUpload(file, ipAddress);
|
validateDemoUpload(file, ipAddress);
|
||||||
|
|
||||||
|
String safeFileName = sanitizeFileName(file.getOriginalFilename());
|
||||||
|
|
||||||
DemoSession session = DemoSession.builder()
|
DemoSession session = DemoSession.builder()
|
||||||
.sessionId(UUID.randomUUID().toString())
|
.sessionId(UUID.randomUUID().toString())
|
||||||
.ipAddress(ipAddress)
|
.ipAddress(ipAddress)
|
||||||
.fileName(file.getOriginalFilename())
|
.fileName(safeFileName)
|
||||||
.fileSize(file.getSize())
|
.fileSize(file.getSize())
|
||||||
.fileType(detectFileType(file.getContentType()))
|
.fileType(detectFileType(file.getContentType()))
|
||||||
.mimeType(file.getContentType())
|
.mimeType(file.getContentType())
|
||||||
.status(DemoSessionStatus.CREATED)
|
.status(DemoSessionStatus.CREATED)
|
||||||
.s3Path(demoS3Path + UUID.randomUUID() + "/" + file.getOriginalFilename())
|
.s3Path(demoS3Path + UUID.randomUUID() + "/" + safeFileName)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
String tempFilePath = null;
|
String tempFilePath = null;
|
||||||
@@ -247,9 +250,33 @@ public class DemoSessionService {
|
|||||||
throw new RuntimeException("File type not supported for demo");
|
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);
|
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) {
|
private String detectFileType(String mimeType) {
|
||||||
if (mimeType == null) return "unknown";
|
if (mimeType == null) return "unknown";
|
||||||
if (mimeType.contains("image")) return "image";
|
if (mimeType.contains("image")) return "image";
|
||||||
@@ -301,7 +328,7 @@ public class DemoSessionService {
|
|||||||
Path tempDir = Paths.get(basePath).resolve("temp").resolve("demo").resolve(sessionId);
|
Path tempDir = Paths.get(basePath).resolve("temp").resolve("demo").resolve(sessionId);
|
||||||
Files.createDirectories(tempDir);
|
Files.createDirectories(tempDir);
|
||||||
|
|
||||||
String fileName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
|
String fileName = sanitizeFileName(file.getOriginalFilename());
|
||||||
Path tempFilePath = tempDir.resolve(fileName);
|
Path tempFilePath = tempDir.resolve(fileName);
|
||||||
|
|
||||||
file.transferTo(tempFilePath.toFile());
|
file.transferTo(tempFilePath.toFile());
|
||||||
@@ -310,6 +337,24 @@ public class DemoSessionService {
|
|||||||
return tempFilePath.toString();
|
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 {
|
private void performProtectionCheck(String tempFilePath, String fileType) throws IOException, DuplicateImageException {
|
||||||
File file = new File(tempFilePath);
|
File file = new File(tempFilePath);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user