Compare commits

...
Author SHA1 Message Date
Anthony Stirling 40d180d665 Merge main into fix_ex_20260720 2026-08-21 07:29:57 +01:00
Ludy 8a9c5baceb Merge branch 'main' into fix_ex_20260720 2026-08-16 11:52:15 +02:00
Ludy87 1ab7cc3374 Update PolicyControllerTest.java 2026-08-11 12:04:18 +02:00
Ludy e461cfbd9b Merge branch 'main' into fix_ex_20260720 2026-08-11 11:15:25 +02:00
Ludy d8964c7322 Merge branch 'main' into fix_ex_20260720 2026-07-23 08:50:56 +02:00
Ludy87 803dadf38e Update RequestUriUtils.java 2026-07-22 11:12:19 +02:00
Ludy87 5c86ab2dae Fix transaction cleanup, controller autowiring, and test
1) WorkflowParticipantController: annotate Lombok @RequiredArgsConstructor with @Autowired to ensure Spring constructor injection.
2) WorkflowFinalizationCoordinator: only register TransactionSynchronization when TransactionSynchronizationManager.isSynchronizationActive(); otherwise call deleteOriginalFile immediately to support unit-test/in-process flows and avoid synchronization errors.
3) ParticipantRequestSecurityFilterTest: replace multipartRequest usage with a MockHttpServletRequest that overrides getContentLengthLong, sets Content-Type and remote address so oversized mobile uploads are rejected before multipart parsing.
2026-07-22 10:45:46 +02:00
Ludy87 f06d34fbe5 Support optional session repository in participant controller
Add a test-focused constructor that allows WorkflowParticipantController to be instantiated without a session repository, enabling focused unit tests that don't need session locking. Add null checks before accessing sessionRepository in token validation paths.

Also fix test setup to use addHeader() instead of setContentLengthLong() for proper Content-Length header mocking.
2026-07-22 10:08:56 +02:00
Ludy 349280cd4b Merge branch 'main' into fix_ex_20260720 2026-07-22 09:57:34 +02:00
Ludy87 a61af74590 Security: harden path matching and enforce size limits
This commit hardens security across multiple areas:

- Replaced startsWith() path checks with exact-match-or-child logic via new matchesPathOrChild() helper to prevent lookalike path attacks (e.g., /oauth2-admin bypassing /oauth2 checks)
- Added MAX_SIGNATURE_SIZE_BYTES validation in SharedSignatureService
- Added MAX_MOBILE_UPLOAD_REQUEST_SIZE_BYTES (101 MiB) limit for mobile scanner uploads
- Enhanced ParticipantRequestSecurityFilter with URL decoding support to detect encoded path traversal attempts
- Added session expiration checks in MobileScannerService
- Fixed SignatureController @PreAuthorize directives to explicitly check isAuthenticated()
- Added pessimistic locking (PESSIMISTIC_WRITE) to WorkflowSessionRepository for participant token queries to prevent race conditions
- Added @Transactional annotations to participant endpoints for atomicity
- Deferred original file deletion in WorkflowFinalizationCoordinator until DB transaction commits to prevent orphaned references
- Added test coverage for lookalike auth paths and oversized mobile uploads
2026-07-22 09:56:09 +02:00
Ludy 3553aced94 Merge branch 'main' into fix_ex_20260720 2026-07-21 23:59:27 +02:00
Ludy87 d7f5ad4a02 Merge branch 'fix_ex_20260720' of https://github.com/Stirling-Tools/Stirling-PDF into fix_ex_20260720 2026-07-20 18:48:40 +02:00
Ludy87 1a15f2256c Format ParticipantRequestSecurityFilter
Split long field declaration across multiple lines for better code formatting compliance.
2026-07-20 18:48:27 +02:00
Ludy 53a9271fd7 Merge branch 'main' into fix_ex_20260720 2026-07-20 18:48:25 +02:00
Ludy87 0749f5a7bf Use record for rate limit window tracking
Replace `long[]` array with a typed `RequestWindow` record for storing request counts and window start times in the rate limiter. This improves code clarity and type safety, making the intent of each field explicit and eliminating array index magic numbers.
2026-07-20 18:28:44 +02:00
Ludy c3c3e6e7eb Merge branch 'main' into fix_ex_20260720 2026-07-20 16:55:07 +02:00
Ludy 7fa53d7c31 Merge branch 'main' into fix_ex_20260720 2026-07-20 16:06:23 +02:00
Ludy87 dd738efbe6 Update RequestUriUtilsTest.java 2026-07-20 16:06:02 +02:00
Ludy c37340dd33 Merge branch 'main' into fix_ex_20260720 2026-07-20 15:43:06 +02:00
Ludy87 b8ad494d66 Update RequestUriUtils.java 2026-07-20 15:42:25 +02:00
Ludy87 ff194d741b Fix HTTP methods for database API endpoints
Changed database endpoints from GET to appropriate HTTP verbs:
- Create backup: GET → POST
- Import backup: GET → POST
- Delete backup: GET → DELETE

These operations modify state and should not use GET requests. Updated frontend API calls to match.
2026-07-20 11:59:28 +02:00
Ludy87 749d4c6829 Add certificate file size validation test
Test that CertificateFileUtils.read() rejects files where actual content size exceeds declared size, returning 413 Payload Too Large. This validates the security check against deceptive file uploads.
2026-07-20 11:26:47 +02:00
Ludy87 4c95d854e4 Secure certificate handling and validation
Centralizes certificate file size validation into a new CertificateFileUtils class to prevent oversized files from being read into memory. Updates certificate signing and validation controllers to use this utility for bounded reads with 5 MiB limit.

Improves security by generating high-entropy random passwords for user keystores instead of predictable passwords derived from user IDs.

