fix(security): hardening — rate limiting, CORS config, audit safety, CSP headers, validation
Deploy to Production / test (push) Failing after 10m44s
Deploy to Production / deploy (push) Has been skipped

- Fix 1: Login rate limiting (5 attempts/min/IP) on POST /api/v1/auth/login
  - New LoginRateLimiter (ConcurrentHashMap + @Scheduled reset every 60s)
  - HTTP 429 with German message on exceed
  - Client IP via X-Forwarded-For with proxy fallback
  - @EnableScheduling on CannaManageApplication

- Fix 2: CORS origins configurable via cannamanage.cors.allowed-origins env var
  - Defaults to localhost + docker frontend for dev
  - SecurityConfig reads with @Value, splits comma-separated list

- Fix 3: Audit JSON safety — replaced manual string concat with Jackson ObjectMapper
  - New AuditService.toMetadataJson(Map) helper
  - RetentionService and AuthorityExportService refactored

- Fix 4: Tomcat max-http-form-post-size=2MB prevents DoS via oversized payloads

- Fix 5: @Valid added to @RequestBody on 17+ endpoints across
  ComplianceRecordsController, FinanceController, ConsentController,
  StaffController, ComplianceDeadlineController, SubscriptionController,
  ForumController (admin + portal)

- Fix 6: Content-Security-Policy 'default-src \'self\'; frame-ancestors \'none\''
  + frameOptions(deny) on both API + portal filter chains
This commit is contained in:
Patrick Plate
2026-06-15 19:29:32 +02:00
parent 6319552675
commit 6f7352124d
16 changed files with 459 additions and 28 deletions
@@ -1,5 +1,7 @@
package de.cannamanage.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.cannamanage.domain.entity.AuditEvent;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.AuditEventType;
@@ -13,6 +15,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
@@ -25,6 +28,9 @@ public class AuditService {
private static final Logger log = LoggerFactory.getLogger(AuditService.class);
/** Shared ObjectMapper for safe JSON serialization of audit metadata. */
private static final ObjectMapper METADATA_MAPPER = new ObjectMapper();
private final AuditEventRepository auditEventRepository;
private final PdfReportGenerator pdfReportGenerator;
@@ -33,6 +39,23 @@ public class AuditService {
this.pdfReportGenerator = pdfReportGenerator;
}
/**
* Serializes the given metadata map to JSON safely (proper escaping of quotes,
* newlines, unicode). Returns {@code null} for a null/empty input and falls
* back to {@code "{}"} if serialization fails (never throws).
*/
public static String toMetadataJson(Map<String, ?> metadata) {
if (metadata == null || metadata.isEmpty()) {
return null;
}
try {
return METADATA_MAPPER.writeValueAsString(metadata);
} catch (JsonProcessingException e) {
log.warn("Failed to serialize audit metadata; storing empty object instead: {}", e.getMessage());
return "{}";
}
}
/**
* Writes an immutable audit event. Once persisted, it cannot be modified or deleted.
*/
@@ -170,7 +170,7 @@ public class RetentionService {
"SYSTEM",
"RETENTION_SERVICE",
"KCanG §24: Member data anonymized after 5-year retention period",
"{\"membershipNumber\":\"" + member.getMembershipNumber() + "\"}",
AuditService.toMetadataJson(Map.of("membershipNumber", member.getMembershipNumber())),
null
);
@@ -6,6 +6,7 @@ import de.cannamanage.domain.enums.AuditEventType;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.AuditService;
import java.util.Map;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.MemberRepository;
import org.slf4j.Logger;
@@ -101,7 +102,7 @@ public class AuthorityExportService {
"Club", clubId, userId,
"System", "ADMIN",
"Behörden-Export generiert. Grund: " + reason + ". Jahr: " + year,
"{\"year\":" + year + ",\"reason\":\"" + escapeJson(reason) + "\"}",
AuditService.toMetadataJson(Map.of("year", year, "reason", reason)),
null
);