From 3916f3ace62f6b43c9a14adb03cd5623d0f85416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 4 Mar 2026 14:45:09 +0100 Subject: [PATCH 1/2] feat(back-end and front-end) email for request --- backend/src/main/resources/application.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 3769350..db27cf5 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -43,7 +43,7 @@ app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}} app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true} app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local} app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:true} -app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:infog@3d-fab.ch} +app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch} app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true} app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200} From 6f47d02813438de78394d1ff398afe87c4c5c071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 4 Mar 2026 14:55:51 +0100 Subject: [PATCH 2/2] feat(back-end and front-end) email --- .../controller/OrderController.java | 70 +++++---- .../admin/AdminOrderController.java | 17 ++- .../event/OrderShippedEvent.java | 16 ++ .../event/listener/OrderEmailListener.java | 86 +++++++++++ .../templates/email/order-shipped.html | 110 ++++++++++++++ .../OrderControllerPrivacyTest.java | 140 ++++++++++++++++++ ...inOrderControllerStatusValidationTest.java | 7 +- security_best_practices_report.md | 123 +++++++++++++++ 8 files changed, 539 insertions(+), 30 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/event/OrderShippedEvent.java create mode 100644 backend/src/main/resources/templates/email/order-shipped.html create mode 100644 backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java create mode 100644 security_best_practices_report.md diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index 3dd4874..c8a2b5c 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -27,6 +27,7 @@ import java.util.UUID; import java.util.Map; import java.util.HashMap; import java.util.Base64; +import java.util.Set; import java.util.stream.Collectors; import java.net.URI; import java.util.Locale; @@ -36,6 +37,11 @@ import java.util.regex.Pattern; @RequestMapping("/api/orders") public class OrderController { private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); + private static final Set PERSONAL_DATA_REDACTED_STATUSES = Set.of( + "IN_PRODUCTION", + "SHIPPED", + "COMPLETED" + ); private final OrderService orderService; private final OrderRepository orderRepo; @@ -292,10 +298,13 @@ public class OrderController { dto.setPaymentMethod(p.getMethod()); }); - dto.setCustomerEmail(order.getCustomerEmail()); - dto.setCustomerPhone(order.getCustomerPhone()); + boolean redactPersonalData = shouldRedactPersonalData(order.getStatus()); + if (!redactPersonalData) { + dto.setCustomerEmail(order.getCustomerEmail()); + dto.setCustomerPhone(order.getCustomerPhone()); + dto.setBillingCustomerType(order.getBillingCustomerType()); + } dto.setPreferredLanguage(order.getPreferredLanguage()); - dto.setBillingCustomerType(order.getBillingCustomerType()); dto.setCurrency(order.getCurrency()); dto.setSetupCostChf(order.getSetupCostChf()); dto.setShippingCostChf(order.getShippingCostChf()); @@ -310,30 +319,32 @@ public class OrderController { dto.setCreatedAt(order.getCreatedAt()); dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); - AddressDto billing = new AddressDto(); - billing.setFirstName(order.getBillingFirstName()); - billing.setLastName(order.getBillingLastName()); - billing.setCompanyName(order.getBillingCompanyName()); - billing.setContactPerson(order.getBillingContactPerson()); - billing.setAddressLine1(order.getBillingAddressLine1()); - billing.setAddressLine2(order.getBillingAddressLine2()); - billing.setZip(order.getBillingZip()); - billing.setCity(order.getBillingCity()); - billing.setCountryCode(order.getBillingCountryCode()); - dto.setBillingAddress(billing); + if (!redactPersonalData) { + AddressDto billing = new AddressDto(); + billing.setFirstName(order.getBillingFirstName()); + billing.setLastName(order.getBillingLastName()); + billing.setCompanyName(order.getBillingCompanyName()); + billing.setContactPerson(order.getBillingContactPerson()); + billing.setAddressLine1(order.getBillingAddressLine1()); + billing.setAddressLine2(order.getBillingAddressLine2()); + billing.setZip(order.getBillingZip()); + billing.setCity(order.getBillingCity()); + billing.setCountryCode(order.getBillingCountryCode()); + dto.setBillingAddress(billing); - if (!order.getShippingSameAsBilling()) { - AddressDto shipping = new AddressDto(); - shipping.setFirstName(order.getShippingFirstName()); - shipping.setLastName(order.getShippingLastName()); - shipping.setCompanyName(order.getShippingCompanyName()); - shipping.setContactPerson(order.getShippingContactPerson()); - shipping.setAddressLine1(order.getShippingAddressLine1()); - shipping.setAddressLine2(order.getShippingAddressLine2()); - shipping.setZip(order.getShippingZip()); - shipping.setCity(order.getShippingCity()); - shipping.setCountryCode(order.getShippingCountryCode()); - dto.setShippingAddress(shipping); + if (!order.getShippingSameAsBilling()) { + AddressDto shipping = new AddressDto(); + shipping.setFirstName(order.getShippingFirstName()); + shipping.setLastName(order.getShippingLastName()); + shipping.setCompanyName(order.getShippingCompanyName()); + shipping.setContactPerson(order.getShippingContactPerson()); + shipping.setAddressLine1(order.getShippingAddressLine1()); + shipping.setAddressLine2(order.getShippingAddressLine2()); + shipping.setZip(order.getShippingZip()); + shipping.setCity(order.getShippingCity()); + shipping.setCountryCode(order.getShippingCountryCode()); + dto.setShippingAddress(shipping); + } } List itemDtos = items.stream().map(i -> { @@ -354,6 +365,13 @@ public class OrderController { return dto; } + private boolean shouldRedactPersonalData(String status) { + if (status == null || status.isBlank()) { + return false; + } + return PERSONAL_DATA_REDACTED_STATUSES.contains(status.trim().toUpperCase(Locale.ROOT)); + } + private String getDisplayOrderNumber(Order order) { String orderNumber = order.getOrderNumber(); if (orderNumber != null && !orderNumber.isBlank()) { diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java index 1deb013..9e8e134 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java @@ -8,6 +8,7 @@ import com.printcalculator.entity.Order; import com.printcalculator.entity.OrderItem; import com.printcalculator.entity.Payment; import com.printcalculator.entity.QuoteSession; +import com.printcalculator.event.OrderShippedEvent; import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.PaymentRepository; @@ -15,6 +16,7 @@ import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.PaymentService; import com.printcalculator.service.QrBillService; import com.printcalculator.service.StorageService; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.core.io.Resource; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; @@ -61,6 +63,7 @@ public class AdminOrderController { private final StorageService storageService; private final InvoicePdfRenderingService invoiceService; private final QrBillService qrBillService; + private final ApplicationEventPublisher eventPublisher; public AdminOrderController( OrderRepository orderRepo, @@ -69,7 +72,8 @@ public class AdminOrderController { PaymentService paymentService, StorageService storageService, InvoicePdfRenderingService invoiceService, - QrBillService qrBillService + QrBillService qrBillService, + ApplicationEventPublisher eventPublisher ) { this.orderRepo = orderRepo; this.orderItemRepo = orderItemRepo; @@ -78,6 +82,7 @@ public class AdminOrderController { this.storageService = storageService; this.invoiceService = invoiceService; this.qrBillService = qrBillService; + this.eventPublisher = eventPublisher; } @GetMapping @@ -124,10 +129,16 @@ public class AdminOrderController { "Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES) ); } + String previousStatus = order.getStatus(); order.setStatus(normalizedStatus); - orderRepo.save(order); + Order savedOrder = orderRepo.save(order); - return ResponseEntity.ok(toOrderDto(order)); + // Notify customer only on transition to SHIPPED. + if (!"SHIPPED".equals(previousStatus) && "SHIPPED".equals(normalizedStatus)) { + eventPublisher.publishEvent(new OrderShippedEvent(this, savedOrder)); + } + + return ResponseEntity.ok(toOrderDto(savedOrder)); } @GetMapping("/{orderId}/items/{orderItemId}/file") diff --git a/backend/src/main/java/com/printcalculator/event/OrderShippedEvent.java b/backend/src/main/java/com/printcalculator/event/OrderShippedEvent.java new file mode 100644 index 0000000..a6f540f --- /dev/null +++ b/backend/src/main/java/com/printcalculator/event/OrderShippedEvent.java @@ -0,0 +1,16 @@ +package com.printcalculator.event; + +import com.printcalculator.entity.Order; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class OrderShippedEvent extends ApplicationEvent { + + private final Order order; + + public OrderShippedEvent(Object source, Order order) { + super(source); + this.order = order; + } +} diff --git a/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java index 076b128..a1cfaa2 100644 --- a/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java +++ b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java @@ -4,6 +4,7 @@ import com.printcalculator.entity.Order; import com.printcalculator.entity.OrderItem; import com.printcalculator.entity.Payment; import com.printcalculator.event.OrderCreatedEvent; +import com.printcalculator.event.OrderShippedEvent; import com.printcalculator.event.PaymentConfirmedEvent; import com.printcalculator.event.PaymentReportedEvent; import com.printcalculator.repository.OrderItemRepository; @@ -95,6 +96,19 @@ public class OrderEmailListener { } } + @Async + @EventListener + public void handleOrderShippedEvent(OrderShippedEvent event) { + Order order = event.getOrder(); + log.info("Processing OrderShippedEvent for order id: {}", order.getId()); + + try { + sendOrderShippedEmail(order); + } catch (Exception e) { + log.error("Failed to send order shipped email for order id: {}", order.getId(), e); + } + } + private void sendCustomerConfirmationEmail(Order order) { String language = resolveLanguage(order.getPreferredLanguage()); String orderNumber = getDisplayOrderNumber(order); @@ -153,6 +167,21 @@ public class OrderEmailListener { ); } + private void sendOrderShippedEmail(Order order) { + String language = resolveLanguage(order.getPreferredLanguage()); + String orderNumber = getDisplayOrderNumber(order); + + Map templateData = buildBaseTemplateData(order, language); + String subject = applyOrderShippedTexts(templateData, language, orderNumber); + + emailNotificationService.sendEmail( + order.getCustomer().getEmail(), + subject, + "order-shipped", + templateData + ); + } + private void sendAdminNotificationEmail(Order order) { String orderNumber = getDisplayOrderNumber(order); Map templateData = buildBaseTemplateData(order, DEFAULT_LANGUAGE); @@ -381,6 +410,63 @@ public class OrderEmailListener { }; } + private String applyOrderShippedTexts(Map templateData, String language, String orderNumber) { + return switch (language) { + case "en" -> { + templateData.put("emailTitle", "Order Shipped"); + templateData.put("headlineText", "Your order #" + orderNumber + " has been shipped"); + templateData.put("greetingText", "Hi " + templateData.get("customerName") + ","); + templateData.put("introText", "Good news: your package has left our workshop and is on its way."); + templateData.put("statusText", "Current status: Shipped."); + templateData.put("orderDetailsCtaText", "View order status"); + templateData.put("supportText", "If you need assistance, reply to this email."); + templateData.put("footerText", "Automated message from 3D-Fab."); + templateData.put("labelOrderNumber", "Order number"); + templateData.put("labelTotal", "Total"); + yield "Your order has been shipped (Order #" + orderNumber + ") - 3D-Fab"; + } + case "de" -> { + templateData.put("emailTitle", "Bestellung versandt"); + templateData.put("headlineText", "Ihre Bestellung #" + orderNumber + " wurde versandt"); + templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ","); + templateData.put("introText", "Gute Nachricht: Ihr Paket hat unsere Werkstatt verlassen und ist unterwegs."); + templateData.put("statusText", "Aktueller Status: Versandt."); + templateData.put("orderDetailsCtaText", "Bestellstatus ansehen"); + templateData.put("supportText", "Wenn Sie Hilfe benoetigen, antworten Sie auf diese E-Mail."); + templateData.put("footerText", "Automatische Nachricht von 3D-Fab."); + templateData.put("labelOrderNumber", "Bestellnummer"); + templateData.put("labelTotal", "Gesamtbetrag"); + yield "Ihre Bestellung wurde versandt (Bestellung #" + orderNumber + ") - 3D-Fab"; + } + case "fr" -> { + templateData.put("emailTitle", "Commande expediee"); + templateData.put("headlineText", "Votre commande #" + orderNumber + " a ete expediee"); + templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ","); + templateData.put("introText", "Bonne nouvelle: votre colis a quitte notre atelier et est en route."); + templateData.put("statusText", "Statut actuel: Expediee."); + templateData.put("orderDetailsCtaText", "Voir le statut de la commande"); + templateData.put("supportText", "Si vous avez besoin d'aide, repondez a cet email."); + templateData.put("footerText", "Message automatique de 3D-Fab."); + templateData.put("labelOrderNumber", "Numero de commande"); + templateData.put("labelTotal", "Total"); + yield "Votre commande a ete expediee (Commande #" + orderNumber + ") - 3D-Fab"; + } + default -> { + templateData.put("emailTitle", "Ordine spedito"); + templateData.put("headlineText", "Il tuo ordine #" + orderNumber + " e' stato spedito"); + templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ","); + templateData.put("introText", "Buone notizie: il tuo pacco e' partito dal nostro laboratorio ed e' in viaggio."); + templateData.put("statusText", "Stato attuale: spedito."); + templateData.put("orderDetailsCtaText", "Visualizza stato ordine"); + templateData.put("supportText", "Se hai bisogno di assistenza, rispondi a questa email."); + templateData.put("footerText", "Messaggio automatico di 3D-Fab."); + templateData.put("labelOrderNumber", "Numero ordine"); + templateData.put("labelTotal", "Totale"); + yield "Il tuo ordine e' stato spedito (Ordine #" + orderNumber + ") - 3D-Fab"; + } + }; + } + private String getDisplayOrderNumber(Order order) { String orderNumber = order.getOrderNumber(); if (orderNumber != null && !orderNumber.isBlank()) { diff --git a/backend/src/main/resources/templates/email/order-shipped.html b/backend/src/main/resources/templates/email/order-shipped.html new file mode 100644 index 0000000..74f5aa7 --- /dev/null +++ b/backend/src/main/resources/templates/email/order-shipped.html @@ -0,0 +1,110 @@ + + + + + Order Shipped + + + +
+
+

Your order #00000000 has been shipped

+
+ +
+

Hi Customer,

+

Good news: your package is on its way.

+ +
+ Current status: Shipped. +
+ +
+ + + + + + + + + +
Order number00000000
TotalCHF 0.00
+
+ +

+ View order status: + https://example.com/en/co/00000000-0000-0000-0000-000000000000 +

+

If you need assistance, reply to this email.

+
+ + +
+ + diff --git a/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java b/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java new file mode 100644 index 0000000..b3d6665 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java @@ -0,0 +1,140 @@ +package com.printcalculator.controller; + +import com.printcalculator.dto.OrderDto; +import com.printcalculator.entity.Order; +import com.printcalculator.repository.CustomerRepository; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.service.InvoicePdfRenderingService; +import com.printcalculator.service.OrderService; +import com.printcalculator.service.PaymentService; +import com.printcalculator.service.QrBillService; +import com.printcalculator.service.StorageService; +import com.printcalculator.service.TwintPaymentService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderControllerPrivacyTest { + + @Mock + private OrderService orderService; + @Mock + private OrderRepository orderRepo; + @Mock + private OrderItemRepository orderItemRepo; + @Mock + private QuoteSessionRepository quoteSessionRepo; + @Mock + private QuoteLineItemRepository quoteLineItemRepo; + @Mock + private CustomerRepository customerRepo; + @Mock + private StorageService storageService; + @Mock + private InvoicePdfRenderingService invoiceService; + @Mock + private QrBillService qrBillService; + @Mock + private TwintPaymentService twintPaymentService; + @Mock + private PaymentService paymentService; + @Mock + private PaymentRepository paymentRepo; + + private OrderController controller; + + @BeforeEach + void setUp() { + controller = new OrderController( + orderService, + orderRepo, + orderItemRepo, + quoteSessionRepo, + quoteLineItemRepo, + customerRepo, + storageService, + invoiceService, + qrBillService, + twintPaymentService, + paymentService, + paymentRepo + ); + } + + @Test + void getOrder_pendingPayment_keepsPersonalData() { + UUID orderId = UUID.randomUUID(); + Order order = buildOrder(orderId, "PENDING_PAYMENT"); + + when(orderRepo.findById(orderId)).thenReturn(Optional.of(order)); + when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of()); + when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.empty()); + + ResponseEntity response = controller.getOrder(orderId); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals("customer@example.com", response.getBody().getCustomerEmail()); + assertEquals("+41790000000", response.getBody().getCustomerPhone()); + assertNotNull(response.getBody().getBillingAddress()); + } + + @Test + void getOrder_advancedStatuses_redactsPersonalData() { + List statuses = List.of("IN_PRODUCTION", "SHIPPED", "COMPLETED"); + + for (String status : statuses) { + UUID orderId = UUID.randomUUID(); + Order order = buildOrder(orderId, status); + + when(orderRepo.findById(orderId)).thenReturn(Optional.of(order)); + when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of()); + when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.empty()); + + ResponseEntity response = controller.getOrder(orderId); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertNull(response.getBody().getCustomerEmail()); + assertNull(response.getBody().getCustomerPhone()); + assertNull(response.getBody().getBillingCustomerType()); + assertNull(response.getBody().getBillingAddress()); + assertNull(response.getBody().getShippingAddress()); + } + } + + private Order buildOrder(UUID orderId, String status) { + Order order = new Order(); + order.setId(orderId); + order.setStatus(status); + order.setCustomerEmail("customer@example.com"); + order.setCustomerPhone("+41790000000"); + order.setBillingCustomerType("PRIVATE"); + order.setBillingFirstName("Joe"); + order.setBillingLastName("Kung"); + order.setBillingAddressLine1("Via G. Pioda 1"); + order.setBillingZip("6900"); + order.setBillingCity("Lugano"); + order.setBillingCountryCode("CH"); + order.setShippingSameAsBilling(true); + return order; + } +} diff --git a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java index ee5e96a..88701dd 100644 --- a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java +++ b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.server.ResponseStatusException; @@ -47,6 +48,8 @@ class AdminOrderControllerStatusValidationTest { private InvoicePdfRenderingService invoicePdfRenderingService; @Mock private QrBillService qrBillService; + @Mock + private ApplicationEventPublisher eventPublisher; private AdminOrderController controller; @@ -59,7 +62,8 @@ class AdminOrderControllerStatusValidationTest { paymentService, storageService, invoicePdfRenderingService, - qrBillService + qrBillService, + eventPublisher ); } @@ -92,6 +96,7 @@ class AdminOrderControllerStatusValidationTest { order.setStatus("PENDING_PAYMENT"); when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(orderItemRepository.findByOrder_Id(orderId)).thenReturn(List.of()); when(paymentRepository.findByOrder_Id(orderId)).thenReturn(Optional.empty()); diff --git a/security_best_practices_report.md b/security_best_practices_report.md new file mode 100644 index 0000000..f90ecd5 --- /dev/null +++ b/security_best_practices_report.md @@ -0,0 +1,123 @@ +# Security Best Practices Report + +## Executive summary + +Revisione sicurezza del progetto `print-calculator` (backend Spring Boot Java + frontend Angular/TypeScript) con focus su autenticazione/autorizzazione, esposizione dati, upload file, hardening e resilienza. + +Risultato: **6 finding** totali. + +- **Critical**: 1 +- **High**: 3 +- **Medium**: 2 + +Rischio principale: API pubbliche basate su UUID senza controllo di ownership/token, che consentono lettura PII e azioni di business su ordini. + +## Scope e metodo + +- Codice analizzato: backend (`backend/src/main/java`, `backend/src/main/resources`), frontend (`frontend/src/app`), config deploy (`deploy/`, `docker-compose*.yml`). +- Riferimenti skill usati: `javascript-general-web-frontend-security.md`. +- Nota: non è presente un riferimento specifico Java/Spring nel set dello skill; per il backend sono state applicate best practice consolidate Spring/security engineering. + +## Critical findings + +### SBP-001 - Broken access control su API ordine pubbliche (lettura PII + azioni stato) + +- **Severity**: Critical +- **Impatto**: Chiunque ottenga un `orderId` può leggere dati personali ordine e invocare operazioni di business senza autenticazione. +- **Evidenze**: + - `backend/src/main/java/com/printcalculator/config/SecurityConfig.java:36` (`.anyRequest().permitAll()`). + - `backend/src/main/java/com/printcalculator/controller/OrderController.java:131` (`GET /api/orders/{orderId}`). + - `backend/src/main/java/com/printcalculator/controller/OrderController.java:141` (`POST /api/orders/{orderId}/payments/report`). + - `backend/src/main/java/com/printcalculator/controller/OrderController.java:93` (`POST /api/orders/{orderId}/items/{orderItemId}/file`). + - `backend/src/main/java/com/printcalculator/controller/OrderController.java:295`-`337` (PII completa nel DTO: email, telefono, indirizzi billing/shipping). + - `backend/src/main/java/com/printcalculator/service/PaymentService.java:53`-`75` (cambio stato pagamento a `REPORTED`). +- **Rischio tecnico**: + - Assenza di autenticazione/authorization applicativa su endpoint ordine. + - Modello “capability by UUID” senza token secondario, expiry o binding utente. +- **Fix raccomandato**: + - Introdurre un `order_access_token` random ad alta entropia (>=128 bit), memorizzato hashato e richiesto sugli endpoint pubblici ordine. + - Separare endpoint pubblici (minimo set dati) da endpoint interni/admin. + - Rimuovere `orderItemId` e dettagli sensibili dal DTO pubblico, o usare URL firmate a scadenza per upload/download. + - Valutare auth customer leggera (magic link OTP) per consultazione/modifica ordine. + +## High findings + +### SBP-002 - Esposizione PII su endpoint pubblico custom quote request + +- **Severity**: High +- **Evidenze**: + - `backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java:188`-`193` (`GET /api/custom-quote-requests/{id}` senza auth). + - `backend/src/main/java/com/printcalculator/entity/CustomQuoteRequest.java:24`-`40` (campi PII e messaggio cliente). + - `backend/src/main/java/com/printcalculator/config/SecurityConfig.java:36` (`.anyRequest().permitAll()`). +- **Rischio tecnico**: + - Endpoint “lookup by UUID” ritorna oggetto completo con dati personali. +- **Fix raccomandato**: + - Proteggere endpoint con token di accesso separato per richiesta (non solo UUID). + - Restituire una vista redatta/minimale per endpoint pubblici. + - Se endpoint non usato dal frontend, rimuoverlo. + +### SBP-003 - Antivirus in fail-open + default scanner disattivato + +- **Severity**: High +- **Evidenze**: + - `backend/src/main/resources/application.properties:27` (`clamav.enabled=${CLAMAV_ENABLED:false}`). + - `backend/src/main/java/com/printcalculator/service/ClamAVService.java:42`-`43` (scanner disabilitato => ritorna `true`). + - `backend/src/main/java/com/printcalculator/service/ClamAVService.java:54`-`61` (errori scanner => `FAIL-OPEN`). + - `backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java:59`-`60` (eccezioni scanner ignorate, file mantenuto). +- **Rischio tecnico**: + - File malevoli possono essere accettati quando scanner è down/non configurato. +- **Fix raccomandato**: + - Policy fail-closed in ambienti non-dev (`reject on scan error`). + - Rendere `CLAMAV_ENABLED=true` default in deploy runtime e bloccare startup se scanner richiesto ma non raggiungibile. + - Telemetria/alerting su scan bypass e failure rate. + +### SBP-004 - Endpoint costosi esposti senza throttling/rate limit (DoS applicativo) + +- **Severity**: High +- **Evidenze**: + - `backend/src/main/java/com/printcalculator/config/SecurityConfig.java:36` (endpoint pubblici permessi globalmente). + - `backend/src/main/java/com/printcalculator/controller/QuoteController.java:38`-`39` (`POST /api/quote` pubblico). + - `backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java:114`-`120` (`POST /api/quote-sessions/{id}/line-items` pubblico). + - `backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java:228`-`235` (invocazione slicing). + - `backend/src/main/java/com/printcalculator/service/SlicerService.java:156`-`163` (job fino a 5 minuti). +- **Rischio tecnico**: + - Upload/slicing massivo può saturare CPU, I/O e worker thread. +- **Fix raccomandato**: + - Rate limiting per IP/fingerprint/session (anche lato reverse proxy). + - Coda asincrona con limiti di concorrenza e timeout più stretti. + - Quote per utente/sessione e limite richieste per finestra temporale. + - CAPTCHA o proof-of-work per endpoint anonimi ad alto costo. + +## Medium findings + +### SBP-005 - Secret/default credenziali deboli nel codice di configurazione + +- **Severity**: Medium +- **Evidenze**: + - `backend/src/main/resources/application.properties:7` (`DB_PASSWORD` fallback `printcalc_secret`). + - `backend/src/main/resources/application-local.properties:7`-`8` (admin password/secret hardcoded per profilo local). +- **Rischio tecnico**: + - In caso di misconfigurazione ambientale o uso improprio profilo, vengono usati valori prevedibili. +- **Fix raccomandato**: + - Rimuovere fallback sensibili e rendere obbligatori i secret a startup. + - Spostare credenziali locali in file non versionato (`.env.local`, `.gitignore`) con template placeholder. + - Policy di secret rotation periodica. + +### SBP-006 - CSRF disabilitato globalmente con autenticazione admin basata su cookie + +- **Severity**: Medium +- **Evidenze**: + - `backend/src/main/java/com/printcalculator/config/SecurityConfig.java:24` (CSRF disabilitato globalmente). + - `backend/src/main/java/com/printcalculator/security/AdminSessionService.java:129`-`136` (cookie sessione admin). + - `backend/src/main/java/com/printcalculator/security/AdminSessionService.java:133`-`134` (`Secure` + `SameSite=Strict` presenti, mitigazione parziale). +- **Rischio tecnico**: + - Con auth cookie-based, la protezione CSRF andrebbe mantenuta sugli endpoint state-changing admin; `SameSite=Strict` riduce ma non elimina tutti i vettori. +- **Fix raccomandato**: + - Riabilitare CSRF almeno su `/api/admin/**` e usare token CSRF (double-submit o synchronizer token). + - Mantenere `SameSite=Strict` come difesa aggiuntiva. + +## Note e assunzioni + +- Alcuni endpoint pubblici sembrano progettati come flusso anonimo customer; il finding resta valido perché manca una seconda prova di possesso oltre all’UUID. +- Non è stata eseguita una DAST esterna o pentest black-box; analisi effettuata su codice statico e configurazioni nel repository. +