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
@@ -4,6 +4,7 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* CannaManage Spring Boot application entry point.
@@ -17,6 +18,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication(scanBasePackages = "de.cannamanage")
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
@EntityScan(basePackages = "de.cannamanage.domain.entity")
@EnableScheduling
public class CannaManageApplication {
public static void main(String[] args) {
@@ -4,11 +4,14 @@ import de.cannamanage.api.dto.auth.LoginRequest;
import de.cannamanage.api.dto.auth.LoginResponse;
import de.cannamanage.api.dto.auth.RefreshRequest;
import de.cannamanage.api.dto.auth.SetPasswordRequest;
import de.cannamanage.api.security.LoginRateLimiter;
import de.cannamanage.api.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -24,10 +27,19 @@ import java.util.Map;
public class AuthController {
private final AuthService authService;
private final LoginRateLimiter loginRateLimiter;
@PostMapping("/login")
@Operation(summary = "Login with email + password", description = "Returns JWT access + refresh tokens")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) {
String ip = resolveClientIp(httpRequest);
if (!loginRateLimiter.tryAcquire(ip)) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of(
"error", "rate_limited",
"message", "Zu viele Anmeldeversuche. Bitte warten Sie eine Minute."
));
}
LoginResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
@@ -46,4 +58,17 @@ public class AuthController {
authService.setPassword(request);
return ResponseEntity.ok(Map.of("message", "Password set successfully. You can now log in."));
}
/**
* Returns the originating client IP, honouring X-Forwarded-For when present
* (so reverse-proxy / load-balancer setups still get per-client rate limits).
*/
private String resolveClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
int comma = xff.indexOf(',');
return (comma > 0 ? xff.substring(0, comma) : xff).trim();
}
return request.getRemoteAddr();
}
}
@@ -6,6 +6,7 @@ import de.cannamanage.domain.enums.ComplianceArea;
import de.cannamanage.service.repository.ComplianceDeadlineRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -39,7 +40,7 @@ public class ComplianceDeadlineController {
@PostMapping
@Operation(summary = "Create a new compliance deadline")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<ComplianceDeadline> createDeadline(@RequestBody CreateDeadlineRequest request) {
public ResponseEntity<ComplianceDeadline> createDeadline(@Valid @RequestBody CreateDeadlineRequest request) {
ComplianceDeadline deadline = new ComplianceDeadline();
deadline.setClubId(request.clubId());
deadline.setArea(request.area());
@@ -57,7 +58,7 @@ public class ComplianceDeadlineController {
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<ComplianceDeadline> completeDeadline(
@PathVariable UUID id,
@RequestBody CompleteDeadlineRequest request) {
@Valid @RequestBody CompleteDeadlineRequest request) {
ComplianceDeadline deadline = deadlineRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Deadline not found: " + id));
@@ -6,6 +6,7 @@ import de.cannamanage.domain.enums.TransportStatus;
import de.cannamanage.service.repository.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -37,7 +38,7 @@ public class ComplianceRecordsController {
@PostMapping("/destruction-records")
@Operation(summary = "Record a cannabis destruction event")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<DestructionRecord> recordDestruction(@RequestBody CreateDestructionRequest request) {
public ResponseEntity<DestructionRecord> recordDestruction(@Valid @RequestBody CreateDestructionRequest request) {
DestructionRecord record = new DestructionRecord();
record.setClubId(request.clubId());
record.setBatchId(request.batchId());
@@ -65,7 +66,7 @@ public class ComplianceRecordsController {
@PostMapping("/transport-records")
@Operation(summary = "Record a cannabis transport event")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<TransportRecord> recordTransport(@RequestBody CreateTransportRequest request) {
public ResponseEntity<TransportRecord> recordTransport(@Valid @RequestBody CreateTransportRequest request) {
TransportRecord record = new TransportRecord();
record.setClubId(request.clubId());
record.setDescription(request.description());
@@ -94,7 +95,7 @@ public class ComplianceRecordsController {
@PostMapping("/propagation-sources")
@Operation(summary = "Record a propagation source (seed/cutting receipt)")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<PropagationSource> recordPropagationSource(@RequestBody CreatePropagationSourceRequest request) {
public ResponseEntity<PropagationSource> recordPropagationSource(@Valid @RequestBody CreatePropagationSourceRequest request) {
PropagationSource record = new PropagationSource();
record.setClubId(request.clubId());
record.setSourceType(request.sourceType());
@@ -121,7 +122,7 @@ public class ComplianceRecordsController {
@PostMapping("/prevention-activities")
@Operation(summary = "Record a prevention/education activity per KCanG §23")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<PreventionActivity> recordPreventionActivity(@RequestBody CreatePreventionActivityRequest request) {
public ResponseEntity<PreventionActivity> recordPreventionActivity(@Valid @RequestBody CreatePreventionActivityRequest request) {
PreventionActivity record = new PreventionActivity();
record.setClubId(request.clubId());
record.setActivityDate(request.activityDate());
@@ -9,6 +9,7 @@ import de.cannamanage.service.repository.UserRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@@ -43,7 +44,7 @@ public class ConsentController {
@PostMapping
@Operation(summary = "Grant consent")
public ResponseEntity<ConsentResponse> grantConsent(
@RequestBody GrantConsentRequest request,
@Valid @RequestBody GrantConsentRequest request,
Authentication auth,
HttpServletRequest httpRequest) {
UUID userId = resolveUserId(auth);
@@ -81,7 +81,7 @@ public class FinanceController {
@PutMapping("/finance/fee-schedules/{id}")
public ResponseEntity<FeeSchedule> updateFeeSchedule(@PathVariable UUID id,
@RequestBody UpdateFeeScheduleRequest request,
@Valid @RequestBody UpdateFeeScheduleRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
FeeSchedule updated = financeService.updateFeeSchedule(
@@ -3,6 +3,7 @@ package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.*;
import de.cannamanage.service.ForumService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -26,7 +27,7 @@ public class ForumController {
// ---- Admin Topic Endpoints ----
@PostMapping("/forum/topics")
public ResponseEntity<ForumTopic> createTopic(@RequestBody CreateTopicRequest request,
public ResponseEntity<ForumTopic> createTopic(@Valid @RequestBody CreateTopicRequest request,
@RequestHeader("X-Club-Id") UUID clubId,
@RequestHeader("X-User-Id") UUID userId) {
ForumTopic topic = forumService.createTopic(clubId, request.title(), request.content(), userId);
@@ -90,7 +91,7 @@ public class ForumController {
@PostMapping("/forum/topics/{topicId}/replies")
public ResponseEntity<ForumReply> createReply(@PathVariable UUID topicId,
@RequestBody CreateReplyRequest request,
@Valid @RequestBody CreateReplyRequest request,
@RequestHeader("X-User-Id") UUID userId) {
ForumReply reply = forumService.createReply(topicId, request.content(), userId);
return ResponseEntity.ok(reply);
@@ -98,7 +99,7 @@ public class ForumController {
@PutMapping("/forum/replies/{id}")
public ResponseEntity<ForumReply> editReply(@PathVariable UUID id,
@RequestBody CreateReplyRequest request,
@Valid @RequestBody CreateReplyRequest request,
@RequestHeader("X-User-Id") UUID userId) {
ForumReply reply = forumService.editReply(id, request.content(), userId);
return ResponseEntity.ok(reply);
@@ -114,7 +115,7 @@ public class ForumController {
// ---- Reaction Endpoints ----
@PostMapping("/forum/reactions")
public ResponseEntity<Map<String, Object>> toggleReaction(@RequestBody ReactionRequest request,
public ResponseEntity<Map<String, Object>> toggleReaction(@Valid @RequestBody ReactionRequest request,
@RequestHeader("X-User-Id") UUID userId) {
var result = forumService.toggleReaction(
request.targetType(), request.targetId(), userId, request.reactionType());
@@ -125,7 +126,7 @@ public class ForumController {
// ---- Report Endpoints ----
@PostMapping("/forum/reports")
public ResponseEntity<Map<String, String>> reportContent(@RequestBody ReportRequest request,
public ResponseEntity<Map<String, String>> reportContent(@Valid @RequestBody ReportRequest request,
@RequestHeader("X-Club-Id") UUID clubId,
@RequestHeader("X-User-Id") UUID userId) {
forumService.reportContent(clubId, request.targetType(), request.targetId(), userId, request.reason());
@@ -147,7 +148,7 @@ public class ForumController {
@PostMapping("/forum/reports/{id}/review")
public ResponseEntity<ForumReport> reviewReport(@PathVariable UUID id,
@RequestBody ReviewReportRequest request,
@Valid @RequestBody ReviewReportRequest request,
@RequestHeader("X-User-Id") UUID userId) {
ForumReport report = forumService.reviewReport(id, userId, request.status());
return ResponseEntity.ok(report);
@@ -156,7 +157,7 @@ public class ForumController {
// ---- Portal Endpoints (member-scoped, same logic) ----
@PostMapping("/portal/forum/topics")
public ResponseEntity<ForumTopic> portalCreateTopic(@RequestBody CreateTopicRequest request,
public ResponseEntity<ForumTopic> portalCreateTopic(@Valid @RequestBody CreateTopicRequest request,
@RequestHeader("X-Club-Id") UUID clubId,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.createTopic(clubId, request.title(), request.content(), userId));
@@ -185,20 +186,20 @@ public class ForumController {
@PostMapping("/portal/forum/topics/{topicId}/replies")
public ResponseEntity<ForumReply> portalCreateReply(@PathVariable UUID topicId,
@RequestBody CreateReplyRequest request,
@Valid @RequestBody CreateReplyRequest request,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.createReply(topicId, request.content(), userId));
}
@PutMapping("/portal/forum/replies/{id}")
public ResponseEntity<ForumReply> portalEditReply(@PathVariable UUID id,
@RequestBody CreateReplyRequest request,
@Valid @RequestBody CreateReplyRequest request,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.editReply(id, request.content(), userId));
}
@PostMapping("/portal/forum/reactions")
public ResponseEntity<Map<String, Object>> portalToggleReaction(@RequestBody ReactionRequest request,
public ResponseEntity<Map<String, Object>> portalToggleReaction(@Valid @RequestBody ReactionRequest request,
@RequestHeader("X-User-Id") UUID userId) {
var result = forumService.toggleReaction(
request.targetType(), request.targetId(), userId, request.reactionType());
@@ -206,7 +207,7 @@ public class ForumController {
}
@PostMapping("/portal/forum/reports")
public ResponseEntity<Map<String, String>> portalReportContent(@RequestBody ReportRequest request,
public ResponseEntity<Map<String, String>> portalReportContent(@Valid @RequestBody ReportRequest request,
@RequestHeader("X-Club-Id") UUID clubId,
@RequestHeader("X-User-Id") UUID userId) {
forumService.reportContent(clubId, request.targetType(), request.targetId(), userId, request.reason());
@@ -83,7 +83,7 @@ public class StaffController {
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Update staff permissions/profile (revokes tokens on permission change)")
public ResponseEntity<StaffResponse> updateStaff(@PathVariable UUID id,
@RequestBody UpdateStaffRequest request) {
@Valid @RequestBody UpdateStaffRequest request) {
UUID tenantId = TenantContext.getCurrentTenant();
StaffAccount staff = staffService.updateStaff(
tenantId, id,
@@ -10,6 +10,7 @@ import de.cannamanage.service.StripeService;
import de.cannamanage.service.repository.ClubRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
@@ -46,7 +47,7 @@ public class SubscriptionController {
@PostMapping("/checkout")
@Operation(summary = "Create checkout session", description = "Creates a Stripe Checkout session for plan upgrade")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Map<String, String>> createCheckout(@RequestBody CheckoutRequest request) throws StripeException {
public ResponseEntity<Map<String, String>> createCheckout(@Valid @RequestBody CheckoutRequest request) throws StripeException {
UUID tenantId = TenantContext.getCurrentTenant();
UUID clubId = clubRepository.findByTenantId(tenantId)
.orElseThrow(() -> new IllegalStateException("No club for tenant"))
@@ -0,0 +1,60 @@
package de.cannamanage.api.security;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Simple in-memory brute-force protection for the login endpoint.
*
* <p>Tracks attempts per source IP and rejects further attempts once the
* configured threshold ({@link #MAX_ATTEMPTS_PER_WINDOW}) is exceeded within
* the current 60-second window. Counters are reset every minute by
* {@link #resetCounters()}.
*
* <p>This deliberately stays in-memory rather than introducing Resilience4j /
* Bucket4j for a single endpoint. For multi-instance deployments behind a
* load balancer this should be revisited.
*/
@Slf4j
@Component
public class LoginRateLimiter {
/** Maximum failed/total login attempts allowed per IP per window. */
public static final int MAX_ATTEMPTS_PER_WINDOW = 5;
private final ConcurrentHashMap<String, AtomicInteger> attemptsByIp = new ConcurrentHashMap<>();
/**
* Records an attempt and returns {@code true} if the request is allowed
* (still within the per-window quota), {@code false} if it must be
* rejected with HTTP 429.
*/
public boolean tryAcquire(String ipAddress) {
if (ipAddress == null || ipAddress.isBlank()) {
ipAddress = "unknown";
}
AtomicInteger counter = attemptsByIp.computeIfAbsent(ipAddress, k -> new AtomicInteger(0));
int current = counter.incrementAndGet();
if (current > MAX_ATTEMPTS_PER_WINDOW) {
log.warn("Login rate limit exceeded for IP {} ({} attempts in current window)", ipAddress, current);
return false;
}
return true;
}
/**
* Resets all counters every 60 seconds. Fixed-rate scheduler keeps the
* implementation predictable and free of timestamp bookkeeping.
*/
@Scheduled(fixedRate = 60_000L)
public void resetCounters() {
if (!attemptsByIp.isEmpty()) {
log.debug("Resetting login rate-limit counters for {} IPs", attemptsByIp.size());
attemptsByIp.clear();
}
}
}
@@ -19,6 +19,7 @@ import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.List;
/**
@@ -34,6 +35,14 @@ public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final PortalUserDetailsService portalUserDetailsService;
/**
* Comma-separated allowed CORS origins. Defaults to local dev origins; production
* deployments override via the {@code CORS_ORIGINS} environment variable
* (e.g. {@code https://cannamanage.plate-software.de}).
*/
@Value("${cannamanage.cors.allowed-origins:http://localhost:3000,http://frontend:3000}")
private String allowedOrigins;
/**
* API security — stateless JWT authentication.
* URL-level role checks provide first layer; @PreAuthorize provides fine-grained.
@@ -47,6 +56,10 @@ public class SecurityConfig {
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; frame-ancestors 'none'"))
.frameOptions(frame -> frame.deny()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/webhooks/**").permitAll()
@@ -82,6 +95,10 @@ public class SecurityConfig {
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1))
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; frame-ancestors 'none'"))
.frameOptions(frame -> frame.deny()))
.userDetailsService(portalUserDetailsService)
.formLogin(form -> form
.loginProcessingUrl("/portal/login")
@@ -132,10 +149,11 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"http://localhost:3000",
"http://frontend:3000"
));
List<String> origins = Arrays.stream(allowedOrigins.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
config.setAllowedOrigins(origins);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
@@ -53,3 +53,9 @@ server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=6MB
# Security hardening — limit non-multipart request body sizes to prevent DoS via oversized payloads
server.tomcat.max-http-form-post-size=2MB
# CORS allowed origins (comma-separated). Override via CORS_ORIGINS env var in production.
cannamanage.cors.allowed-origins=${CORS_ORIGINS:http://localhost:3000,http://frontend:3000}