diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index 50054f2..6b332e3 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -28,7 +28,7 @@ jobs: with: # In Gitea, pr-mode funziona se il runner ha accesso ai dati del clone pr-mode: ${{ gitea.event_name == 'pull_request' }} - use-caches: true + use-caches: false # Nota: Gitea ha un supporto limitato per i commenti automatici # rispetto a GitHub, ma l'analisi verrĂ  eseguita correttamente. post-pr-comment: false diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java index 9869a50..b3918c5 100644 --- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -7,6 +7,7 @@ import com.printcalculator.repository.CustomQuoteRequestRepository; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -14,13 +15,18 @@ import org.springframework.web.multipart.MultipartFile; import jakarta.validation.Valid; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.time.OffsetDateTime; import java.util.List; +import java.util.Locale; import java.util.Set; import java.util.UUID; +import java.util.regex.Pattern; @RestController @RequestMapping("/api/custom-quote-requests") @@ -31,7 +37,8 @@ public class CustomQuoteRequestController { private final com.printcalculator.service.ClamAVService clamAVService; // TODO: Inject Storage Service - private static final String STORAGE_ROOT = "storage_requests"; + private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize(); + private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); private static final Set FORBIDDEN_COMPRESSED_EXTENSIONS = Set.of( "zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst" ); @@ -116,8 +123,7 @@ public class CustomQuoteRequestController { // Generate path UUID fileUuid = UUID.randomUUID(); - String ext = getExtension(file.getOriginalFilename()); - String storedFilename = fileUuid.toString() + "." + ext; + String storedFilename = fileUuid + ".upload"; // Note: We don't have attachment ID yet. // We'll save attachment first to get ID. @@ -126,14 +132,22 @@ public class CustomQuoteRequestController { attachment = attachmentRepo.save(attachment); - String relativePath = "quote-requests/" + request.getId() + "/attachments/" + attachment.getId() + "/" + storedFilename; - attachment.setStoredRelativePath(relativePath); + Path relativePath = Path.of( + "quote-requests", + request.getId().toString(), + "attachments", + attachment.getId().toString(), + storedFilename + ); + attachment.setStoredRelativePath(relativePath.toString()); attachmentRepo.save(attachment); // Save file to disk - Path absolutePath = Paths.get(STORAGE_ROOT, relativePath); + Path absolutePath = resolveWithinStorageRoot(relativePath); Files.createDirectories(absolutePath.getParent()); - Files.copy(file.getInputStream(), absolutePath); + try (InputStream inputStream = file.getInputStream()) { + Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING); + } } } @@ -151,9 +165,16 @@ public class CustomQuoteRequestController { // Helper private String getExtension(String filename) { if (filename == null) return "dat"; - int i = filename.lastIndexOf('.'); - if (i > 0) { - return filename.substring(i + 1).toLowerCase(); + String cleaned = StringUtils.cleanPath(filename); + if (cleaned.contains("..")) { + return "dat"; + } + int i = cleaned.lastIndexOf('.'); + if (i > 0 && i < cleaned.length() - 1) { + String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT); + if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) { + return ext; + } } return "dat"; } @@ -166,4 +187,20 @@ public class CustomQuoteRequestController { String mime = file.getContentType(); return mime != null && FORBIDDEN_COMPRESSED_MIME_TYPES.contains(mime.toLowerCase()); } + + private Path resolveWithinStorageRoot(Path relativePath) { + try { + Path normalizedRelative = relativePath.normalize(); + if (normalizedRelative.isAbsolute()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); + } + Path absolutePath = STORAGE_ROOT.resolve(normalizedRelative).normalize(); + if (!absolutePath.startsWith(STORAGE_ROOT)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); + } + return absolutePath; + } catch (InvalidPathException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); + } + } } diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index 7995e75..e002fb9 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -11,18 +11,17 @@ import com.printcalculator.service.StorageService; import com.printcalculator.service.TwintPaymentService; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import jakarta.validation.Valid; import java.io.IOException; -import java.math.BigDecimal; -import java.nio.file.Files; + +import java.nio.file.InvalidPathException; import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.OffsetDateTime; -import java.time.format.DateTimeFormatter; + import java.util.List; import java.util.UUID; import java.util.Map; @@ -30,10 +29,13 @@ import java.util.HashMap; import java.util.Base64; import java.util.stream.Collectors; import java.net.URI; +import java.util.Locale; +import java.util.regex.Pattern; @RestController @RequestMapping("/api/orders") public class OrderController { + private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); private final OrderService orderService; private final OrderRepository orderRepo; @@ -104,15 +106,21 @@ public class OrderController { } String relativePath = item.getStoredRelativePath(); + Path destinationRelativePath; if (relativePath == null || relativePath.equals("PENDING")) { String ext = getExtension(file.getOriginalFilename()); - String storedFilename = UUID.randomUUID().toString() + "." + ext; - relativePath = "orders/" + orderId + "/3d-files/" + orderItemId + "/" + storedFilename; - item.setStoredRelativePath(relativePath); + String storedFilename = UUID.randomUUID() + "." + ext; + destinationRelativePath = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString(), storedFilename); + item.setStoredRelativePath(destinationRelativePath.toString()); item.setStoredFilename(storedFilename); + } else { + destinationRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId); + if (destinationRelativePath == null) { + return ResponseEntity.badRequest().build(); + } } - - storageService.store(file, Paths.get(relativePath)); + + storageService.store(file, destinationRelativePath); item.setFileSizeBytes(file.getSize()); item.setMimeType(file.getContentType()); orderItemRepo.save(item); @@ -158,9 +166,9 @@ public class OrderController { .orElseThrow(() -> new RuntimeException("Order not found")); if (isConfirmation) { - String relativePath = buildConfirmationPdfRelativePath(order); + Path relativePath = buildConfirmationPdfRelativePath(order); try { - byte[] existingPdf = storageService.loadAsResource(Paths.get(relativePath)).getInputStream().readAllBytes(); + byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes(); return ResponseEntity.ok() .header("Content-Disposition", "attachment; filename=\"confirmation-" + getDisplayOrderNumber(order) + ".pdf\"") .contentType(MediaType.APPLICATION_PDF) @@ -182,8 +190,13 @@ public class OrderController { .body(pdf); } - private String buildConfirmationPdfRelativePath(Order order) { - return "orders/" + order.getId() + "/documents/confirmation-" + getDisplayOrderNumber(order) + ".pdf"; + private Path buildConfirmationPdfRelativePath(Order order) { + return Path.of( + "orders", + order.getId().toString(), + "documents", + "confirmation-" + getDisplayOrderNumber(order) + ".pdf" + ); } @GetMapping("/{orderId}/twint") @@ -236,13 +249,38 @@ public class OrderController { private String getExtension(String filename) { if (filename == null) return "stl"; - int i = filename.lastIndexOf('.'); - if (i > 0) { - return filename.substring(i + 1); + String cleaned = StringUtils.cleanPath(filename); + if (cleaned.contains("..")) { + return "stl"; + } + int i = cleaned.lastIndexOf('.'); + if (i > 0 && i < cleaned.length() - 1) { + String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT); + if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) { + return ext; + } } return "stl"; } + private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) { + try { + Path candidate = Path.of(storedRelativePath).normalize(); + if (candidate.isAbsolute()) { + return null; + } + + Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString()); + if (!candidate.startsWith(expectedPrefix)) { + return null; + } + + return candidate; + } catch (InvalidPathException e) { + return null; + } + } + private OrderDto convertToDto(Order order, List items) { OrderDto dto = new OrderDto(); dto.setId(order.getId()); diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 910fa94..3119cb1 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -19,15 +19,19 @@ import com.printcalculator.service.SlicerService; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.io.InputStream; import java.math.BigDecimal; import java.math.RoundingMode; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.HashMap; @@ -35,6 +39,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; import java.util.Optional; +import java.util.Locale; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; @@ -42,6 +47,7 @@ import org.springframework.core.io.UrlResource; @RequestMapping("/api/quote-sessions") public class QuoteSessionController { + private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize(); private final QuoteSessionRepository sessionRepo; private final QuoteLineItemRepository lineItemRepo; @@ -121,19 +127,24 @@ public class QuoteSessionController { // 1. Define Persistent Storage Path // Structure: storage_quotes/{sessionId}/{uuid}.{ext} - String storageDir = "storage_quotes/" + session.getId(); - Files.createDirectories(Paths.get(storageDir)); - + Path sessionStorageDir = QUOTE_STORAGE_ROOT.resolve(session.getId().toString()).normalize(); + if (!sessionStorageDir.startsWith(QUOTE_STORAGE_ROOT)) { + throw new IOException("Invalid quote session storage path"); + } + Files.createDirectories(sessionStorageDir); + String originalFilename = file.getOriginalFilename(); - String ext = originalFilename != null && originalFilename.contains(".") - ? originalFilename.substring(originalFilename.lastIndexOf(".")) - : ".stl"; - - String storedFilename = UUID.randomUUID() + ext; - Path persistentPath = Paths.get(storageDir, storedFilename); - + String ext = getSafeExtension(originalFilename, "stl"); + String storedFilename = UUID.randomUUID() + "." + ext; + Path persistentPath = sessionStorageDir.resolve(storedFilename).normalize(); + if (!persistentPath.startsWith(sessionStorageDir)) { + throw new IOException("Invalid quote line-item storage path"); + } + // Save file - Files.copy(file.getInputStream(), persistentPath); + try (InputStream inputStream = file.getInputStream()) { + Files.copy(inputStream, persistentPath, StandardCopyOption.REPLACE_EXISTING); + } try { // Apply Basic/Advanced Logic @@ -191,7 +202,7 @@ public class QuoteSessionController { QuoteLineItem item = new QuoteLineItem(); item.setQuoteSession(session); item.setOriginalFilename(file.getOriginalFilename()); - item.setStoredPath(persistentPath.toString()); // SAVE PATH + item.setStoredPath(QUOTE_STORAGE_ROOT.relativize(persistentPath).toString()); // SAVE PATH (relative to root) item.setQuantity(1); item.setColorCode(selectedVariant.getColorName()); item.setFilamentVariant(selectedVariant); @@ -460,16 +471,54 @@ public class QuoteSessionController { return ResponseEntity.notFound().build(); } - Path path = Paths.get(item.getStoredPath()); - if (!Files.exists(path)) { + Path path = resolveStoredQuotePath(item.getStoredPath(), sessionId); + if (path == null || !Files.exists(path)) { return ResponseEntity.notFound().build(); } - org.springframework.core.io.Resource resource = new org.springframework.core.io.UrlResource(path.toUri()); + org.springframework.core.io.Resource resource = new UrlResource(path.toUri()); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + item.getOriginalFilename() + "\"") .body(resource); } + + private String getSafeExtension(String filename, String fallback) { + if (filename == null) { + return fallback; + } + String cleaned = StringUtils.cleanPath(filename); + if (cleaned.contains("..")) { + return fallback; + } + int index = cleaned.lastIndexOf('.'); + if (index <= 0 || index >= cleaned.length() - 1) { + return fallback; + } + String ext = cleaned.substring(index + 1).toLowerCase(Locale.ROOT); + return switch (ext) { + case "stl" -> "stl"; + case "3mf" -> "3mf"; + case "step", "stp" -> "step"; + default -> fallback; + }; + } + + private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) { + if (storedPath == null || storedPath.isBlank()) { + return null; + } + try { + Path raw = Path.of(storedPath).normalize(); + Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize(); + Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize(); + if (!resolved.startsWith(expectedSessionRoot)) { + return null; + } + return resolved; + } catch (InvalidPathException e) { + return null; + } + } }