fix(security): resolve 4 production blockers from final review
- IDOR (HIGH): DocumentController download/delete now verify document.clubId matches TenantContext; returns 403 on mismatch via new loadOwnedDocument() helper - Path Traversal (HIGH): DocumentService.sanitizeFilename() strips path components, removes control/reserved chars, caps at 200 chars, falls back to UUID. Applied to uploadDocument() and archiveProtocol() - JWT Dev Secret (HIGH): @PostConstruct guard in JwtService throws IllegalStateException if secret null/<32 chars/equals fail-loud marker. application.properties default replaced with CHANGE_ME_IN_PRODUCTION_THIS_WILL_FAIL_ON_STARTUP placeholder (env var CANNAMANAGE_SECURITY_JWT_SECRET set in docker-compose.yml; test profiles have their own valid secrets) - SecurityConfig (MEDIUM): explicit /api/v1/documents/** matcher with hasAnyRole(ADMIN, STAFF, MEMBER) for defense-in-depth Verified: Docker rebuild healthy, backend starts cleanly (JWT guard accepts env var), Playwright 203 pass (2 pre-existing login failures unrelated — dev compose profile has no seed users; admin@test.de only loaded via docker-compose.test.yml)
This commit is contained in:
+27
-2
@@ -1,14 +1,17 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.Document;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.DocumentAccessLevel;
|
||||
import de.cannamanage.domain.enums.DocumentCategory;
|
||||
import de.cannamanage.service.DocumentService;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.Principal;
|
||||
@@ -26,6 +29,21 @@ public class DocumentController {
|
||||
this.documentService = documentService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the requested document belongs to the caller's current tenant (club).
|
||||
* Prevents IDOR: a user from club A must not be able to download/delete a document of club B
|
||||
* just by guessing or enumerating the document UUID.
|
||||
*/
|
||||
private Document loadOwnedDocument(UUID documentId) {
|
||||
Document doc = documentService.getDocument(documentId);
|
||||
UUID currentTenantId = TenantContext.getCurrentTenant();
|
||||
if (currentTenantId == null || doc.getClubId() == null || !doc.getClubId().equals(currentTenantId)) {
|
||||
// Use 403 (not 404) — caller is authenticated, just not authorized for this resource.
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied to document");
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
@PostMapping("/documents/upload")
|
||||
public ResponseEntity<Document> uploadDocument(
|
||||
@RequestParam UUID clubId,
|
||||
@@ -51,7 +69,7 @@ public class DocumentController {
|
||||
|
||||
@GetMapping("/documents/{id}/download")
|
||||
public ResponseEntity<byte[]> downloadDocument(@PathVariable UUID id) throws IOException {
|
||||
Document doc = documentService.getDocument(id);
|
||||
Document doc = loadOwnedDocument(id);
|
||||
byte[] content = documentService.downloadDocument(id);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + doc.getFilename() + "\"")
|
||||
@@ -64,8 +82,15 @@ public class DocumentController {
|
||||
@PathVariable UUID id,
|
||||
@RequestParam UUID clubId,
|
||||
Principal principal) throws IOException {
|
||||
// Verify the document belongs to the caller's tenant before honouring the delete.
|
||||
// Also reject if the supplied clubId param disagrees with the authenticated tenant.
|
||||
Document doc = loadOwnedDocument(id);
|
||||
UUID currentTenantId = TenantContext.getCurrentTenant();
|
||||
if (!clubId.equals(currentTenantId)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Tenant mismatch");
|
||||
}
|
||||
UUID userId = UUID.fromString(principal.getName());
|
||||
documentService.deleteDocument(id, userId, clubId);
|
||||
documentService.deleteDocument(id, userId, doc.getClubId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.io.Decoders;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -29,6 +30,32 @@ public class JwtService {
|
||||
@Value("${cannamanage.security.jwt.refresh-token-expiry:2592000}")
|
||||
private long refreshTokenExpiry; // seconds (30 days)
|
||||
|
||||
/**
|
||||
* Sentinel value used in the application.properties default. If the runtime JWT secret
|
||||
* matches this string (or is missing/too short) the application must fail to start —
|
||||
* we never want a deployment to silently fall back to a publicly-known dev secret.
|
||||
*/
|
||||
static final String UNCONFIGURED_SECRET_MARKER = "CHANGE_ME_IN_PRODUCTION_THIS_WILL_FAIL_ON_STARTUP";
|
||||
|
||||
/**
|
||||
* Validate JWT secret on startup — fail fast if the deployment is missing a proper secret.
|
||||
* Runs after Spring property binding (@Value) so we see the effective value.
|
||||
*/
|
||||
@PostConstruct
|
||||
void validateSecret() {
|
||||
if (secretKey == null
|
||||
|| secretKey.isBlank()
|
||||
|| secretKey.length() < 32
|
||||
|| UNCONFIGURED_SECRET_MARKER.equals(secretKey)) {
|
||||
throw new IllegalStateException(
|
||||
"FATAL: JWT secret is not configured or uses the default dev placeholder. "
|
||||
+ "Set the CANNAMANAGE_SECURITY_JWT_SECRET environment variable "
|
||||
+ "(or cannamanage.security.jwt.secret property) to a base64-encoded "
|
||||
+ "256-bit (or larger) random key."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token for ADMIN/MEMBER roles (no permissions claim needed).
|
||||
*/
|
||||
|
||||
@@ -58,6 +58,10 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
|
||||
// Documents endpoint — explicit listing for defense-in-depth so it can
|
||||
// never accidentally end up in a permitAll() rule above. Per-document
|
||||
// tenant ownership is additionally enforced in DocumentController.
|
||||
.requestMatchers("/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
|
||||
@@ -5,7 +5,12 @@ spring.jpa.properties.hibernate.packagesToScan=de.cannamanage.domain.entity
|
||||
spring.flyway.enabled=false
|
||||
|
||||
# JWT Security
|
||||
cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI=
|
||||
# DO NOT ship a usable default secret. JwtService.validateSecret() detects the marker below
|
||||
# and refuses to start, forcing every deployment to provide a real base64-encoded 256-bit key
|
||||
# via the CANNAMANAGE_SECURITY_JWT_SECRET environment variable (or override property).
|
||||
# Test/integration profiles pin their own valid dev secret in application-test.properties /
|
||||
# application-integration.properties.
|
||||
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET:CHANGE_ME_IN_PRODUCTION_THIS_WILL_FAIL_ON_STARTUP}
|
||||
cannamanage.security.jwt.access-token-expiry=3600
|
||||
cannamanage.security.jwt.refresh-token-expiry=2592000
|
||||
|
||||
|
||||
Reference in New Issue
Block a user