Consolidates duplicate credential file validation logic in WorkflowUploadUtils to use the new common utility. Adds tests verifying oversized certificate rejection before reading and validates random password generation.
2026-07-20 11:24:54 +02:00
Ludy87 36d58484a9 Refactor workflow upload validation and enhance security
Extract shared credential upload validation into WorkflowUploadUtils utility class to reduce duplication across ParticipantController and SessionService. Expand ParticipantRequestSecurityFilter to protect authenticated certificate validation endpoints with pattern-based path matching. Add workflow session state validation (active/inactive) and participant expiration checks. Normalize request path handling with trailing slash stripping. Update error messages from participant-specific to workflow-scoped terminology.
2026-07-20 11:13:04 +02:00
Ludy87 eadf6700e2 Clean up Java imports and fix code formatting
Simplify ConfigController by importing @Autowired and using the short form instead of fully-qualified names. Fix indentation alignment in MobileScannerController JavaDoc string concatenations.
2026-07-20 10:43:19 +02:00
Ludy87 e817a5cdf6 Replace HttpStatus.PAYLOAD_TOO_LARGE with CONTENT_TOO_LARGE
Spring Boot 4.x deprecated PAYLOAD_TOO_LARGE in favor of CONTENT_TOO_LARGE HTTP status. Updated all occurrences across core and proprietary modules, including corresponding test assertions. Also fixed comment formatting to break long lines into proper continuation strings.
2026-07-20 10:35:27 +02:00
Ludy87 af99dcb38a Prevent modifications to inactive workflow sessions
Add validation to block participant actions and status updates on completed or cancelled workflows. When a workflow session is no longer active, participants can still view it but cannot modify their status or take actions like declining. Includes parameterized tests covering both COMPLETED and CANCELLED states.
2026-07-20 10:31:37 +02:00
Ludy87 a1f20206d0 Harden participant endpoint security with filter
Replace HandlerInterceptor rate limiter with servlet-level OncePerRequestFilter to validate requests before multipart parsing. Adds multipart request size limits (16 MiB) and certificate file size limits (5 MiB) at the filter layer, preventing resource exhaustion attacks. Refactor certificate handling to read files once and reuse bytes for both validation and encryption. Add comprehensive tests for oversized uploads, missing content-length, and multiple certificate file rejection.
2026-07-20 10:20:50 +02:00
Ludy87 0533a8e7fb Extract invite acceptance into service
Move invite token consumption and user creation into a dedicated service with pessimistic write locking to prevent race conditions when multiple users attempt to accept the same invite simultaneously. This improves separation of concerns, testability, and guarantees atomicity of the acceptance operation within a transaction.
2026-07-20 09:58:10 +02:00
Ludy87 6e4c11fef2 Security hardening 2026-07-20 09:43:06 +02:00
44 changed files with 1885 additions and 425 deletions
@@ -25,6 +25,12 @@ import lombok.extern.slf4j.Slf4j;
public class MobileScannerService {
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
private static final int MAX_ACTIVE_SESSIONS = 100;
private static final int MAX_FILES_PER_SESSION = 20;
private static final int MAX_UPLOADS_PER_SESSION = 30;
private static final long MAX_FILE_SIZE_BYTES = 25L * 1024 * 1024;
private static final long MAX_SESSION_STORAGE_BYTES = 100L * 1024 * 1024;
private static final long MAX_TOTAL_STORAGE_BYTES = 500L * 1024 * 1024;
private static final Pattern FILENAME_SANITIZE_PATTERN = Pattern.compile("[^a-zA-Z0-9._-]");
private static final Pattern SESSION_ID_VALIDATION_PATTERN = Pattern.compile("[a-zA-Z0-9-]+");
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
@@ -45,18 +51,22 @@ public class MobileScannerService {
* @param sessionId Unique session identifier
* @return SessionInfo with creation time and expiry
*/
public SessionInfo createSession(String sessionId) {
public synchronized SessionInfo createSession(String sessionId) {
validateSessionId(sessionId);
SessionData existingSession = activeSessions.get(sessionId);
if (existingSession != null) {
return toSessionInfo(existingSession);
}
if (activeSessions.size() >= MAX_ACTIVE_SESSIONS) {
throw new SessionLimitExceededException("Too many active mobile scanner sessions");
}
SessionData session = new SessionData(sessionId);
activeSessions.put(sessionId, session);
log.info("Created mobile scanner session: {}", sessionId);
return new SessionInfo(
sessionId,
session.createdAt,
session.createdAt + SESSION_TIMEOUT_MS,
SESSION_TIMEOUT_MS);
return toSessionInfo(session);
}
/**
@@ -65,7 +75,7 @@ public class MobileScannerService {
* @param sessionId Session identifier to validate
* @return SessionInfo if valid, null if invalid/expired
*/
public SessionInfo validateSession(String sessionId) {
public synchronized SessionInfo validateSession(String sessionId) {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
return null;
@@ -91,21 +101,29 @@ public class MobileScannerService {
* @param files Files to upload
* @throws IOException If file storage fails
*/
public void uploadFiles(String sessionId, List<MultipartFile> files) throws IOException {
public synchronized void uploadFiles(String sessionId, List<MultipartFile> files)
throws IOException {
validateSessionId(sessionId);
SessionData session =
activeSessions.computeIfAbsent(sessionId, id -> new SessionData(sessionId));
SessionData session = activeSessions.get(sessionId);
if (session == null) {
throw new SessionNotFoundException("Session not found or expired: " + sessionId);
}
if (System.currentTimeMillis() - session.getLastAccessTime() > SESSION_TIMEOUT_MS) {
deleteSession(sessionId);
throw new SessionNotFoundException("Session not found or expired: " + sessionId);
}
List<MultipartFile> nonEmptyFiles = files.stream().filter(file -> !file.isEmpty()).toList();
session.recordUploadAttempt();
validateUploadLimits(session, nonEmptyFiles);
// Create session directory
Path sessionDir = getSafeSessionDirectory(sessionId);
Files.createDirectories(sessionDir);
// Save each file
for (MultipartFile file : files) {
if (file.isEmpty()) {
continue;
}
for (MultipartFile file : nonEmptyFiles) {
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || originalFilename.isBlank()) {
@@ -156,11 +174,15 @@ public class MobileScannerService {
* @param sessionId Session identifier
* @return List of file metadata, or empty list if session doesn't exist
*/
public List<FileMetadata> getSessionFiles(String sessionId) {
public synchronized List<FileMetadata> getSessionFiles(String sessionId) {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
return List.of();
}
if (isExpired(session)) {
deleteSession(sessionId);
return List.of();
}
session.updateLastAccess();
return new ArrayList<>(session.getFiles());
}
@@ -173,11 +195,15 @@ public class MobileScannerService {
* @return File path
* @throws IOException If file not found or session doesn't exist
*/
public Path getFile(String sessionId, String filename) throws IOException {
public synchronized Path getFile(String sessionId, String filename) throws IOException {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
throw new IOException("Session not found: " + sessionId);
}
if (isExpired(session)) {
deleteSession(sessionId);
throw new IOException("Session expired: " + sessionId);
}
Path filePath = getSafeFilePath(sessionId, filename);
if (!Files.exists(filePath)) {
@@ -196,7 +222,7 @@ public class MobileScannerService {
* @param sessionId Session identifier
* @param filename Filename to delete
*/
public void deleteFileAfterDownload(String sessionId, String filename) {
public synchronized void deleteFileAfterDownload(String sessionId, String filename) {
try {
Path filePath = getSafeFilePath(sessionId, filename);
Files.deleteIfExists(filePath);
@@ -218,7 +244,7 @@ public class MobileScannerService {
*
* @param sessionId Session to delete
*/
public void deleteSession(String sessionId) {
public synchronized void deleteSession(String sessionId) {
SessionData session = activeSessions.remove(sessionId);
if (session != null) {
try {
@@ -255,7 +281,7 @@ public class MobileScannerService {
/** Scheduled cleanup of expired sessions (runs every 5 minutes) */
@Scheduled(fixedRate = 5 * 60 * 1000)
public void cleanupExpiredSessions() {
public synchronized void cleanupExpiredSessions() {
long now = System.currentTimeMillis();
List<String> expiredSessions = new ArrayList<>();
@@ -282,6 +308,51 @@ public class MobileScannerService {
}
}
private SessionInfo toSessionInfo(SessionData session) {
return new SessionInfo(
session.sessionId,
session.createdAt,
session.getLastAccessTime() + SESSION_TIMEOUT_MS,
SESSION_TIMEOUT_MS);
}
private void validateUploadLimits(SessionData session, List<MultipartFile> files)
throws UploadSizeLimitExceededException {
if (session.getFiles().size() + files.size() > MAX_FILES_PER_SESSION) {
throw new UploadSizeLimitExceededException("Too many files in scanner session");
}
long uploadSize = 0;
for (MultipartFile file : files) {
if (file.getSize() > MAX_FILE_SIZE_BYTES) {
throw new UploadSizeLimitExceededException(
"Mobile scanner file exceeds the maximum size");
}
try {
uploadSize = Math.addExact(uploadSize, file.getSize());
} catch (ArithmeticException e) {
throw new UploadSizeLimitExceededException("Mobile scanner upload is too large");
}
}
if (session.getStoredBytes() + uploadSize > MAX_SESSION_STORAGE_BYTES) {
throw new UploadSizeLimitExceededException(
"Mobile scanner session storage quota exceeded");
}
if (getTotalStoredBytes() + uploadSize > MAX_TOTAL_STORAGE_BYTES) {
throw new StorageCapacityExceededException(
"Mobile scanner temporary storage quota exceeded");
}
}
private long getTotalStoredBytes() {
return activeSessions.values().stream().mapToLong(SessionData::getStoredBytes).sum();
}
private boolean isExpired(SessionData session) {
return System.currentTimeMillis() - session.getLastAccessTime() > SESSION_TIMEOUT_MS;
}
private String sanitizeFilename(String filename) {
// Remove path traversal attempts and dangerous characters
String sanitized = FILENAME_SANITIZE_PATTERN.matcher(filename).replaceAll("_");
@@ -401,6 +472,36 @@ public class MobileScannerService {
}
}
public static class SessionNotFoundException extends IOException {
public SessionNotFoundException(String message) {
super(message);
}
}
public static class UploadSizeLimitExceededException extends IOException {
public UploadSizeLimitExceededException(String message) {
super(message);
}
}
public static class StorageCapacityExceededException extends UploadSizeLimitExceededException {
public StorageCapacityExceededException(String message) {
super(message);
}
}
public static class UploadRateLimitExceededException extends IOException {
public UploadRateLimitExceededException(String message) {
super(message);
}
}
public static class SessionLimitExceededException extends IllegalStateException {
public SessionLimitExceededException(String message) {
super(message);
}
}
/** Session data tracking */
private static class SessionData {
private final String sessionId;
@@ -408,6 +509,8 @@ public class MobileScannerService {
private final Map<String, Boolean> downloadedFiles = new HashMap<>();
private final long createdAt;
private long lastAccessTime;
private int uploadAttempts;
private long storedBytes;
public SessionData(String sessionId) {
this.sessionId = sessionId;
@@ -418,6 +521,7 @@ public class MobileScannerService {
public void addFile(FileMetadata file) {
files.add(file);
downloadedFiles.put(file.getFilename(), false);
storedBytes += file.getSize();
}
public List<FileMetadata> getFiles() {
@@ -440,5 +544,17 @@ public class MobileScannerService {
public long getLastAccessTime() {
return lastAccessTime;
}
public long getStoredBytes() {
return storedBytes;
}
public void recordUploadAttempt() throws UploadRateLimitExceededException {
if (uploadAttempts >= MAX_UPLOADS_PER_SESSION) {
throw new UploadRateLimitExceededException(
"Mobile scanner upload rate limit exceeded");
}
uploadAttempts++;
}
}
}
@@ -0,0 +1,40 @@
package stirling.software.common.util;
import java.io.IOException;
import org.springframework.http.HttpStatus;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
/** Size validation and bounded reads for certificate credentials supplied as multipart files. */
public final class CertificateFileUtils {
public static final long MAX_CERTIFICATE_FILE_SIZE_BYTES = 5L * 1024 * 1024;
private CertificateFileUtils() {}
public static void validateSize(MultipartFile file) {
if (file != null && !file.isEmpty() && file.getSize() > MAX_CERTIFICATE_FILE_SIZE_BYTES) {
throw certificateFileTooLarge();
}
}
public static byte[] read(MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) {
return null;
}
validateSize(file);
byte[] bytes = file.getBytes();
if (bytes.length > MAX_CERTIFICATE_FILE_SIZE_BYTES) {
throw certificateFileTooLarge();
}
return bytes;
}
private static ResponseStatusException certificateFileTooLarge() {
return new ResponseStatusException(
HttpStatus.CONTENT_TOO_LARGE,
"Certificate credential file exceeds the 5 MiB limit");
}
}
@@ -19,7 +19,7 @@ public class RequestUriUtils {
// API routes are never static except for the public status endpoint
if (normalizedUri.startsWith("/api/")) {
return normalizedUri.startsWith("/api/v1/info/status");
return matchesPathOrChild(normalizedUri, "/api/v1/info/status");
}
// Well-known static asset directories (backend + React build artifacts)
@@ -69,7 +69,7 @@ public class RequestUriUtils {
// cookie, so the server can't authenticate the navigation itself). The
// portal gates access via its own auth gate + RequirePortalAccess, and its
// data APIs stay protected, so serving the shell pre-auth is safe.
if (normalizedUri.equals("/processor") || normalizedUri.startsWith("/processor/")) {
if ("/processor".equals(normalizedUri) || normalizedUri.startsWith("/processor/")) {
return true;
}
@@ -180,32 +180,33 @@ public class RequestUriUtils {
: requestURI;
// Public auth endpoints that don't require authentication
return trimmedUri.startsWith("/login")
return matchesPathOrChild(trimmedUri, "/login")
|| trimmedUri.startsWith("/auth/")
|| trimmedUri.startsWith("/oauth2")
|| trimmedUri.startsWith("/saml2")
|| matchesPathOrChild(trimmedUri, "/oauth2")
|| matchesPathOrChild(trimmedUri, "/saml2")
|| trimmedUri.contains("/login/oauth2/code/") // Spring Security OAuth2 callback
|| trimmedUri.contains("/oauth2/authorization/") // OAuth2 authorization endpoint
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
|| "/api/v1/auth/login".equals(trimmedUri)
|| "/api/v1/auth/refresh".equals(trimmedUri)
|| "/api/v1/auth/logout".equals(trimmedUri)
|| "/api/v1/proprietary/ui-data/login"
.equals(trimmedUri) // Login page config (SSO providers +
// enableLogin)
|| trimmedUri.startsWith(
"/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
|| matchesPathOrChild(trimmedUri, "/api/v1/invite/validate")
|| matchesPathOrChild(trimmedUri, "/api/v1/invite/accept")
// Health Endpoints
|| trimmedUri.startsWith("/actuator/health")
|| trimmedUri.startsWith("/health")
|| trimmedUri.startsWith("/healthz")
|| trimmedUri.startsWith("/liveness")
|| trimmedUri.startsWith("/readiness")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|| matchesPathOrChild(trimmedUri, "/actuator/health")
|| matchesPathOrChild(trimmedUri, "/health")
|| matchesPathOrChild(trimmedUri, "/healthz")
|| matchesPathOrChild(trimmedUri, "/liveness")
|| matchesPathOrChild(trimmedUri, "/readiness")
// Mobile scanner sessions are intentionally public so desktop and phone clients
// can exchange files without a login round-trip.
|| trimmedUri.startsWith("/api/v1/mobile-scanner/")
|| trimmedUri.startsWith("/api/v1/webhooks/")
|| trimmedUri.startsWith("/v1/api-docs")
|| matchesPathOrChild(trimmedUri, "/v1/api-docs")
// Workflow participant endpoints - access controlled by share tokens, not login
|| trimmedUri.startsWith("/api/v1/workflow/participant/")
// Share-link SPA bootstrap; data APIs remain protected
@@ -218,4 +219,9 @@ public class RequestUriUtils {
}
return requestURI;
}
/** Matches one endpoint exactly, or a legitimate path-variable/sub-resource below it. */
private static boolean matchesPathOrChild(String requestUri, String endpoint) {
return requestUri.equals(endpoint) || requestUri.startsWith(endpoint + "/");
}
}
@@ -47,6 +47,15 @@ class MobileScannerServiceTest {
return new MockMultipartFile("file", name, "text/plain", new byte[0]);
}
private MultipartFile fileWithReportedSize(String name, long reportedSize) {
return new MockMultipartFile("file", name, "text/plain", new byte[] {1}) {
@Override
public long getSize() {
return reportedSize;
}
};
}
@Nested
@DisplayName("createSession")
class CreateSession {
@@ -95,6 +104,18 @@ class MobileScannerServiceTest {
void acceptsValidChars() {
assertNotNull(service.createSession("ABC-def-123"));
}
@Test
@DisplayName("limits the number of active sessions")
void limitsActiveSessions() {
for (int index = 0; index < 100; index++) {
service.createSession("session-" + index);
}
assertThrows(
MobileScannerService.SessionLimitExceededException.class,
() -> service.createSession("one-too-many"));
}
}
@Nested
@@ -156,12 +177,14 @@ class MobileScannerServiceTest {
}
@Test
@DisplayName("auto-creates a session when uploading to an unregistered session ID")
void autoCreatesSession() throws IOException {
service.uploadFiles("new-session", List.of(file("a.txt", "data")));
@DisplayName("rejects uploads to an unregistered session ID")
void rejectsUnknownSession() {
assertThrows(
MobileScannerService.SessionNotFoundException.class,
() -> service.uploadFiles("new-session", List.of(file("a.txt", "data"))));
List<FileMetadata> metas = service.getSessionFiles("new-session");
assertEquals(1, metas.size());
assertTrue(service.getSessionFiles("new-session").isEmpty());
assertFalse(Files.exists(tempDir.resolve("new-session")));
}
@Test
@@ -236,6 +259,56 @@ class MobileScannerServiceTest {
assertTrue(service.getSessionFiles("up6").isEmpty());
}
@Test
@DisplayName("limits upload attempts per session")
void limitsUploadAttempts() throws IOException {
service.createSession("limited");
for (int attempt = 0; attempt < 30; attempt++) {
service.uploadFiles("limited", List.of());
}
assertThrows(
MobileScannerService.UploadRateLimitExceededException.class,
() -> service.uploadFiles("limited", List.of()));
}
@Test
@DisplayName("limits the number of files per session")
void limitsFilesPerSession() throws IOException {
service.createSession("many-files");
for (int index = 0; index < 20; index++) {
service.uploadFiles("many-files", List.of(file("file-" + index, "x")));
}
assertThrows(
MobileScannerService.UploadSizeLimitExceededException.class,
() -> service.uploadFiles("many-files", List.of(file("one-too-many", "x"))));
}
@Test
@DisplayName("enforces the global temporary storage quota")
void enforcesGlobalStorageQuota() throws IOException {
long twentyFiveMiB = 25L * 1024 * 1024;
for (int sessionIndex = 0; sessionIndex < 5; sessionIndex++) {
String sessionId = "quota-" + sessionIndex;
service.createSession(sessionId);
for (int fileIndex = 0; fileIndex < 4; fileIndex++) {
service.uploadFiles(
sessionId,
List.of(
fileWithReportedSize(
"file-" + fileIndex + ".jpg", twentyFiveMiB)));
}
}
service.createSession("over-quota");
assertThrows(
MobileScannerService.StorageCapacityExceededException.class,
() ->
service.uploadFiles(
"over-quota", List.of(fileWithReportedSize("extra.jpg", 1))));
}
}
@Nested
@@ -0,0 +1,38 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
class CertificateFileUtilsTest {
@Test
void rejectsActualContentLargerThanDeclaredSize() throws Exception {
MultipartFile deceptiveFile = mock(MultipartFile.class);
when(deceptiveFile.isEmpty()).thenReturn(false);
when(deceptiveFile.getSize()).thenReturn(1L);
when(deceptiveFile.getBytes())
.thenReturn(
new byte
[Math.toIntExact(
CertificateFileUtils
.MAX_CERTIFICATE_FILE_SIZE_BYTES)
+ 1]);
assertThatThrownBy(() -> CertificateFileUtils.read(deceptiveFile))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
error ->
org.assertj.core.api.Assertions.assertThat(
((ResponseStatusException) error)
.getStatusCode()
.value())
.isEqualTo(413));
verify(deceptiveFile).getBytes();
}
}
@@ -183,6 +183,38 @@ class RequestUriUtilsTest {
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/convert", ""));
}
@Test
void testIsPublicAuthEndpoint_rejectsLookalikeAuthPaths() {
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/auth/login-admin", ""));
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/auth/refresh-token", ""));
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/invite/acceptance", ""));
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/actuator/healthz-extra", ""));
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/oauth2-admin", ""));
assertFalse(RequestUriUtils.isStaticResource("/api/v1/info/status-details"));
}
@Test
void testIsPublicAuthEndpoint_allMobileScannerRoutesArePublic() {
assertTrue(
RequestUriUtils.isPublicAuthEndpoint(
"/api/v1/mobile-scanner/validate-session/session-id", ""));
assertTrue(
RequestUriUtils.isPublicAuthEndpoint(
"/api/v1/mobile-scanner/upload/session-id", ""));
assertTrue(
RequestUriUtils.isPublicAuthEndpoint(
"/api/v1/mobile-scanner/create-session/session-id", ""));
assertTrue(
RequestUriUtils.isPublicAuthEndpoint(
"/api/v1/mobile-scanner/files/session-id", ""));
assertTrue(
RequestUriUtils.isPublicAuthEndpoint(
"/api/v1/mobile-scanner/download/session-id/file.jpg", ""));
assertTrue(
RequestUriUtils.isPublicAuthEndpoint(
"/api/v1/mobile-scanner/session/session-id", ""));
}
@Test
void testIsPublicAuthEndpoint_webhookReceiver() {
// The webhook source receiver authenticates each delivery by HMAC signature, not a session.
@@ -5,6 +5,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@@ -46,13 +47,10 @@ public class ConfigController {
ApplicationProperties applicationProperties,
ApplicationContext applicationContext,
EndpointConfiguration endpointConfiguration,
@org.springframework.beans.factory.annotation.Autowired(required = false)
ServerCertificateServiceInterface serverCertificateService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
UserServiceInterface userService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
ShowAdminInterface showAdmin,
@org.springframework.beans.factory.annotation.Autowired(required = false)
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
@Autowired(required = false) UserServiceInterface userService,
@Autowired(required = false) ShowAdminInterface showAdmin,
@Autowired(required = false)
stirling.software.common.service.LicenseServiceInterface licenseService,
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
this.applicationProperties = applicationProperties;
@@ -34,6 +34,11 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.MobileScannerService;
import stirling.software.common.service.MobileScannerService.FileMetadata;
import stirling.software.common.service.MobileScannerService.SessionLimitExceededException;
import stirling.software.common.service.MobileScannerService.SessionNotFoundException;
import stirling.software.common.service.MobileScannerService.StorageCapacityExceededException;
import stirling.software.common.service.MobileScannerService.UploadRateLimitExceededException;
import stirling.software.common.service.MobileScannerService.UploadSizeLimitExceededException;
/**
* REST controller for mobile scanner functionality. Allows mobile devices to upload scanned images
@@ -45,8 +50,8 @@ import stirling.software.common.service.MobileScannerService.FileMetadata;
@Tag(
name = "Mobile Scanner",
description =
"Endpoints for mobile-to-desktop file transfer via QR code scanning. "
+ "Files are temporarily stored and automatically cleaned up after 10 minutes.")
"Endpoints for mobile-to-desktop file transfer via QR code scanning. Files are"
+ " temporarily stored and automatically cleaned up after 10 minutes.")
@Hidden
@Slf4j
public class MobileScannerController {
@@ -125,6 +130,10 @@ public class MobileScannerController {
} catch (IllegalArgumentException e) {
log.warn("Invalid session creation request: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} catch (SessionLimitExceededException e) {
log.warn("Mobile scanner session limit reached");
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of("error", e.getMessage()));
}
}
@@ -219,6 +228,18 @@ public class MobileScannerController {
} catch (IllegalArgumentException e) {
log.warn("Invalid mobile scanner upload request: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} catch (SessionNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", e.getMessage()));
} catch (UploadRateLimitExceededException e) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of("error", e.getMessage()));
} catch (StorageCapacityExceededException e) {
return ResponseEntity.status(HttpStatus.INSUFFICIENT_STORAGE)
.body(Map.of("error", e.getMessage()));
} catch (UploadSizeLimitExceededException e) {
return ResponseEntity.status(HttpStatus.CONTENT_TOO_LARGE)
.body(Map.of("error", e.getMessage()));
} catch (IOException e) {
log.error("Failed to upload files for session: {}", sessionId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
@@ -271,7 +292,8 @@ public class MobileScannerController {
@Operation(
summary = "Download a specific file",
description =
"Download a file that was uploaded to a session. File is automatically deleted after download.")
"Download a file that was uploaded to a session. File is automatically deleted"
+ " after download.")
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
@ApiResponse(responseCode = "404", description = "File or session not found")
@@ -83,6 +83,7 @@ import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.util.CertificateFileUtils;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
@@ -204,6 +205,11 @@ public class CertSignController {
"certificate type");
}
CertificateFileUtils.validateSize(privateKeyFile);
CertificateFileUtils.validateSize(certFile);
CertificateFileUtils.validateSize(p12File);
CertificateFileUtils.validateSize(jksfile);
KeyStore ks = null;
String keystorePassword = password;
Provider signingProvider = null;
@@ -219,8 +225,10 @@ public class CertSignController {
certFile, "PEM certificate", "certificate file is required");
ks = KeyStore.getInstance("JKS");
ks.load(null);
PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyFile.getBytes(), password);
Certificate cert = (Certificate) getCertificateFromPEM(certFile.getBytes());
byte[] privateKeyBytes = CertificateFileUtils.read(privateKeyFile);
byte[] certificateBytes = CertificateFileUtils.read(certFile);
PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyBytes, password);
Certificate cert = (Certificate) getCertificateFromPEM(certificateBytes);
ks.setKeyEntry(
"alias", privateKey, password.toCharArray(), new Certificate[] {cert});
break;
@@ -230,14 +238,18 @@ public class CertSignController {
validateFilePresent(
p12File, "PKCS12 keystore", "PKCS12/PFX keystore file is required");
ks = KeyStore.getInstance("PKCS12");
ks.load(p12File.getInputStream(), password.toCharArray());
ks.load(
new ByteArrayInputStream(CertificateFileUtils.read(p12File)),
password.toCharArray());
break;
case "JKS":
jksfile =
validateFilePresent(
jksfile, "JKS keystore", "JKS keystore file is required");
ks = KeyStore.getInstance("JKS");
ks.load(jksfile.getInputStream(), password.toCharArray());
ks.load(
new ByteArrayInputStream(CertificateFileUtils.read(jksfile)),
password.toCharArray());
break;
case "SERVER":
if (serverCertificateService == null) {
@@ -49,6 +49,7 @@ import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CertificateFileUtils;
import stirling.software.common.util.ExceptionUtils;
@Slf4j
@@ -93,8 +94,8 @@ public class ValidateSignatureController {
// Load custom certificate if provided
X509Certificate customCert = null;
if (request.getCertFile() != null && !request.getCertFile().isEmpty()) {
try (ByteArrayInputStream certStream =
new ByteArrayInputStream(request.getCertFile().getBytes())) {
byte[] certificateBytes = CertificateFileUtils.read(request.getCertFile());
try (ByteArrayInputStream certStream = new ByteArrayInputStream(certificateBytes)) {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
customCert = (X509Certificate) cf.generateCertificate(certStream);
} catch (CertificateException e) {
@@ -30,6 +30,7 @@ public class SharedSignatureService {
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
private final String SIGNATURE_BASE_PATH;
private static final String ALL_USERS_FOLDER = "ALL_USERS";
private static final long MAX_SIGNATURE_SIZE_BYTES = 2_000_000;
private final ObjectMapper objectMapper;
public SharedSignatureService(ObjectMapper objectMapper) {
@@ -157,10 +158,17 @@ public class SharedSignatureService {
// Extract and save image data
String dataUrl = request.getDataUrl();
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
if (dataUrl.length() > MAX_SIGNATURE_SIZE_BYTES * 2) {
throw new IllegalArgumentException("Signature data too large");
}
// Extract base64 data
String base64Data = dataUrl.substring(dataUrl.indexOf(',') + 1);
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
if (imageBytes.length > MAX_SIGNATURE_SIZE_BYTES) {
throw new IllegalArgumentException("Signature image too large");
}
// Determine and validate file extension from data URL
String mimeType = dataUrl.substring(dataUrl.indexOf(':') + 1, dataUrl.indexOf(';'));
String rawExtension = mimeType.substring(mimeType.indexOf('/') + 1);
@@ -24,6 +24,9 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.MobileScannerService;
import stirling.software.common.service.MobileScannerService.FileMetadata;
import stirling.software.common.service.MobileScannerService.SessionInfo;
import stirling.software.common.service.MobileScannerService.SessionNotFoundException;
import stirling.software.common.service.MobileScannerService.UploadRateLimitExceededException;
import stirling.software.common.service.MobileScannerService.UploadSizeLimitExceededException;
@ExtendWith(MockitoExtension.class)
class MobileScannerControllerTest {
@@ -199,6 +202,50 @@ class MobileScannerControllerTest {
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
}
@Test
void uploadFiles_whenSessionDoesNotExist_returns404() throws Exception {
enableMobileScanner();
List<MultipartFile> files =
List.of(new MockMultipartFile("files", "scan.jpg", "image/jpeg", new byte[] {1}));
doThrow(new SessionNotFoundException("Session not found"))
.when(mobileScannerService)
.uploadFiles(eq("unknown"), any());
ResponseEntity<Map<String, Object>> response = controller.uploadFiles("unknown", files);
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
}
@Test
void uploadFiles_whenRateLimited_returns429() throws Exception {
enableMobileScanner();
List<MultipartFile> files =
List.of(new MockMultipartFile("files", "scan.jpg", "image/jpeg", new byte[] {1}));
doThrow(new UploadRateLimitExceededException("Rate limit exceeded"))
.when(mobileScannerService)
.uploadFiles(eq("test-session"), any());
ResponseEntity<Map<String, Object>> response =
controller.uploadFiles("test-session", files);
assertEquals(HttpStatus.TOO_MANY_REQUESTS, response.getStatusCode());
}
@Test
void uploadFiles_whenQuotaExceeded_returns413() throws Exception {
enableMobileScanner();
List<MultipartFile> files =
List.of(new MockMultipartFile("files", "scan.jpg", "image/jpeg", new byte[] {1}));
doThrow(new UploadSizeLimitExceededException("Quota exceeded"))
.when(mobileScannerService)
.uploadFiles(eq("test-session"), any());
ResponseEntity<Map<String, Object>> response =
controller.uploadFiles("test-session", files);
assertEquals(HttpStatus.CONTENT_TOO_LARGE, response.getStatusCode());
}
// --- getSessionFiles tests ---
@Test
@@ -7,6 +7,9 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
@@ -29,12 +32,14 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.SPDF.service.HardwareKeyStoreService;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CertificateFileUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@@ -233,6 +238,26 @@ class CertSignControllerTest {
assertTrue(exception.getMessage().contains("PKCS12 keystore"));
}
@Test
void oversizedCredentialIsRejectedBeforeReadingIt() throws Exception {
MultipartFile oversizedKey = mock(MultipartFile.class);
when(oversizedKey.isEmpty()).thenReturn(false);
when(oversizedKey.getSize())
.thenReturn(CertificateFileUtils.MAX_CERTIFICATE_FILE_SIZE_BYTES + 1);
SignPDFWithCertRequest request = new SignPDFWithCertRequest();
request.setCertType("PEM");
request.setPrivateKeyFile(oversizedKey);
ResponseStatusException exception =
assertThrows(
ResponseStatusException.class,
() -> certSignController.signPDFWithCert(request, httpRequest));
assertTrue(exception.getStatusCode().value() == 413);
verify(oversizedKey, never()).getBytes();
}
@Test
void testSignPdfWithJks() throws Exception {
MockMultipartFile pdfFile =
@@ -2,6 +2,9 @@ package stirling.software.SPDF.controller.api.security;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
@@ -26,11 +29,14 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.SPDF.model.api.security.SignatureValidationRequest;
import stirling.software.SPDF.model.api.security.SignatureValidationResult;
import stirling.software.SPDF.service.CertificateValidationService;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CertificateFileUtils;
@DisplayName("ValidateSignatureController Tests")
@ExtendWith(MockitoExtension.class)
@@ -159,6 +165,26 @@ class ValidateSignatureControllerTest {
() -> validateSignatureController.validateSignature(request));
}
@Test
@DisplayName("Should reject an oversized custom trust certificate before reading it")
void testValidateSignature_OversizedCertFile() throws Exception {
MultipartFile oversizedCert = mock(MultipartFile.class);
when(oversizedCert.isEmpty()).thenReturn(false);
when(oversizedCert.getSize())
.thenReturn(CertificateFileUtils.MAX_CERTIFICATE_FILE_SIZE_BYTES + 1);
SignatureValidationRequest request = new SignatureValidationRequest();
request.setCertFile(oversizedCert);
ResponseStatusException exception =
assertThrows(
ResponseStatusException.class,
() -> validateSignatureController.validateSignature(request));
assertEquals(413, exception.getStatusCode().value());
verify(oversizedCert, never()).getBytes();
}
@Test
@DisplayName("Should handle IOException from PDF loading")
void testValidateSignature_IOException() throws Exception {
@@ -105,7 +105,7 @@ public class SignatureController {
* shared signatures.
*/
@PostMapping("/{signatureId}/label")
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
public ResponseEntity<Void> updateSignatureLabel(
@PathVariable String signatureId, @RequestBody Map<String, String> body) {
try {
@@ -140,7 +140,7 @@ public class SignatureController {
* signatures. Admins can also delete shared signatures.
*/
@DeleteMapping("/{signatureId}")
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
public ResponseEntity<Void> deleteSignature(@PathVariable String signatureId) {
try {
String username = userService.getCurrentUsername();
@@ -187,6 +187,9 @@ public class PolicyController {
summary = "Get pipeline run status",
description = "Returns the current status, step cursor, and output files of a run.")
public ResponseEntity<PolicyRunView> status(@PathVariable String runId) {
if (!ownedByCurrentUser(runId)) {
return ResponseEntity.notFound().build();
}
PolicyRun run = runRegistry.get(runId);
if (run != null) {
return ResponseEntity.ok(PolicyRunView.of(run));
@@ -1,22 +0,0 @@
package stirling.software.proprietary.security.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.filter.ParticipantRateLimitInterceptor;
@Configuration
@RequiredArgsConstructor
public class ProprietaryWebMvcConfig implements WebMvcConfigurer {
private final ParticipantRateLimitInterceptor participantRateLimitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(participantRateLimitInterceptor)
.addPathPatterns("/api/v1/workflow/participant/**");
}
}
@@ -91,7 +91,7 @@ public class DatabaseController {
@Operation(
summary = "Import database backup by filename",
description = "Imports a database backup file from the server using its file name.")
@GetMapping("/import-database-file/{fileName}")
@PostMapping("/import-database-file/{fileName}")
public ResponseEntity<?> importDatabaseFromBackupUI(
@Parameter(description = "Name of the file to import", required = true) @PathVariable
String fileName) {
@@ -141,7 +141,7 @@ public class DatabaseController {
@Operation(
summary = "Delete a database backup file",
description = "Deletes a specified database backup file from the server.")
@GetMapping("/delete/{fileName}")
@DeleteMapping("/delete/{fileName}")
public ResponseEntity<?> deleteFile(
@Parameter(description = "Name of the file to delete", required = true) @PathVariable
String fileName) {
@@ -228,7 +228,7 @@ public class DatabaseController {
@Operation(
summary = "Create a database backup",
description = "This endpoint triggers the creation of a database backup.")
@GetMapping("/createDatabaseBackup")
@PostMapping("/createDatabaseBackup")
public ResponseEntity<?> createDatabaseBackup() {
log.info("Starting database backup creation...");
databaseService.exportDatabase();
@@ -22,7 +22,9 @@ import stirling.software.proprietary.security.model.InviteToken;
import stirling.software.proprietary.security.repository.InviteTokenRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.SaveUserRequest;
import stirling.software.proprietary.security.service.InviteAcceptanceService;
import stirling.software.proprietary.security.service.InviteAcceptanceService.AcceptanceResult;
import stirling.software.proprietary.security.service.InviteAcceptanceService.InviteAcceptanceException;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@@ -38,6 +40,7 @@ public class InviteLinkController {
private final ApplicationProperties applicationProperties;
private final Optional<EmailService> emailService;
private final UserLicenseSettingsService userLicenseSettingsService;
private final InviteAcceptanceService inviteAcceptanceService;
/**
* Generate a new invite link (admin only)
@@ -411,67 +414,22 @@ public class InviteLinkController {
.body(Map.of("error", "Password is required"));
}
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
if (inviteOpt.isEmpty()) {
return invalidInviteResponse();
}
InviteToken invite = inviteOpt.get();
if (invite.isUsed()) {
return invalidInviteResponse();
}
if (invite.isExpired()) {
return invalidInviteResponse();
}
// Determine the email to use
String effectiveEmail = invite.getEmail();
if (effectiveEmail == null) {
// Email not pre-set, must be provided by user
if (email == null || email.trim().isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Email address is required"));
}
// Validate email format
if (!email.contains("@")) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid email address"));
}
effectiveEmail = email.trim().toLowerCase();
}
// Check if user already exists
if (userService.usernameExistsIgnoreCase(effectiveEmail)) {
return invalidInviteResponse();
}
// Create the user account
SaveUserRequest.Builder builder =
SaveUserRequest.builder()
.username(effectiveEmail)
.password(password)
.teamId(invite.getTeamId())
.role(invite.getRole());
userService.saveUserCore(builder.build());
// Mark invite as used
invite.setUsed(true);
invite.setUsedAt(LocalDateTime.now());
inviteTokenRepository.save(invite);
AcceptanceResult result = inviteAcceptanceService.accept(token, email, password);
log.info(
"User account created via invite link: {} with role: {}",
effectiveEmail,
invite.getRole());
result.username(),
result.role());
return ResponseEntity.ok(
Map.of("message", "Account created successfully", "username", effectiveEmail));
Map.of(
"message",
"Account created successfully",
"username",
result.username()));
} catch (InviteAcceptanceException e) {
return ResponseEntity.status(e.getStatus()).body(Map.of("error", e.getMessage()));
} catch (Exception e) {
log.error("Failed to accept invite: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
@@ -37,7 +37,7 @@ public class EnterpriseEndpointFilter extends OncePerRequestFilter {
: uri;
boolean isHealthCheck =
trimmedUri.startsWith("/actuator/health")
matchesPathOrChild(trimmedUri, "/actuator/health")
|| "/health".equals(trimmedUri)
|| "/healthz".equals(trimmedUri)
|| "/liveness".equals(trimmedUri)
@@ -54,4 +54,8 @@ public class EnterpriseEndpointFilter extends OncePerRequestFilter {
private boolean isPrometheusEndpointRequest(HttpServletRequest request) {
return request.getRequestURI().contains("/actuator/");
}
private boolean matchesPathOrChild(String requestUri, String endpoint) {
return requestUri.equals(endpoint) || requestUri.startsWith(endpoint + "/");
}
}
@@ -1,73 +0,0 @@
package stirling.software.proprietary.security.filter;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
/** Per-IP rate limiter for the unauthenticated participant token endpoints. */
@Slf4j
@Component
public class ParticipantRateLimitInterceptor implements HandlerInterceptor {
private static final int MAX_REQUESTS_PER_MINUTE = 20;
private static final long WINDOW_MS = 60_000L;
// value: [requestCount, windowStartMs]
private final ConcurrentHashMap<String, long[]> requestCounts = new ConcurrentHashMap<>();
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String ip = getClientIp(request);
long now = System.currentTimeMillis();
long[] entry =
requestCounts.compute(
ip,
(key, existing) -> {
if (existing == null || now - existing[1] >= WINDOW_MS) {
return new long[] {1, now};
}
existing[0]++;
return existing;
});
if (entry[0] > MAX_REQUESTS_PER_MINUTE) {
log.warn(
"Rate limit exceeded for IP {} on participant endpoint {}",
ip,
request.getRequestURI());
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader("Retry-After", "60");
response.setContentType("application/json");
response.getWriter()
.write("{\"error\":\"Rate limit exceeded. Try again in 60 seconds.\"}");
return false;
}
return true;
}
private String getClientIp(HttpServletRequest request) {
// Do not trust X-Forwarded-For: it is user-controlled and trivially spoofed,
// which would allow an attacker to bypass this rate limiter by rotating fake IPs.
// Operators who deploy behind a trusted reverse proxy should configure Spring's
// RemoteIpFilter / ForwardedHeaderFilter at the framework level instead.
return request.getRemoteAddr();
}
@Scheduled(fixedDelay = 300_000)
public void cleanupExpiredWindows() {
long cutoff = System.currentTimeMillis() - WINDOW_MS;
requestCounts.entrySet().removeIf(e -> e.getValue()[1] < cutoff);
}
}
@@ -0,0 +1,159 @@
package stirling.software.proprietary.security.filter;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
/** Protects workflow participant endpoints before multipart requests are parsed. */
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ParticipantRequestSecurityFilter extends OncePerRequestFilter {
static final long MAX_MULTIPART_REQUEST_SIZE_BYTES = 16L * 1024 * 1024;
static final long MAX_MOBILE_UPLOAD_REQUEST_SIZE_BYTES = 101L * 1024 * 1024;
private static final String PARTICIPANT_PATH = "/api/v1/workflow/participant/";
private static final String MOBILE_SCANNER_UPLOAD_PATH = "/api/v1/mobile-scanner/upload/";
private static final String AUTHENTICATED_CERTIFICATE_VALIDATION_PATH =
"/api/v1/security/cert-sign/validate-certificate";
private static final Pattern AUTHENTICATED_SIGN_PATH =
Pattern.compile("^/api/v1/security/cert-sign/sign-requests/[^/]+/sign$");
private static final Set<String> MULTIPART_UPLOAD_PATHS =
Set.of(
PARTICIPANT_PATH + "submit-signature",
PARTICIPANT_PATH + "validate-certificate",
AUTHENTICATED_CERTIFICATE_VALIDATION_PATH);
private static final int MAX_REQUESTS_PER_MINUTE = 20;
private static final long WINDOW_MS = 60_000L;
private final ConcurrentHashMap<String, RequestWindow> requestCounts =
new ConcurrentHashMap<>();
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = normalizedRequestPath(request);
return !path.startsWith(PARTICIPANT_PATH)
&& !path.startsWith(MOBILE_SCANNER_UPLOAD_PATH)
&& !isAuthenticatedWorkflowUploadPath(path);
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (rateLimitExceeded(request)) {
log.warn(
"Rate limit exceeded for IP {} on participant endpoint {}",
request.getRemoteAddr(),
request.getRequestURI());
writeError(
response,
HttpStatus.TOO_MANY_REQUESTS,
"Rate limit exceeded. Try again in 60 seconds.");
response.setHeader("Retry-After", "60");
return;
}
if (isMultipartUploadEndpoint(request)) {
long contentLength = request.getContentLengthLong();
if (contentLength < 0) {
writeError(
response,
HttpStatus.LENGTH_REQUIRED,
"Content-Length is required for workflow uploads.");
return;
}
long maxRequestSize =
normalizedRequestPath(request).startsWith(MOBILE_SCANNER_UPLOAD_PATH)
? MAX_MOBILE_UPLOAD_REQUEST_SIZE_BYTES
: MAX_MULTIPART_REQUEST_SIZE_BYTES;
if (contentLength > maxRequestSize) {
writeError(
response,
HttpStatus.CONTENT_TOO_LARGE,
"Upload exceeds the configured request limit.");
return;
}
}
filterChain.doFilter(request, response);
}
private boolean rateLimitExceeded(HttpServletRequest request) {
long now = System.currentTimeMillis();
RequestWindow entry =
requestCounts.compute(
request.getRemoteAddr(),
(key, existing) -> {
if (existing == null || now - existing.windowStartMs() >= WINDOW_MS) {
return new RequestWindow(1, now);
}
return new RequestWindow(
existing.requestCount() + 1, existing.windowStartMs());
});
return entry.requestCount() > MAX_REQUESTS_PER_MINUTE;
}
private boolean isMultipartUploadEndpoint(HttpServletRequest request) {
String path = normalizedRequestPath(request);
return "POST".equalsIgnoreCase(request.getMethod())
&& (path.startsWith(MOBILE_SCANNER_UPLOAD_PATH)
|| MULTIPART_UPLOAD_PATHS.contains(path)
|| AUTHENTICATED_SIGN_PATH.matcher(path).matches());
}
private boolean isAuthenticatedWorkflowUploadPath(String path) {
return AUTHENTICATED_CERTIFICATE_VALIDATION_PATH.equals(path)
|| AUTHENTICATED_SIGN_PATH.matcher(path).matches();
}
private String normalizedRequestPath(HttpServletRequest request) {
String requestUri = request.getRequestURI();
String contextPath = request.getContextPath();
if (!contextPath.isEmpty() && requestUri.startsWith(contextPath)) {
requestUri = requestUri.substring(contextPath.length());
}
try {
requestUri = URLDecoder.decode(requestUri, StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
// Leave malformed paths unchanged; they will not match a protected upload route.
log.debug("Malformed encoded request path: {}", requestUri);
}
return requestUri.length() > 1 && requestUri.endsWith("/")
? requestUri.substring(0, requestUri.length() - 1)
: requestUri;
}
private void writeError(HttpServletResponse response, HttpStatus status, String errorMessage)
throws IOException {
response.setStatus(status.value());
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"" + errorMessage + "\"}");
}
@Scheduled(fixedDelay = 300_000)
public void cleanupExpiredWindows() {
long cutoff = System.currentTimeMillis() - WINDOW_MS;
requestCounts.entrySet().removeIf(entry -> entry.getValue().windowStartMs() < cutoff);
}
private record RequestWindow(int requestCount, long windowStartMs) {}
}
@@ -5,11 +5,14 @@ import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import jakarta.persistence.LockModeType;
import stirling.software.proprietary.security.model.InviteToken;
@Repository
@@ -17,6 +20,10 @@ public interface InviteTokenRepository extends JpaRepository<InviteToken, Long>
Optional<InviteToken> findByToken(String token);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT it FROM InviteToken it WHERE it.token = :token")
Optional<InviteToken> findByTokenForUpdate(@Param("token") String token);
Optional<InviteToken> findByEmail(String email);
List<InviteToken> findByUsedFalseAndExpiresAtAfter(LocalDateTime now);
@@ -0,0 +1,85 @@
package stirling.software.proprietary.security.service;
import java.time.LocalDateTime;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.model.InviteToken;
import stirling.software.proprietary.security.repository.InviteTokenRepository;
/** Atomically consumes an invite and creates the invited account. */
@Service
@RequiredArgsConstructor
public class InviteAcceptanceService {
private final InviteTokenRepository inviteTokenRepository;
private final UserService userService;
@Transactional(rollbackFor = Exception.class)
public AcceptanceResult accept(String token, String requestedEmail, String password)
throws Exception {
InviteToken invite =
inviteTokenRepository
.findByTokenForUpdate(token)
.orElseThrow(InviteAcceptanceException::invalidInvite);
if (invite.isUsed() || invite.isExpired()) {
throw InviteAcceptanceException.invalidInvite();
}
String effectiveEmail = resolveEmail(invite, requestedEmail);
if (userService.usernameExistsIgnoreCase(effectiveEmail)) {
throw InviteAcceptanceException.invalidInvite();
}
SaveUserRequest request =
SaveUserRequest.builder()
.username(effectiveEmail)
.password(password)
.teamId(invite.getTeamId())
.role(invite.getRole())
.build();
userService.saveUserCore(request);
invite.setUsed(true);
invite.setUsedAt(LocalDateTime.now());
inviteTokenRepository.save(invite);
return new AcceptanceResult(effectiveEmail, invite.getRole());
}
private String resolveEmail(InviteToken invite, String requestedEmail) {
if (invite.getEmail() != null) {
return invite.getEmail();
}
if (requestedEmail == null || requestedEmail.trim().isEmpty()) {
throw new InviteAcceptanceException(
HttpStatus.BAD_REQUEST, "Email address is required");
}
if (!requestedEmail.contains("@")) {
throw new InviteAcceptanceException(HttpStatus.BAD_REQUEST, "Invalid email address");
}
return requestedEmail.trim().toLowerCase();
}
public record AcceptanceResult(String username, String role) {}
@Getter
public static class InviteAcceptanceException extends RuntimeException {
private final HttpStatus status;
public InviteAcceptanceException(HttpStatus status, String message) {
super(message);
this.status = status;
}
public static InviteAcceptanceException invalidInvite() {
return new InviteAcceptanceException(HttpStatus.NOT_FOUND, "Invalid invite link");
}
}
}
@@ -41,8 +41,10 @@ import stirling.software.proprietary.workflow.dto.ParticipantRequest;
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
import stirling.software.proprietary.workflow.service.SigningFinalizationService;
import stirling.software.proprietary.workflow.service.WorkflowFinalizationCoordinator;
import stirling.software.proprietary.workflow.service.WorkflowFinalizationCoordinator.FinalizedWorkflow;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
import stirling.software.proprietary.workflow.util.WorkflowUploadUtils;
@Slf4j
@RestController
@@ -55,7 +57,7 @@ public class SigningSessionController {
private final WorkflowSessionService workflowSessionService;
private final UserService userService;
private final SigningFinalizationService signingFinalizationService;
private final WorkflowFinalizationCoordinator workflowFinalizationCoordinator;
private final CertificateSubmissionValidator certificateSubmissionValidator;
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -239,36 +241,9 @@ public class SigningSessionController {
try {
User owner = getCurrentUser(principal);
WorkflowSession session =
workflowSessionService.getSessionWithParticipantsForOwner(sessionId, owner);
byte[] originalPdf = workflowSessionService.getOriginalFile(sessionId);
byte[] pdf = signingFinalizationService.finalizeDocument(session, originalPdf);
String filename = session.getDocumentName().replace(".pdf", "") + "_shared_signed.pdf";
workflowSessionService.storeProcessedFile(session, pdf, filename);
workflowSessionService.finalizeSession(sessionId, owner);
workflowSessionService.deleteOriginalFile(session);
try {
signingFinalizationService.clearSensitiveMetadata(session);
} catch (Exception e) {
log.error(
"SECURITY: Failed to clear sensitive metadata for session {} "
+ "(participants: {}). Keystore credentials may remain in the "
+ "database until manual cleanup.",
sessionId,
session.getParticipants() != null
? session.getParticipants().stream().map(p -> p.getEmail()).toList()
: "unknown",
e);
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Document signed successfully but post-signing cleanup failed. "
+ "Contact your administrator to complete the cleanup.");
}
return WebResponseUtils.bytesToWebResponse(pdf, filename);
FinalizedWorkflow finalized =
workflowFinalizationCoordinator.finalizeSession(sessionId, owner);
return WebResponseUtils.bytesToWebResponse(finalized.pdf(), finalized.filename());
} catch (Exception e) {
log.error("Error finalizing session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
@@ -436,14 +411,27 @@ public class SigningSessionController {
HttpStatus.BAD_REQUEST, "No certificate file provided");
}
try {
byte[] keystoreBytes = null;
if (p12File != null && !p12File.isEmpty()) {
keystoreBytes = p12File.getBytes();
} else if (jksFile != null && !jksFile.isEmpty()) {
keystoreBytes = jksFile.getBytes();
}
WorkflowUploadUtils.rejectMultipleKeystores(p12File, jksFile);
byte[] keystoreBytes;
try {
keystoreBytes =
WorkflowUploadUtils.readCredentialFile(
p12File != null && !p12File.isEmpty() ? p12File : jksFile);
} catch (IOException e) {
log.error("Error reading certificate file during pre-validation", e);
return ResponseEntity.ok(
new CertificateValidationResponse(
false,
null,
null,
null,
null,
false,
"Failed to read certificate file"));
}
try {
CertificateInfo info =
certificateSubmissionValidator.validateAndExtractInfo(
keystoreBytes, certType, password);
@@ -470,17 +458,6 @@ public class SigningSessionController {
return ResponseEntity.ok(
new CertificateValidationResponse(
false, null, null, null, null, false, e.getReason()));
} catch (IOException e) {
log.error("Error reading certificate file during pre-validation", e);
return ResponseEntity.ok(
new CertificateValidationResponse(
false,
null,
null,
null,
null,
false,
"Failed to read certificate file"));
}
}
@@ -7,6 +7,7 @@ import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
@@ -24,6 +25,7 @@ import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.transaction.Transactional;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@@ -40,10 +42,12 @@ import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
import stirling.software.proprietary.workflow.service.MetadataEncryptionService;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
import stirling.software.proprietary.workflow.util.WorkflowMapper;
import stirling.software.proprietary.workflow.util.WorkflowUploadUtils;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@@ -58,15 +62,34 @@ import tools.jackson.databind.ObjectMapper;
@RestController
@RequestMapping("/api/v1/workflow/participant")
@Tag(name = "Workflow Participant", description = "Participant Action APIs")
@RequiredArgsConstructor
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class WorkflowParticipantController {
private final WorkflowSessionService workflowSessionService;
private final WorkflowParticipantRepository participantRepository;
private final WorkflowSessionRepository sessionRepository;
private final ObjectMapper objectMapper;
private final MetadataEncryptionService metadataEncryptionService;
private final CertificateSubmissionValidator certificateSubmissionValidator;
/**
* Compatibility constructor for focused controller tests that do not exercise session locks.
*/
public WorkflowParticipantController(
WorkflowSessionService workflowSessionService,
WorkflowParticipantRepository participantRepository,
ObjectMapper objectMapper,
MetadataEncryptionService metadataEncryptionService,
CertificateSubmissionValidator certificateSubmissionValidator) {
this(
workflowSessionService,
participantRepository,
null,
objectMapper,
metadataEncryptionService,
certificateSubmissionValidator);
}
private static final DateTimeFormatter ISO_UTC =
DateTimeFormatter.ISO_INSTANT.withZone(ZoneOffset.UTC);
@@ -93,14 +116,16 @@ public class WorkflowParticipantController {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
}
// Mark as viewed if not already
if (participant.getStatus() == ParticipantStatus.PENDING
|| participant.getStatus() == ParticipantStatus.NOTIFIED) {
WorkflowSession session = participant.getWorkflowSession();
// Completed and cancelled workflows remain readable for participants, but immutable.
if (session.isActive()
&& (participant.getStatus() == ParticipantStatus.PENDING
|| participant.getStatus() == ParticipantStatus.NOTIFIED)) {
workflowSessionService.updateParticipantStatus(
participant.getId(), ParticipantStatus.VIEWED);
}
WorkflowSession session = participant.getWorkflowSession();
// Strip peer share tokens — a single participant token must not enumerate peer bearer
// tokens (GHSA-qgg6-mxw4-xg62).
return ResponseEntity.ok(WorkflowMapper.toResponse(session, null, false));
@@ -124,17 +149,23 @@ public class WorkflowParticipantController {
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
if (participant.isExpired()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
}
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false));
}
@Operation(
summary = "Submit signature (wet signature and/or certificate)",
description =
"Participants submit their signature data and certificate information for signing")
"Participants submit their signature data and certificate information for"
+ " signing")
@PostMapping(
value = "/submit-signature",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
@Transactional
public ResponseEntity<ParticipantResponse> submitSignature(
@ModelAttribute SignatureSubmissionRequest request) {
@@ -154,6 +185,24 @@ public class WorkflowParticipantController {
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
if (sessionRepository != null) {
sessionRepository
.findByParticipantShareTokenForUpdate(request.getParticipantToken())
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
}
participant =
participantRepository
.findByShareToken(request.getParticipantToken())
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
// Check if participant can still submit
if (participant.isExpired()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
@@ -198,6 +247,7 @@ public class WorkflowParticipantController {
summary = "Decline participation",
description = "Participant declines to sign or participate in the workflow")
@PostMapping(value = "/decline", produces = MediaType.APPLICATION_JSON_VALUE)
@Transactional
public ResponseEntity<ParticipantResponse> declineParticipation(
@RequestParam("token") @NotBlank String token,
@RequestParam(value = "reason", required = false) @Size(max = 500) String reason) {
@@ -213,11 +263,38 @@ public class WorkflowParticipantController {
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
if (sessionRepository != null) {
sessionRepository
.findByParticipantShareTokenForUpdate(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
}
participant =
participantRepository
.findByShareToken(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
if (participant.isExpired()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
}
if (participant.hasCompleted()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Participant has already completed their action");
}
if (!participant.getWorkflowSession().isActive()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Workflow session is no longer active");
}
// Update status to DECLINED
participant.setStatus(ParticipantStatus.DECLINED);
@@ -320,14 +397,27 @@ public class WorkflowParticipantController {
HttpStatus.BAD_REQUEST, "No certificate file provided");
}
try {
byte[] keystoreBytes = null;
if (p12File != null && !p12File.isEmpty()) {
keystoreBytes = p12File.getBytes();
} else if (jksFile != null && !jksFile.isEmpty()) {
keystoreBytes = jksFile.getBytes();
}
WorkflowUploadUtils.rejectMultipleKeystores(p12File, jksFile);
byte[] keystoreBytes;
try {
keystoreBytes =
WorkflowUploadUtils.readCredentialFile(
p12File != null && !p12File.isEmpty() ? p12File : jksFile);
} catch (IOException e) {
log.error("Error reading certificate file during pre-validation", e);
return ResponseEntity.ok(
new CertificateValidationResponse(
false,
null,
null,
null,
null,
false,
"Failed to read certificate file"));
}
try {
CertificateInfo info =
certificateSubmissionValidator.validateAndExtractInfo(
keystoreBytes, certType, password);
@@ -356,17 +446,6 @@ public class WorkflowParticipantController {
return ResponseEntity.ok(
new CertificateValidationResponse(
false, null, null, null, null, false, e.getReason()));
} catch (IOException e) {
log.error("Error reading certificate file during pre-validation", e);
return ResponseEntity.ok(
new CertificateValidationResponse(
false,
null,
null,
null,
null,
false,
"Failed to read certificate file"));
}
}
@@ -378,14 +457,13 @@ public class WorkflowParticipantController {
throws IOException {
Map<String, Object> metadata = new HashMap<>();
WorkflowUploadUtils.rejectMultipleKeystores(request.getP12File(), request.getJksFile());
byte[] p12Bytes = WorkflowUploadUtils.readCredentialFile(request.getP12File());
byte[] jksBytes = WorkflowUploadUtils.readCredentialFile(request.getJksFile());
// Validate certificate before storing — throws 400 if invalid, expired, or wrong password
if (request.getCertType() != null && !"SERVER".equalsIgnoreCase(request.getCertType())) {
byte[] keystoreBytes = null;
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
keystoreBytes = request.getP12File().getBytes();
} else if (request.getJksFile() != null && !request.getJksFile().isEmpty()) {
keystoreBytes = request.getJksFile().getBytes();
}
byte[] keystoreBytes = p12Bytes != null ? p12Bytes : jksBytes;
if (keystoreBytes != null) {
certificateSubmissionValidator.validateAndExtractInfo(
keystoreBytes, request.getCertType(), request.getPassword());
@@ -405,15 +483,11 @@ public class WorkflowParticipantController {
certSubmission.put("showLogo", request.getShowLogo());
// Store the certificate keystores encrypted at rest.
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
certSubmission.put(
"p12Keystore",
metadataEncryptionService.encryptBytes(request.getP12File().getBytes()));
if (p12Bytes != null) {
certSubmission.put("p12Keystore", metadataEncryptionService.encryptBytes(p12Bytes));
}
if (request.getJksFile() != null && !request.getJksFile().isEmpty()) {
certSubmission.put(
"jksKeystore",
metadataEncryptionService.encryptBytes(request.getJksFile().getBytes()));
if (jksBytes != null) {
certSubmission.put("jksKeystore", metadataEncryptionService.encryptBytes(jksBytes));
}
metadata.put("certificateSubmission", certSubmission);
@@ -421,10 +495,7 @@ public class WorkflowParticipantController {
// Add wet signatures data if provided - parse once and store as List directly
if (request.getWetSignaturesData() != null && !request.getWetSignaturesData().isBlank()) {
if (request.getWetSignaturesData().length() > 5 * 1024 * 1024) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Wet signatures data exceeds maximum allowed size");
}
WorkflowUploadUtils.validateWetSignatureDataSize(request.getWetSignaturesData());
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> wetSigs =
objectMapper.readValue(
@@ -4,10 +4,13 @@ import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import jakarta.persistence.LockModeType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.model.WorkflowStatus;
@@ -24,6 +27,16 @@ public interface WorkflowSessionRepository extends JpaRepository<WorkflowSession
"SELECT ws FROM WorkflowSession ws LEFT JOIN FETCH ws.participants WHERE ws.sessionId = :sessionId")
Optional<WorkflowSession> findBySessionIdWithParticipants(@Param("sessionId") String sessionId);
/** Lock a session before finalization so only one request can process it at a time. */
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT ws FROM WorkflowSession ws WHERE ws.sessionId = :sessionId")
Optional<WorkflowSession> findBySessionIdForUpdate(@Param("sessionId") String sessionId);
/** Lock the owning session while a participant performs a state-changing action. */
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT ws FROM WorkflowSession ws JOIN ws.participants p WHERE p.shareToken = :token")
Optional<WorkflowSession> findByParticipantShareTokenForUpdate(@Param("token") String token);
/** Find all workflow sessions owned by a specific user */
List<WorkflowSession> findByOwnerOrderByCreatedAtDesc(User owner);
@@ -7,6 +7,7 @@ import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Base64;
import java.util.Date;
import java.util.Optional;
@@ -43,7 +44,7 @@ import stirling.software.proprietary.workflow.repository.UserServerCertificateRe
public class UserServerCertificateService {
private static final String KEYSTORE_ALIAS = "stirling-pdf-user-cert";
private static final String DEFAULT_PASSWORD_PREFIX = "stirling-user-cert-";
private static final int GENERATED_PASSWORD_BYTES = 32;
private static final int VALIDITY_DAYS = 365;
private final UserServerCertificateRepository certificateRepository;
@@ -135,7 +136,7 @@ public class UserServerCertificateService {
// Create keystore
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(null, null);
String password = generateUserPassword(user.getId());
String password = generateUserPassword();
keyStore.setKeyEntry(
KEYSTORE_ALIAS,
keyPair.getPrivate(),
@@ -251,8 +252,10 @@ public class UserServerCertificateService {
return certificateRepository.findByUserId(userId);
}
/** Generate consistent password for user (based on user ID) */
private String generateUserPassword(Long userId) {
return DEFAULT_PASSWORD_PREFIX + userId;
/** Generate a high-entropy password for a newly created private-key keystore. */
private String generateUserPassword() {
byte[] passwordBytes = new byte[GENERATED_PASSWORD_BYTES];
new SecureRandom().nextBytes(passwordBytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(passwordBytes);
}
}
@@ -0,0 +1,61 @@
package stirling.software.proprietary.workflow.service;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.workflow.model.WorkflowSession;
/** Serializes the complete signing finalization lifecycle for a workflow session. */
@Service
@RequiredArgsConstructor
public class WorkflowFinalizationCoordinator {
private final WorkflowSessionService workflowSessionService;
private final SigningFinalizationService signingFinalizationService;
@Transactional(rollbackFor = Exception.class)
public FinalizedWorkflow finalizeSession(String sessionId, User owner) throws Exception {
WorkflowSession session =
workflowSessionService.getSessionWithParticipantsForOwnerForUpdate(
sessionId, owner);
if (!session.isActive()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Workflow session is no longer active");
}
byte[] originalPdf = workflowSessionService.getOriginalFile(sessionId);
byte[] finalizedPdf = signingFinalizationService.finalizeDocument(session, originalPdf);
String filename = session.getDocumentName().replace(".pdf", "") + "_shared_signed.pdf";
workflowSessionService.storeProcessedFile(session, finalizedPdf, filename);
workflowSessionService.finalizeSession(sessionId, owner);
signingFinalizationService.clearSensitiveMetadata(session);
// Deleting the source is an external storage side effect. Defer it until the DB
// transaction has committed so a commit failure cannot leave the workflow pointing at a
// deleted original document.
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
workflowSessionService.deleteOriginalFile(session);
}
});
} else {
// Direct unit-test/in-process calls without a transaction retain the original cleanup
// behavior; Spring-managed requests always take the after-commit branch above.
workflowSessionService.deleteOriginalFile(session);
}
return new FinalizedWorkflow(finalizedPdf, filename);
}
public record FinalizedWorkflow(byte[] pdf, String filename) {}
}
@@ -54,6 +54,7 @@ import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.model.WorkflowStatus;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
import stirling.software.proprietary.workflow.util.WorkflowUploadUtils;
import tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
@@ -317,6 +318,25 @@ public class WorkflowSessionService {
return session;
}
/** Retrieves and locks a workflow session for an owner-controlled state transition. */
public WorkflowSession getSessionWithParticipantsForOwnerForUpdate(
String sessionId, User owner) {
WorkflowSession session =
workflowSessionRepository
.findBySessionIdForUpdate(sessionId)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Workflow session not found: " + sessionId));
if (!session.getOwner().equals(owner)) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "Not authorized to access this workflow session");
}
session.getParticipants().size();
return session;
}
/** Lists all workflow sessions owned by a user. */
@Transactional(readOnly = true)
public List<WorkflowSession> listUserSessions(User owner) {
@@ -349,6 +369,11 @@ public class WorkflowSessionService {
public void removeParticipant(String sessionId, Long participantId, User owner) {
WorkflowSession session = getSessionForOwner(sessionId, owner);
if (!session.isActive()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Cannot remove participants from inactive workflow");
}
WorkflowParticipant participant =
workflowParticipantRepository
.findById(participantId)
@@ -635,7 +660,7 @@ public class WorkflowSessionService {
}
// Update status to VIEWED if it was NOTIFIED
if (participant.getStatus() == ParticipantStatus.NOTIFIED) {
if (session.isActive() && participant.getStatus() == ParticipantStatus.NOTIFIED) {
participant.setStatus(ParticipantStatus.VIEWED);
workflowParticipantRepository.save(participant);
}
@@ -692,6 +717,11 @@ public class WorkflowSessionService {
WorkflowSession session = getSession(sessionId);
WorkflowParticipant participant = getParticipantForUser(session, user);
if (!session.isActive()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Workflow session is no longer active");
}
if (participant.getStatus() == ParticipantStatus.SIGNED) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Document already signed by this user");
@@ -702,6 +732,18 @@ public class WorkflowSessionService {
HttpStatus.BAD_REQUEST, "Cannot sign after declining");
}
WorkflowUploadUtils.validateWetSignatureDataSize(request.getWetSignaturesData());
WorkflowUploadUtils.rejectMultipleKeystores(request.getP12File(), request.getJksFile());
byte[] p12Bytes = readCredentialFile(request.getP12File(), "P12 keystore");
byte[] jksBytes = readCredentialFile(request.getJksFile(), "JKS keystore");
byte[] privateKeyBytes = null;
byte[] certificateBytes = null;
if ("PEM".equalsIgnoreCase(request.getCertType())) {
privateKeyBytes = readCredentialFile(request.getPrivateKeyFile(), "PEM private key");
certificateBytes = readCredentialFile(request.getCertFile(), "PEM certificate");
}
// Build metadata JSON containing certificate submission and wet signature data
// Merge with existing metadata if present (preserves owner-configured appearance
// settings)
@@ -717,36 +759,15 @@ public class WorkflowSessionService {
// password
if (request.getCertType() != null
&& !"SERVER".equalsIgnoreCase(request.getCertType())
&& request.getP12File() != null
&& !request.getP12File().isEmpty()) {
try {
certificateSubmissionValidator.validateAndExtractInfo(
request.getP12File().getBytes(),
request.getCertType(),
request.getPassword());
} catch (ResponseStatusException e) {
throw e;
} catch (IOException e) {
log.error("Failed to read P12 keystore file for validation", e);
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
}
&& p12Bytes != null) {
certificateSubmissionValidator.validateAndExtractInfo(
p12Bytes, request.getCertType(), request.getPassword());
}
// Validate an uploaded JKS keystore too (same early rejection as P12/PFX).
if ("JKS".equalsIgnoreCase(request.getCertType())
&& request.getJksFile() != null
&& !request.getJksFile().isEmpty()) {
try {
certificateSubmissionValidator.validateAndExtractInfo(
request.getJksFile().getBytes(), "JKS", request.getPassword());
} catch (ResponseStatusException e) {
throw e;
} catch (IOException e) {
log.error("Failed to read JKS keystore file for validation", e);
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
}
if ("JKS".equalsIgnoreCase(request.getCertType()) && jksBytes != null) {
certificateSubmissionValidator.validateAndExtractInfo(
jksBytes, "JKS", request.getPassword());
}
// 2. Store certificate submission data
@@ -757,10 +778,7 @@ public class WorkflowSessionService {
// PEM uploads are a separate private key + certificate, not a keystore. Convert them to
// a PKCS12 keystore here so finalization signs via the standard PKCS12 path.
byte[] p12 =
buildPkcs12FromPem(
request.getPrivateKeyFile(),
request.getCertFile(),
request.getPassword());
buildPkcs12FromPem(privateKeyBytes, certificateBytes, request.getPassword());
// Give PEM the same early validation (expiry, key recovery, test-sign) as uploaded
// PKCS12/JKS keystores, so an expired or unusable cert is rejected now rather than at
// finalization.
@@ -772,28 +790,10 @@ public class WorkflowSessionService {
} else {
certSubmission.put("certType", request.getCertType());
// Encrypt the uploaded keystore at rest: PKCS12/PFX → p12Keystore, JKS → jksKeystore.
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
try {
certSubmission.put(
"p12Keystore",
metadataEncryptionService.encryptBytes(
request.getP12File().getBytes()));
} catch (IOException e) {
log.error("Failed to read P12 keystore file", e);
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
}
} else if (request.getJksFile() != null && !request.getJksFile().isEmpty()) {
try {
certSubmission.put(
"jksKeystore",
metadataEncryptionService.encryptBytes(
request.getJksFile().getBytes()));
} catch (IOException e) {
log.error("Failed to read JKS keystore file", e);
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
}
if (p12Bytes != null) {
certSubmission.put("p12Keystore", metadataEncryptionService.encryptBytes(p12Bytes));
} else if (jksBytes != null) {
certSubmission.put("jksKeystore", metadataEncryptionService.encryptBytes(jksBytes));
}
}
@@ -881,6 +881,11 @@ public class WorkflowSessionService {
WorkflowSession session = getSession(sessionId);
WorkflowParticipant participant = getParticipantForUser(session, user);
if (!session.isActive()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Workflow session is no longer active");
}
if (participant.getStatus() == ParticipantStatus.SIGNED) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Cannot decline after signing");
@@ -901,14 +906,19 @@ public class WorkflowSessionService {
* @throws ResponseStatusException if user is not a participant
*/
private WorkflowParticipant getParticipantForUser(WorkflowSession session, User user) {
return session.getParticipants().stream()
.filter(p -> p.getUser() != null && p.getUser().equals(user))
.findFirst()
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"User is not a participant in this session"));
WorkflowParticipant participant =
session.getParticipants().stream()
.filter(p -> p.getUser() != null && p.getUser().equals(user))
.findFirst()
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"User is not a participant in this session"));
if (participant.isExpired()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
}
return participant;
}
/**
@@ -916,21 +926,21 @@ public class WorkflowSessionService {
* supplied password) so finalization can sign via the standard PKCS12 path.
*/
private byte[] buildPkcs12FromPem(
MultipartFile privateKeyFile, MultipartFile certFile, String password) {
if (privateKeyFile == null
|| privateKeyFile.isEmpty()
|| certFile == null
|| certFile.isEmpty()) {
byte[] privateKeyBytes, byte[] certificateBytes, String password) {
if (privateKeyBytes == null
|| privateKeyBytes.length == 0
|| certificateBytes == null
|| certificateBytes.length == 0) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"PEM signing requires both a private key file and a certificate file");
}
char[] pw = password != null ? password.toCharArray() : new char[0];
try {
PrivateKey privateKey = readPemPrivateKey(privateKeyFile.getBytes(), pw);
PrivateKey privateKey = readPemPrivateKey(privateKeyBytes, pw);
Certificate cert =
CertificateFactory.getInstance("X.509")
.generateCertificate(new ByteArrayInputStream(certFile.getBytes()));
.generateCertificate(new ByteArrayInputStream(certificateBytes));
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(null, null);
keyStore.setKeyEntry("alias", privateKey, pw, new Certificate[] {cert});
@@ -947,6 +957,16 @@ public class WorkflowSessionService {
}
}
private byte[] readCredentialFile(MultipartFile file, String description) {
try {
return WorkflowUploadUtils.readCredentialFile(file);
} catch (IOException e) {
log.error("Failed to read {}", description, e);
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Failed to process certificate credential file");
}
}
/** Reads a PEM private key (PKCS8/PKCS1, optionally password-encrypted). */
private PrivateKey readPemPrivateKey(byte[] pemBytes, char[] password) throws Exception {
try (PEMParser pemParser =
@@ -0,0 +1,41 @@
package stirling.software.proprietary.workflow.util;
import java.io.IOException;
import org.springframework.http.HttpStatus;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.util.CertificateFileUtils;
/** Shared size and shape validation for workflow credential uploads. */
public final class WorkflowUploadUtils {
public static final long MAX_CREDENTIAL_FILE_SIZE_BYTES =
CertificateFileUtils.MAX_CERTIFICATE_FILE_SIZE_BYTES;
public static final int MAX_WET_SIGNATURE_DATA_CHARS = 5 * 1024 * 1024;
private WorkflowUploadUtils() {}
public static byte[] readCredentialFile(MultipartFile file) throws IOException {
return CertificateFileUtils.read(file);
}
public static void rejectMultipleKeystores(MultipartFile p12File, MultipartFile jksFile) {
if (hasContent(p12File) && hasContent(jksFile)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Provide only one certificate keystore");
}
}
public static void validateWetSignatureDataSize(String wetSignatureData) {
if (wetSignatureData != null && wetSignatureData.length() > MAX_WET_SIGNATURE_DATA_CHARS) {
throw new ResponseStatusException(
HttpStatus.CONTENT_TOO_LARGE, "Wet signatures data exceeds the 5 MiB limit");
}
}
private static boolean hasContent(MultipartFile file) {
return file != null && !file.isEmpty();
}
}
@@ -224,6 +224,11 @@ class PolicyControllerTest {
return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run));
}
private void mockOwnedRun(String runId) {
when(jobOwnershipService.extractJobId(runId)).thenReturn(runId);
when(jobOwnershipService.createScopedJobKey(runId)).thenReturn(runId);
}
@Nested
@DisplayName("run (ad-hoc)")
class Run {
@@ -355,6 +360,7 @@ class PolicyControllerTest {
@DisplayName("returns the run view when present")
void found() {
PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null, null, null);
mockOwnedRun("run-3");
when(runRegistry.get("run-3")).thenReturn(run);
ResponseEntity<PolicyRunView> response = controller.status("run-3");
@@ -366,6 +372,7 @@ class PolicyControllerTest {
@Test
@DisplayName("returns 404 when run is unknown")
void notFound() {
mockOwnedRun("missing");
when(runRegistry.get("missing")).thenReturn(null);
when(jobOwnershipService.extractJobId("missing")).thenReturn("missing");
when(jobOwnershipService.createScopedJobKey("missing")).thenReturn("missing");
@@ -374,6 +381,18 @@ class PolicyControllerTest {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
@DisplayName("returns 404 without reading a run owned by another user")
void rejectsRunOwnedByAnotherUser() {
when(jobOwnershipService.extractJobId("bob:run-3")).thenReturn("run-3");
when(jobOwnershipService.createScopedJobKey("run-3")).thenReturn("alice:run-3");
ResponseEntity<PolicyRunView> response = controller.status("bob:run-3");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
verify(runRegistry, never()).get("bob:run-3");
}
}
@Nested
@@ -31,6 +31,9 @@ import stirling.software.proprietary.security.model.InviteToken;
import stirling.software.proprietary.security.repository.InviteTokenRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.InviteAcceptanceService;
import stirling.software.proprietary.security.service.InviteAcceptanceService.AcceptanceResult;
import stirling.software.proprietary.security.service.InviteAcceptanceService.InviteAcceptanceException;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@@ -43,6 +46,7 @@ class InviteLinkControllerMoreTest {
@Mock private UserService userService;
@Mock private EmailService emailService;
@Mock private UserLicenseSettingsService userLicenseSettingsService;
@Mock private InviteAcceptanceService inviteAcceptanceService;
private ApplicationProperties applicationProperties;
private MockMvc mockMvc;
@@ -64,7 +68,8 @@ class InviteLinkControllerMoreTest {
userService,
applicationProperties,
Optional.of(emailService),
userLicenseSettingsService);
userLicenseSettingsService,
inviteAcceptanceService);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@@ -266,9 +271,8 @@ class InviteLinkControllerMoreTest {
@Test
@DisplayName("returns 404 for an expired token")
void expiredToken() throws Exception {
InviteToken invite = validInvite("exp");
invite.setExpiresAt(LocalDateTime.now().minusHours(1));
when(inviteTokenRepository.findByToken("exp")).thenReturn(Optional.of(invite));
when(inviteAcceptanceService.accept("exp", null, "secret123"))
.thenThrow(InviteAcceptanceException.invalidInvite());
mockMvc.perform(post("/api/v1/invite/accept/exp").param("password", "secret123"))
.andExpect(status().isNotFound())
@@ -278,9 +282,11 @@ class InviteLinkControllerMoreTest {
@Test
@DisplayName("requires an email when the invite has none")
void emailRequired() throws Exception {
InviteToken invite = validInvite("noemail");
invite.setEmail(null);
when(inviteTokenRepository.findByToken("noemail")).thenReturn(Optional.of(invite));
when(inviteAcceptanceService.accept("noemail", null, "secret123"))
.thenThrow(
new InviteAcceptanceException(
org.springframework.http.HttpStatus.BAD_REQUEST,
"Email address is required"));
mockMvc.perform(post("/api/v1/invite/accept/noemail").param("password", "secret123"))
.andExpect(status().isBadRequest())
@@ -290,18 +296,14 @@ class InviteLinkControllerMoreTest {
@Test
@DisplayName("creates the account using the pre-set email")
void createsWithPresetEmail() throws Exception {
InviteToken invite = validInvite("preset");
invite.setEmail("preset@ex.com");
invite.setTeamId(3L);
when(inviteTokenRepository.findByToken("preset")).thenReturn(Optional.of(invite));
when(userService.usernameExistsIgnoreCase("preset@ex.com")).thenReturn(false);
when(inviteAcceptanceService.accept("preset", null, "secret123"))
.thenReturn(new AcceptanceResult("preset@ex.com", Role.USER.getRoleId()));
mockMvc.perform(post("/api/v1/invite/accept/preset").param("password", "secret123"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("preset@ex.com"));
verify(userService).saveUserCore(any());
verify(inviteTokenRepository).save(invite);
verify(inviteAcceptanceService).accept("preset", null, "secret123");
}
}
}
@@ -29,6 +29,8 @@ import stirling.software.proprietary.security.model.InviteToken;
import stirling.software.proprietary.security.repository.InviteTokenRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.InviteAcceptanceService;
import stirling.software.proprietary.security.service.InviteAcceptanceService.AcceptanceResult;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@@ -41,6 +43,7 @@ class InviteLinkControllerTest {
@Mock private UserService userService;
@Mock private EmailService emailService;
@Mock private UserLicenseSettingsService userLicenseSettingsService;
@Mock private InviteAcceptanceService inviteAcceptanceService;
private ApplicationProperties applicationProperties;
private MockMvc mockMvc;
@@ -62,7 +65,8 @@ class InviteLinkControllerTest {
userService,
applicationProperties,
Optional.of(emailService),
userLicenseSettingsService);
userLicenseSettingsService,
inviteAcceptanceService);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@@ -165,14 +169,8 @@ class InviteLinkControllerTest {
@Test
void acceptInviteCreatesUserWhenEmailProvided() throws Exception {
InviteToken invite = new InviteToken();
invite.setToken("abc");
invite.setExpiresAt(LocalDateTime.now().plusHours(2));
invite.setRole(Role.USER.getRoleId());
invite.setUsed(false);
invite.setEmail(null); // email required from request
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(invite));
when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false);
when(inviteAcceptanceService.accept("abc", "new@example.com", "password123"))
.thenReturn(new AcceptanceResult("new@example.com", Role.USER.getRoleId()));
mockMvc.perform(
post("/api/v1/invite/accept/abc")
@@ -182,7 +180,6 @@ class InviteLinkControllerTest {
.andExpect(jsonPath("$.message").value("Account created successfully"))
.andExpect(jsonPath("$.username").value("new@example.com"));
verify(userService).saveUserCore(any());
verify(inviteTokenRepository).save(invite);
verify(inviteAcceptanceService).accept("abc", "new@example.com", "password123");
}
}
@@ -0,0 +1,152 @@
package stirling.software.proprietary.security.filter;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import jakarta.servlet.FilterChain;
class ParticipantRequestSecurityFilterTest {
private static final String VALIDATE_PATH = "/api/v1/workflow/participant/validate-certificate";
private final ParticipantRequestSecurityFilter filter = new ParticipantRequestSecurityFilter();
@Test
void oversizedUpload_isRejectedBeforeFilterChain() throws Exception {
MockHttpServletRequest request = multipartRequest(VALIDATE_PATH);
request.setContent(
new byte
[(int) ParticipantRequestSecurityFilter.MAX_MULTIPART_REQUEST_SIZE_BYTES
+ 1]);
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
filter.doFilter(request, response, chain);
assertThat(response.getStatus()).isEqualTo(413);
verify(chain, never()).doFilter(request, response);
}
@Test
void uploadWithoutContentLength_isRejectedBeforeFilterChain() throws Exception {
MockHttpServletRequest request = multipartRequest(VALIDATE_PATH);
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
filter.doFilter(request, response, chain);
assertThat(response.getStatus()).isEqualTo(411);
verify(chain, never()).doFilter(request, response);
}
@Test
void boundedUpload_reachesFilterChain() throws Exception {
MockHttpServletRequest request = multipartRequest(VALIDATE_PATH);
request.setContent(new byte[1024]);
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
filter.doFilter(request, response, chain);
verify(chain).doFilter(request, response);
}
@Test
void authenticatedCertificateValidation_isLimitedBeforeFilterChain() throws Exception {
MockHttpServletRequest request =
multipartRequest("/api/v1/security/cert-sign/validate-certificate");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
filter.doFilter(request, response, chain);
assertThat(response.getStatus()).isEqualTo(411);
verify(chain, never()).doFilter(request, response);
}
@Test
void authenticatedSignRequest_isLimitedBeforeFilterChain() throws Exception {
MockHttpServletRequest request =
multipartRequest("/api/v1/security/cert-sign/sign-requests/session-1/sign");
request.setContent(
new byte
[(int) ParticipantRequestSecurityFilter.MAX_MULTIPART_REQUEST_SIZE_BYTES
+ 1]);
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
filter.doFilter(request, response, chain);
assertThat(response.getStatus()).isEqualTo(413);
verify(chain, never()).doFilter(request, response);
}
@Test
void encodedParticipantUpload_isStillLimited() throws Exception {
MockHttpServletRequest request =
multipartRequest("/api/v1/workflow/participant/validate-certificat%65");
request.setContent(
new byte
[(int) ParticipantRequestSecurityFilter.MAX_MULTIPART_REQUEST_SIZE_BYTES
+ 1]);
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
filter.doFilter(request, response, chain);
assertThat(response.getStatus()).isEqualTo(413);
verify(chain, never()).doFilter(request, response);
}
@Test
void oversizedMobileUpload_isRejectedBeforeMultipartParsing() throws Exception {
MockHttpServletRequest request =
new MockHttpServletRequest("POST", "/api/v1/mobile-scanner/upload/session-1") {
@Override
public long getContentLengthLong() {
return ParticipantRequestSecurityFilter.MAX_MOBILE_UPLOAD_REQUEST_SIZE_BYTES
+ 1;
}
};
request.setContentType("multipart/form-data; boundary=test");
request.setRemoteAddr("192.0.2.1");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
filter.doFilter(request, response, chain);
assertThat(response.getStatus()).isEqualTo(413);
verify(chain, never()).doFilter(request, response);
}
@Test
void rateLimit_isAppliedBeforeParticipantRequestHandling() throws Exception {
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
for (int requestNumber = 1; requestNumber <= 21; requestNumber++) {
MockHttpServletRequest request =
new MockHttpServletRequest("GET", "/api/v1/workflow/participant/details");
request.setRemoteAddr("192.0.2.10");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, chain);
if (requestNumber == 21) {
assertThat(response.getStatus()).isEqualTo(429);
assertThat(response.getHeader("Retry-After")).isEqualTo("60");
}
}
}
private MockHttpServletRequest multipartRequest(String path) {
MockHttpServletRequest request = new MockHttpServletRequest("POST", path);
request.setContentType("multipart/form-data; boundary=test");
request.setRemoteAddr("192.0.2.1");
return request;
}
}
@@ -0,0 +1,96 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.transaction.annotation.Transactional;
import jakarta.persistence.LockModeType;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.security.model.InviteToken;
import stirling.software.proprietary.security.repository.InviteTokenRepository;
import stirling.software.proprietary.security.service.InviteAcceptanceService.AcceptanceResult;
import stirling.software.proprietary.security.service.InviteAcceptanceService.InviteAcceptanceException;
@ExtendWith(MockitoExtension.class)
class InviteAcceptanceServiceTest {
@Mock private InviteTokenRepository inviteTokenRepository;
@Mock private UserService userService;
@InjectMocks private InviteAcceptanceService service;
@Test
void consumesInviteAndCreatesUserWhileHoldingLockedToken() throws Exception {
InviteToken invite = validInvite("token");
invite.setTeamId(7L);
when(inviteTokenRepository.findByTokenForUpdate("token")).thenReturn(Optional.of(invite));
when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false);
AcceptanceResult result = service.accept("token", "NEW@example.com", "password123");
assertEquals("new@example.com", result.username());
assertTrue(invite.isUsed());
ArgumentCaptor<SaveUserRequest> request = ArgumentCaptor.forClass(SaveUserRequest.class);
verify(userService).saveUserCore(request.capture());
assertEquals("new@example.com", request.getValue().getUsername());
assertEquals(7L, request.getValue().getTeamId());
verify(inviteTokenRepository).save(invite);
}
@Test
void rejectsInviteThatWasConsumedByEarlierRequest() throws Exception {
InviteToken invite = validInvite("token");
invite.setUsed(true);
when(inviteTokenRepository.findByTokenForUpdate("token")).thenReturn(Optional.of(invite));
InviteAcceptanceException exception =
assertThrows(
InviteAcceptanceException.class,
() -> service.accept("token", "second@example.com", "password123"));
assertEquals(org.springframework.http.HttpStatus.NOT_FOUND, exception.getStatus());
verify(userService, never()).saveUserCore(any());
}
@Test
void repositoryLookupUsesPessimisticWriteLock() throws Exception {
Method method = InviteTokenRepository.class.getMethod("findByTokenForUpdate", String.class);
assertEquals(LockModeType.PESSIMISTIC_WRITE, method.getAnnotation(Lock.class).value());
}
@Test
void acceptanceRollsBackForCheckedFailures() throws Exception {
Method method =
InviteAcceptanceService.class.getMethod(
"accept", String.class, String.class, String.class);
Transactional transactional = method.getAnnotation(Transactional.class);
assertTrue(java.util.List.of(transactional.rollbackFor()).contains(Exception.class));
}
private static InviteToken validInvite(String token) {
InviteToken invite = new InviteToken();
invite.setToken(token);
invite.setExpiresAt(LocalDateTime.now().plusHours(1));
invite.setRole(Role.USER.getRoleId());
return invite;
}
}
@@ -35,8 +35,10 @@ import stirling.software.proprietary.workflow.dto.CertificateInfo;
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
import stirling.software.proprietary.workflow.service.SigningFinalizationService;
import stirling.software.proprietary.workflow.service.WorkflowFinalizationCoordinator;
import stirling.software.proprietary.workflow.service.WorkflowFinalizationCoordinator.FinalizedWorkflow;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
import stirling.software.proprietary.workflow.util.WorkflowUploadUtils;
// Direct handler invocations (no MockMvc) for SigningSessionController; previously 0% covered.
@ExtendWith(MockitoExtension.class)
@@ -45,7 +47,7 @@ class SigningSessionControllerTest {
@Mock private WorkflowSessionService workflowSessionService;
@Mock private UserService userService;
@Mock private SigningFinalizationService signingFinalizationService;
@Mock private WorkflowFinalizationCoordinator workflowFinalizationCoordinator;
@Mock private CertificateSubmissionValidator certificateSubmissionValidator;
private SigningSessionController controller;
@@ -56,7 +58,7 @@ class SigningSessionControllerTest {
new SigningSessionController(
workflowSessionService,
userService,
signingFinalizationService,
workflowFinalizationCoordinator,
certificateSubmissionValidator);
}
@@ -382,26 +384,22 @@ class SigningSessionControllerTest {
@Test
void success_returnsSignedPdf() throws Exception {
User owner = user("alice");
WorkflowSession session = ownedSession("s1", owner);
when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner));
when(workflowSessionService.getSessionWithParticipantsForOwner("s1", owner))
.thenReturn(session);
when(workflowSessionService.getOriginalFile("s1")).thenReturn(new byte[] {1});
when(signingFinalizationService.finalizeDocument(eq(session), any()))
.thenReturn(new byte[] {2, 3});
when(workflowFinalizationCoordinator.finalizeSession("s1", owner))
.thenReturn(new FinalizedWorkflow(new byte[] {2, 3}, "doc_shared_signed.pdf"));
ResponseEntity<byte[]> response = controller.finalizeSession("s1", principal("alice"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(workflowSessionService).finalizeSession("s1", owner);
verify(workflowSessionService).deleteOriginalFile(session);
assertThat(response.getBody()).containsExactly(2, 3);
verify(workflowFinalizationCoordinator).finalizeSession("s1", owner);
}
@Test
void serviceError_returns500() throws Exception {
User owner = user("alice");
when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner));
when(workflowSessionService.getSessionWithParticipantsForOwner("s1", owner))
when(workflowFinalizationCoordinator.finalizeSession("s1", owner))
.thenThrow(new RuntimeException("boom"));
assertThat(controller.finalizeSession("s1", principal("alice")).getStatusCode())
@@ -672,5 +670,25 @@ class SigningSessionControllerTest {
assertThat(response.getBody().valid()).isFalse();
assertThat(response.getBody().error()).isEqualTo("bad password");
}
@Test
void oversizedCertificate_throwsPayloadTooLargeBeforeValidation() {
MockMultipartFile p12 =
new MockMultipartFile(
"p12File",
"c.p12",
"application/octet-stream",
new byte[(int) WorkflowUploadUtils.MAX_CREDENTIAL_FILE_SIZE_BYTES + 1]);
org.assertj.core.api.Assertions.assertThatThrownBy(
() ->
controller.validateCertificate(
"P12", "pw", p12, null, principal("alice")))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.CONTENT_TOO_LARGE);
org.mockito.Mockito.verifyNoInteractions(certificateSubmissionValidator);
}
}
}
@@ -13,10 +13,13 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.proprietary.security.model.User;
@@ -32,6 +35,7 @@ import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepo
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
import stirling.software.proprietary.workflow.service.MetadataEncryptionService;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
import stirling.software.proprietary.workflow.util.WorkflowUploadUtils;
import tools.jackson.databind.ObjectMapper;
@@ -136,6 +140,25 @@ class WorkflowParticipantControllerMoreTest {
org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.any());
}
@ParameterizedTest
@EnumSource(
value = WorkflowStatus.class,
names = {"COMPLETED", "CANCELLED"})
void inactiveSession_isReturnedWithoutUpdatingParticipantStatus(
WorkflowStatus workflowStatus) {
WorkflowParticipant p = participant(ParticipantStatus.PENDING);
p.getWorkflowSession().setStatus(workflowStatus);
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
ResponseEntity<WorkflowSessionResponse> response = controller.getSessionByToken(TOKEN);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(workflowSessionService, org.mockito.Mockito.never())
.updateParticipantStatus(
org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.any());
}
}
// -------------------------------------------------------------------------
@@ -165,6 +188,19 @@ class WorkflowParticipantControllerMoreTest {
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
@DisplayName("getParticipantDetails expired token throws 403")
void getParticipantDetails_expiredToken() {
WorkflowParticipant p = participant(ParticipantStatus.VIEWED);
p.setExpiresAt(java.time.LocalDateTime.now().minusDays(1));
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
assertThatThrownBy(() -> controller.getParticipantDetails(TOKEN))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
}
// -------------------------------------------------------------------------
// submitSignature
// -------------------------------------------------------------------------
@@ -239,6 +275,52 @@ class WorkflowParticipantControllerMoreTest {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(p.getStatus()).isEqualTo(ParticipantStatus.SIGNED);
}
@Test
void oversizedCertificate_throwsPayloadTooLargeWithoutReadingFile() throws Exception {
WorkflowParticipant p = participant(ParticipantStatus.PENDING);
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
MultipartFile certificateFile = org.mockito.Mockito.mock(MultipartFile.class);
when(certificateFile.getSize())
.thenReturn(WorkflowUploadUtils.MAX_CREDENTIAL_FILE_SIZE_BYTES + 1);
SignatureSubmissionRequest r = request(TOKEN);
r.setCertType("P12");
r.setP12File(certificateFile);
assertThatThrownBy(() -> controller.submitSignature(r))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.CONTENT_TOO_LARGE);
verify(certificateFile, org.mockito.Mockito.never()).getBytes();
verify(participantRepository, org.mockito.Mockito.never()).save(p);
}
@Test
void certificateFile_isReadOnlyOnceForValidationAndEncryption() throws Exception {
WorkflowParticipant p = participant(ParticipantStatus.PENDING);
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
when(participantRepository.save(org.mockito.ArgumentMatchers.any()))
.thenAnswer(invocation -> invocation.getArgument(0));
MultipartFile certificateFile = org.mockito.Mockito.mock(MultipartFile.class);
byte[] certificateBytes =
"certificate".getBytes(java.nio.charset.StandardCharsets.UTF_8);
when(certificateFile.getSize()).thenReturn((long) certificateBytes.length);
when(certificateFile.getBytes()).thenReturn(certificateBytes);
when(metadataEncryptionService.encryptBytes(certificateBytes)).thenReturn("encrypted");
SignatureSubmissionRequest r = request(TOKEN);
r.setCertType("P12");
r.setP12File(certificateFile);
controller.submitSignature(r);
verify(certificateFile).getBytes();
verify(certificateSubmissionValidator)
.validateAndExtractInfo(certificateBytes, "P12", null);
verify(metadataEncryptionService).encryptBytes(certificateBytes);
}
}
// -------------------------------------------------------------------------
@@ -259,6 +341,20 @@ class WorkflowParticipantControllerMoreTest {
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void expiredToken_throwsForbiddenWithoutChangingParticipant() {
WorkflowParticipant p = participant(ParticipantStatus.PENDING);
p.setExpiresAt(java.time.LocalDateTime.now().minusDays(1));
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
assertThatThrownBy(() -> controller.declineParticipation(TOKEN, null))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
assertThat(p.getStatus()).isEqualTo(ParticipantStatus.PENDING);
verify(participantRepository, org.mockito.Mockito.never()).save(p);
}
@Test
void alreadyCompleted_throwsBadRequest() {
WorkflowParticipant p = participant(ParticipantStatus.DECLINED);
@@ -270,6 +366,29 @@ class WorkflowParticipantControllerMoreTest {
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@ParameterizedTest
@EnumSource(
value = WorkflowStatus.class,
names = {"COMPLETED", "CANCELLED"})
void inactiveSession_throwsBadRequestWithoutChangingParticipant(
WorkflowStatus workflowStatus) {
WorkflowParticipant p = participant(ParticipantStatus.PENDING);
p.getWorkflowSession().setStatus(workflowStatus);
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
assertThatThrownBy(() -> controller.declineParticipation(TOKEN, "reason"))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(p.getStatus()).isEqualTo(ParticipantStatus.PENDING);
verify(workflowSessionService, org.mockito.Mockito.never())
.addParticipantNotification(
org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyString());
verify(participantRepository, org.mockito.Mockito.never()).save(p);
}
@Test
void withReason_setsDeclinedAndNotifies() {
WorkflowParticipant p = participant(ParticipantStatus.PENDING);
@@ -27,6 +27,7 @@ import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepo
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
import stirling.software.proprietary.workflow.service.MetadataEncryptionService;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
import stirling.software.proprietary.workflow.util.WorkflowUploadUtils;
import tools.jackson.databind.ObjectMapper;
@@ -138,6 +139,51 @@ class WorkflowParticipantValidateCertificateTest {
.andExpect(status().isBadRequest());
}
@Test
void oversizedCertificate_returns413BeforeValidation() throws Exception {
when(participantRepository.findByShareToken(VALID_TOKEN))
.thenReturn(Optional.of(activeParticipant()));
MockMultipartFile certFile =
new MockMultipartFile(
"p12File",
"cert.p12",
"application/octet-stream",
new byte[(int) WorkflowUploadUtils.MAX_CREDENTIAL_FILE_SIZE_BYTES + 1]);
mockMvc.perform(
multipart("/api/v1/workflow/participant/validate-certificate")
.file(certFile)
.param("participantToken", VALID_TOKEN)
.param("certType", "P12")
.param("password", "pass"))
.andExpect(status().isPayloadTooLarge());
org.mockito.Mockito.verifyNoInteractions(certificateSubmissionValidator);
}
@Test
void multipleCertificateFiles_returns400() throws Exception {
when(participantRepository.findByShareToken(VALID_TOKEN))
.thenReturn(Optional.of(activeParticipant()));
MockMultipartFile p12File =
new MockMultipartFile(
"p12File", "cert.p12", "application/octet-stream", DUMMY_CERT);
MockMultipartFile jksFile =
new MockMultipartFile(
"jksFile", "cert.jks", "application/octet-stream", DUMMY_CERT);
mockMvc.perform(
multipart("/api/v1/workflow/participant/validate-certificate")
.file(p12File)
.file(jksFile)
.param("participantToken", VALID_TOKEN)
.param("certType", "P12")
.param("password", "pass"))
.andExpect(status().isBadRequest());
}
// ---- Invalid / expired token → 403 ----
@Test
@@ -82,8 +82,17 @@ class UserServerCertificateServiceTest {
String stored = captor.getValue().getKeystorePassword();
assertThat(stored).startsWith(MetadataEncryptionService.ENC_PREFIX);
// The raw predictable prefix must not appear in the stored value
assertThat(stored).doesNotContain("stirling-user-cert-");
String generatedPassword = encryptionService.decrypt(stored);
assertThat(generatedPassword)
.hasSize(43)
.doesNotStartWith("stirling-user-cert-")
.isNotEqualTo("stirling-user-cert-" + user.getId());
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(
new ByteArrayInputStream(captor.getValue().getKeystoreData()),
generatedPassword.toCharArray());
assertThat(keyStore.aliases().hasMoreElements()).isTrue();
}
// -------------------------------------------------------------------------
@@ -0,0 +1,100 @@
package stirling.software.proprietary.workflow.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.model.WorkflowStatus;
import stirling.software.proprietary.workflow.service.WorkflowFinalizationCoordinator.FinalizedWorkflow;
@ExtendWith(MockitoExtension.class)
class WorkflowFinalizationCoordinatorTest {
@Mock private WorkflowSessionService workflowSessionService;
@Mock private SigningFinalizationService signingFinalizationService;
@Test
void inactiveSession_isRejectedBeforePdfProcessing() throws Exception {
User owner = owner();
WorkflowSession session = session();
session.setStatus(WorkflowStatus.COMPLETED);
when(workflowSessionService.getSessionWithParticipantsForOwnerForUpdate("s1", owner))
.thenReturn(session);
WorkflowFinalizationCoordinator coordinator = coordinator();
assertThatThrownBy(() -> coordinator.finalizeSession("s1", owner))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
verify(signingFinalizationService, never())
.finalizeDocument(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
verify(workflowSessionService, never())
.storeProcessedFile(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any());
}
@Test
void activeSession_isLockedBeforeProcessingAndCompletedInOrder() throws Exception {
User owner = owner();
WorkflowSession session = session();
byte[] original = new byte[] {1};
byte[] finalized = new byte[] {2};
when(workflowSessionService.getSessionWithParticipantsForOwnerForUpdate("s1", owner))
.thenReturn(session);
when(workflowSessionService.getOriginalFile("s1")).thenReturn(original);
when(signingFinalizationService.finalizeDocument(session, original)).thenReturn(finalized);
WorkflowFinalizationCoordinator coordinator = coordinator();
FinalizedWorkflow result = coordinator.finalizeSession("s1", owner);
assertThat(result.pdf()).isSameAs(finalized);
assertThat(result.filename()).isEqualTo("document_shared_signed.pdf");
InOrder order = inOrder(workflowSessionService, signingFinalizationService);
order.verify(workflowSessionService)
.getSessionWithParticipantsForOwnerForUpdate("s1", owner);
order.verify(workflowSessionService).getOriginalFile("s1");
order.verify(signingFinalizationService).finalizeDocument(session, original);
order.verify(workflowSessionService)
.storeProcessedFile(session, finalized, "document_shared_signed.pdf");
order.verify(workflowSessionService).finalizeSession("s1", owner);
order.verify(signingFinalizationService).clearSensitiveMetadata(session);
order.verify(workflowSessionService).deleteOriginalFile(session);
}
private WorkflowFinalizationCoordinator coordinator() {
return new WorkflowFinalizationCoordinator(
workflowSessionService, signingFinalizationService);
}
private User owner() {
User owner = new User();
owner.setId(1L);
owner.setUsername("owner");
return owner;
}
private WorkflowSession session() {
WorkflowSession session = new WorkflowSession();
session.setSessionId("s1");
session.setDocumentName("document.pdf");
session.setStatus(WorkflowStatus.IN_PROGRESS);
return session;
}
}
@@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.IOException;
@@ -297,6 +298,21 @@ class WorkflowSessionServiceMoreTest {
@DisplayName("removeParticipant")
class RemoveParticipant {
@Test
void inactiveSession_throwsBadRequestWithoutDeletingAuditRecord() {
User owner = user("alice", 1L);
WorkflowSession s = session("s1", owner);
s.setStatus(WorkflowStatus.COMPLETED);
when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s));
assertThatThrownBy(() -> service.removeParticipant("s1", 5L, owner))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
verify(workflowParticipantRepository, never()).delete(any());
}
@Test
void participantNotFound_throwsNotFound() {
User owner = user("alice", 1L);
@@ -534,6 +550,23 @@ class WorkflowSessionServiceMoreTest {
verify(workflowParticipantRepository).save(p);
}
@Test
void getSignRequestDetail_inactiveSessionDoesNotTransitionToViewed() {
User user = user("alice", 1L);
User owner = user("owner", 2L);
WorkflowSession s = session("s1", owner);
s.setCreatedAt(LocalDateTime.now());
s.setStatus(WorkflowStatus.COMPLETED);
WorkflowParticipant p = participant(user, ParticipantStatus.NOTIFIED);
s.addParticipant(p);
when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s));
service.getSignRequestDetail("s1", user);
assertThat(p.getStatus()).isEqualTo(ParticipantStatus.NOTIFIED);
verify(workflowParticipantRepository, never()).save(p);
}
@Test
void getSignRequestDetail_readsAppearanceFromMetadata() {
User user = user("alice", 1L);
@@ -600,6 +633,23 @@ class WorkflowSessionServiceMoreTest {
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void getSignRequestDocument_expiredParticipantThrowsForbidden() {
User user = user("alice", 1L);
WorkflowSession s = session("s1", user("owner", 2L));
WorkflowParticipant p = participant(user, ParticipantStatus.PENDING);
p.setExpiresAt(LocalDateTime.now().minusMinutes(1));
s.addParticipant(p);
when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s));
assertThatThrownBy(() -> service.getSignRequestDocument("s1", user))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(storageProvider);
}
}
// -------------------------------------------------------------------------
@@ -610,6 +660,24 @@ class WorkflowSessionServiceMoreTest {
@DisplayName("declineSignRequest")
class DeclineSignRequest {
@Test
void inactiveSessionDoesNotChangeParticipant() {
User user = user("alice", 1L);
WorkflowSession s = session("s1", user("owner", 2L));
s.setStatus(WorkflowStatus.CANCELLED);
WorkflowParticipant p = participant(user, ParticipantStatus.PENDING);
s.addParticipant(p);
when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s));
assertThatThrownBy(() -> service.declineSignRequest("s1", user))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(p.getStatus()).isEqualTo(ParticipantStatus.PENDING);
verify(workflowParticipantRepository, never()).save(any());
}
@Test
void alreadySigned_throwsBadRequest() {
User user = user("alice", 1L);
@@ -21,6 +21,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
@@ -42,6 +43,7 @@ import stirling.software.proprietary.workflow.model.WorkflowStatus;
import stirling.software.proprietary.workflow.model.WorkflowType;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
import stirling.software.proprietary.workflow.util.WorkflowUploadUtils;
import tools.jackson.databind.ObjectMapper;
@@ -56,6 +58,7 @@ class WorkflowSessionServiceTest {
@Mock private ObjectMapper objectMapper;
@Mock private ApplicationProperties applicationProperties;
@Mock private MetadataEncryptionService metadataEncryptionService;
@Mock private CertificateSubmissionValidator certificateSubmissionValidator;
@InjectMocks private WorkflowSessionService service;
@@ -111,6 +114,86 @@ class WorkflowSessionServiceTest {
assertThat(captor.getValue().getStatus()).isEqualTo(ParticipantStatus.SIGNED);
}
@Test
void signDocument_inactiveSessionDoesNotChangeParticipant() {
User user = user("alice");
WorkflowParticipant participant = pendingParticipant(user);
WorkflowSession session = sessionWithParticipant("inactive", participant);
session.setStatus(WorkflowStatus.COMPLETED);
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("SERVER");
assertThatThrownBy(() -> service.signDocument("inactive", user, req))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(participant.getStatus()).isEqualTo(ParticipantStatus.PENDING);
verify(workflowParticipantRepository, never()).save(any());
}
@Test
void signDocument_oversizedCredentialIsRejectedWithoutReadingIt() throws Exception {
User user = user("alice");
WorkflowParticipant participant = pendingParticipant(user);
sessionWithParticipant("oversized", participant);
MultipartFile credential = mock(MultipartFile.class);
when(credential.getSize())
.thenReturn(WorkflowUploadUtils.MAX_CREDENTIAL_FILE_SIZE_BYTES + 1);
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("PKCS12");
req.setP12File(credential);
assertThatThrownBy(() -> service.signDocument("oversized", user, req))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.CONTENT_TOO_LARGE);
verify(credential, never()).getBytes();
verify(workflowParticipantRepository, never()).save(any());
}
@Test
void signDocument_readsCredentialOnlyOnceForValidationAndEncryption() throws Exception {
User user = user("alice");
WorkflowParticipant participant = pendingParticipant(user);
sessionWithParticipant("single-read", participant);
MultipartFile credential = mock(MultipartFile.class);
byte[] bytes = new byte[] {1, 2, 3};
when(credential.getSize()).thenReturn((long) bytes.length);
when(credential.getBytes()).thenReturn(bytes);
when(metadataEncryptionService.encryptBytes(bytes)).thenReturn("encrypted");
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("PKCS12");
req.setP12File(credential);
service.signDocument("single-read", user, req);
verify(credential).getBytes();
verify(certificateSubmissionValidator).validateAndExtractInfo(bytes, "PKCS12", null);
verify(metadataEncryptionService).encryptBytes(bytes);
}
@Test
void signDocument_oversizedWetSignatureDataIsRejected() {
User user = user("alice");
WorkflowParticipant participant = pendingParticipant(user);
sessionWithParticipant("wet-signature", participant);
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("SERVER");
req.setWetSignaturesData("x".repeat(WorkflowUploadUtils.MAX_WET_SIGNATURE_DATA_CHARS + 1));
assertThatThrownBy(() -> service.signDocument("wet-signature", user, req))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.CONTENT_TOO_LARGE);
verify(workflowParticipantRepository, never()).save(any());
}
// -------------------------------------------------------------------------
// signDocument — certificate metadata
// -------------------------------------------------------------------------
@@ -27,11 +27,11 @@ const databaseManagementService = {
},
async createBackup(): Promise<void> {
await apiClient.get("/api/v1/database/createDatabaseBackup");
await apiClient.post("/api/v1/database/createDatabaseBackup");
},
async importFromFileName(fileName: string): Promise<void> {
await apiClient.get(
await apiClient.post(
`/api/v1/database/import-database-file/${encodeURIComponent(fileName)}`,
);
},
@@ -44,7 +44,7 @@ const databaseManagementService = {
},
async deleteBackup(fileName: string): Promise<void> {
await apiClient.get(
await apiClient.delete(
`/api/v1/database/delete/${encodeURIComponent(fileName)}`,
);
},