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:
@@ -56,9 +56,12 @@ public class DocumentService {
|
||||
throw new IllegalArgumentException("File type not allowed. Allowed: PDF, DOCX, XLSX, PNG, JPG");
|
||||
}
|
||||
|
||||
// Generate storage path
|
||||
// Generate storage path — sanitize the original filename to prevent path traversal
|
||||
// (e.g. "../../etc/passwd") and control-character / illegal-character injection into the
|
||||
// on-disk path. We only keep the basename and strip everything that could break out of
|
||||
// the per-club directory or confuse the OS filesystem.
|
||||
UUID documentId = UUID.randomUUID();
|
||||
String filename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "document";
|
||||
String filename = sanitizeFilename(file.getOriginalFilename());
|
||||
String storagePath = clubId + "/" + documentId + "_" + filename;
|
||||
Path fullPath = Paths.get(UPLOAD_BASE, storagePath);
|
||||
|
||||
@@ -148,7 +151,10 @@ public class DocumentService {
|
||||
@Transactional
|
||||
public UUID archiveProtocol(UUID clubId, String title, byte[] pdfBytes, UUID uploadedBy) {
|
||||
UUID documentId = UUID.randomUUID();
|
||||
String filename = title.replaceAll("[^a-zA-Z0-9äöüÄÖÜß\\s\\-]", "") + ".pdf";
|
||||
// Same sanitization invariants as user-uploaded files: no path separators, no control
|
||||
// chars, reasonable length, never empty.
|
||||
String safeTitle = title == null ? "" : title.replaceAll("[^a-zA-Z0-9äöüÄÖÜß\\s\\-]", "");
|
||||
String filename = sanitizeFilename(safeTitle + ".pdf");
|
||||
String storagePath = clubId + "/" + documentId + "_" + filename;
|
||||
Path fullPath = Paths.get(UPLOAD_BASE, storagePath);
|
||||
|
||||
@@ -180,4 +186,36 @@ public class DocumentService {
|
||||
log.info("Protocol archived: {} for club {}", title, clubId);
|
||||
return documentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a user-supplied filename for safe use in storage paths.
|
||||
* - Strips any directory components (defeats "../../etc/passwd" style traversal)
|
||||
* - Removes control characters and characters reserved on common filesystems
|
||||
* - Limits length to keep the resulting path well under filesystem limits
|
||||
* - Falls back to a UUID-based name when input is null/blank or sanitization empties it
|
||||
*/
|
||||
private String sanitizeFilename(String original) {
|
||||
if (original == null || original.isBlank()) {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
// Strip path components — keep only the basename
|
||||
String name;
|
||||
try {
|
||||
name = Paths.get(original).getFileName().toString();
|
||||
} catch (RuntimeException e) {
|
||||
// Invalid path on this platform — fall back to a random name
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
// Remove control characters and path-/shell-/Windows-reserved characters
|
||||
name = name.replaceAll("[\\x00-\\x1F\\x7F/\\\\:*?\"<>|]", "_");
|
||||
// Limit length (filesystems usually cap individual segments at 255 bytes)
|
||||
if (name.length() > 200) {
|
||||
name = name.substring(0, 200);
|
||||
}
|
||||
// Ensure not empty after sanitization
|
||||
if (name.isBlank() || ".".equals(name) || "..".equals(name)) {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user