mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
fix(workflow): stop leaking peer share tokens from participant session API (#6241)
This commit is contained in:
+6
-4
@@ -101,7 +101,9 @@ public class WorkflowParticipantController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WorkflowSession session = participant.getWorkflowSession();
|
WorkflowSession session = participant.getWorkflowSession();
|
||||||
return ResponseEntity.ok(WorkflowMapper.toResponse(session));
|
// 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
@@ -122,7 +124,7 @@ public class WorkflowParticipantController {
|
|||||||
HttpStatus.FORBIDDEN,
|
HttpStatus.FORBIDDEN,
|
||||||
"Invalid or expired participant token"));
|
"Invalid or expired participant token"));
|
||||||
|
|
||||||
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
|
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
@@ -181,7 +183,7 @@ public class WorkflowParticipantController {
|
|||||||
participant.getEmail(),
|
participant.getEmail(),
|
||||||
participant.getWorkflowSession().getSessionId());
|
participant.getWorkflowSession().getSessionId());
|
||||||
|
|
||||||
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
|
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false));
|
||||||
|
|
||||||
} catch (ResponseStatusException e) {
|
} catch (ResponseStatusException e) {
|
||||||
throw e;
|
throw e;
|
||||||
@@ -235,7 +237,7 @@ public class WorkflowParticipantController {
|
|||||||
participant.getEmail(),
|
participant.getEmail(),
|
||||||
participant.getWorkflowSession().getSessionId());
|
participant.getWorkflowSession().getSessionId());
|
||||||
|
|
||||||
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
|
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
|
|||||||
+48
-6
@@ -21,7 +21,7 @@ public class WorkflowMapper {
|
|||||||
|
|
||||||
/** Converts a WorkflowSession entity to a response DTO. */
|
/** Converts a WorkflowSession entity to a response DTO. */
|
||||||
public static WorkflowSessionResponse toResponse(WorkflowSession session) {
|
public static WorkflowSessionResponse toResponse(WorkflowSession session) {
|
||||||
return toResponse(session, null);
|
return toResponse(session, null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +34,21 @@ public class WorkflowMapper {
|
|||||||
*/
|
*/
|
||||||
public static WorkflowSessionResponse toResponse(
|
public static WorkflowSessionResponse toResponse(
|
||||||
WorkflowSession session, ObjectMapper objectMapper) {
|
WorkflowSession session, ObjectMapper objectMapper) {
|
||||||
|
return toResponse(session, objectMapper, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a WorkflowSession entity to a response DTO.
|
||||||
|
*
|
||||||
|
* @param session The workflow session entity
|
||||||
|
* @param objectMapper ObjectMapper for JSON processing (null to skip wet signature extraction)
|
||||||
|
* @param includeShareTokens Whether to include each participant's share token in the response.
|
||||||
|
* Owner-facing endpoints set this to true so the owner can distribute share links;
|
||||||
|
* participant-facing endpoints set this to false so a single participant's token cannot be
|
||||||
|
* used to enumerate peer bearer tokens (GHSA-qgg6-mxw4-xg62).
|
||||||
|
*/
|
||||||
|
public static WorkflowSessionResponse toResponse(
|
||||||
|
WorkflowSession session, ObjectMapper objectMapper, boolean includeShareTokens) {
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -64,12 +79,12 @@ public class WorkflowMapper {
|
|||||||
if (objectMapper != null) {
|
if (objectMapper != null) {
|
||||||
response.setParticipants(
|
response.setParticipants(
|
||||||
session.getParticipants().stream()
|
session.getParticipants().stream()
|
||||||
.map(p -> toParticipantResponse(p, objectMapper))
|
.map(p -> toParticipantResponse(p, objectMapper, includeShareTokens))
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
} else {
|
} else {
|
||||||
response.setParticipants(
|
response.setParticipants(
|
||||||
session.getParticipants().stream()
|
session.getParticipants().stream()
|
||||||
.map(WorkflowMapper::toParticipantResponse)
|
.map(p -> toParticipantResponse(p, includeShareTokens))
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +103,21 @@ public class WorkflowMapper {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Converts a WorkflowParticipant entity to a response DTO. */
|
/** Converts a WorkflowParticipant entity to a response DTO, including the share token. */
|
||||||
public static ParticipantResponse toParticipantResponse(WorkflowParticipant participant) {
|
public static ParticipantResponse toParticipantResponse(WorkflowParticipant participant) {
|
||||||
|
return toParticipantResponse(participant, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a WorkflowParticipant entity to a response DTO.
|
||||||
|
*
|
||||||
|
* @param participant The participant entity
|
||||||
|
* @param includeShareToken Whether to include the participant's share token in the response.
|
||||||
|
* Owner-facing endpoints set this to true; participant-facing endpoints set this to false
|
||||||
|
* so the response cannot be used to enumerate peer bearer tokens (GHSA-qgg6-mxw4-xg62).
|
||||||
|
*/
|
||||||
|
public static ParticipantResponse toParticipantResponse(
|
||||||
|
WorkflowParticipant participant, boolean includeShareToken) {
|
||||||
if (participant == null) {
|
if (participant == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -102,7 +130,9 @@ public class WorkflowMapper {
|
|||||||
response.setEmail(participant.getEmail());
|
response.setEmail(participant.getEmail());
|
||||||
response.setName(participant.getName());
|
response.setName(participant.getName());
|
||||||
response.setStatus(participant.getStatus());
|
response.setStatus(participant.getStatus());
|
||||||
response.setShareToken(participant.getShareToken());
|
if (includeShareToken) {
|
||||||
|
response.setShareToken(participant.getShareToken());
|
||||||
|
}
|
||||||
response.setAccessRole(participant.getAccessRole());
|
response.setAccessRole(participant.getAccessRole());
|
||||||
response.setExpiresAt(participant.getExpiresAt());
|
response.setExpiresAt(participant.getExpiresAt());
|
||||||
response.setLastUpdated(participant.getLastUpdated());
|
response.setLastUpdated(participant.getLastUpdated());
|
||||||
@@ -122,7 +152,19 @@ public class WorkflowMapper {
|
|||||||
*/
|
*/
|
||||||
public static ParticipantResponse toParticipantResponse(
|
public static ParticipantResponse toParticipantResponse(
|
||||||
WorkflowParticipant participant, ObjectMapper objectMapper) {
|
WorkflowParticipant participant, ObjectMapper objectMapper) {
|
||||||
ParticipantResponse response = toParticipantResponse(participant);
|
return toParticipantResponse(participant, objectMapper, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a WorkflowParticipant entity to a response DTO with wet signatures extracted.
|
||||||
|
*
|
||||||
|
* @param participant The participant entity
|
||||||
|
* @param objectMapper ObjectMapper for JSON processing
|
||||||
|
* @param includeShareToken Whether to include the participant's share token in the response.
|
||||||
|
*/
|
||||||
|
public static ParticipantResponse toParticipantResponse(
|
||||||
|
WorkflowParticipant participant, ObjectMapper objectMapper, boolean includeShareToken) {
|
||||||
|
ParticipantResponse response = toParticipantResponse(participant, includeShareToken);
|
||||||
if (response != null) {
|
if (response != null) {
|
||||||
response.setWetSignatures(extractWetSignatures(participant, objectMapper));
|
response.setWetSignatures(extractWetSignatures(participant, objectMapper));
|
||||||
}
|
}
|
||||||
|
|||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
package stirling.software.proprietary.workflow.util;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.proprietary.storage.model.ShareAccessRole;
|
||||||
|
import stirling.software.proprietary.storage.model.StoredFile;
|
||||||
|
import stirling.software.proprietary.workflow.dto.ParticipantResponse;
|
||||||
|
import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse;
|
||||||
|
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.model.WorkflowType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test for GHSA-qgg6-mxw4-xg62 — verifies that the {@code includeShareToken(s)} flag
|
||||||
|
* controls whether {@link WorkflowMapper} discloses participant bearer tokens in responses.
|
||||||
|
* Owner-facing endpoints must still receive tokens (so they can distribute share links);
|
||||||
|
* participant-facing endpoints must not, so a single participant token cannot be used to enumerate
|
||||||
|
* peer bearer tokens.
|
||||||
|
*/
|
||||||
|
class WorkflowMapperShareTokenTest {
|
||||||
|
|
||||||
|
private static final String TOKEN_A = "token-aaaa-1111";
|
||||||
|
private static final String TOKEN_B = "token-bbbb-2222";
|
||||||
|
|
||||||
|
private WorkflowSession buildSessionWithTwoParticipants() {
|
||||||
|
User owner = new User();
|
||||||
|
owner.setId(1L);
|
||||||
|
owner.setUsername("[email protected]");
|
||||||
|
|
||||||
|
StoredFile original = new StoredFile();
|
||||||
|
original.setId(42L);
|
||||||
|
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setSessionId("session-xyz");
|
||||||
|
session.setOwner(owner);
|
||||||
|
session.setOriginalFile(original);
|
||||||
|
session.setWorkflowType(WorkflowType.SIGNING);
|
||||||
|
session.setDocumentName("contract.pdf");
|
||||||
|
|
||||||
|
WorkflowParticipant a = new WorkflowParticipant();
|
||||||
|
a.setId(10L);
|
||||||
|
a.setEmail("[email protected]");
|
||||||
|
a.setName("Alice");
|
||||||
|
a.setStatus(ParticipantStatus.PENDING);
|
||||||
|
a.setShareToken(TOKEN_A);
|
||||||
|
a.setAccessRole(ShareAccessRole.EDITOR);
|
||||||
|
session.addParticipant(a);
|
||||||
|
|
||||||
|
WorkflowParticipant b = new WorkflowParticipant();
|
||||||
|
b.setId(11L);
|
||||||
|
b.setEmail("[email protected]");
|
||||||
|
b.setName("Bob");
|
||||||
|
b.setStatus(ParticipantStatus.PENDING);
|
||||||
|
b.setShareToken(TOKEN_B);
|
||||||
|
b.setAccessRole(ShareAccessRole.EDITOR);
|
||||||
|
session.addParticipant(b);
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toResponse_legacyOverload_includesShareTokensForOwnerCompatibility() {
|
||||||
|
WorkflowSession session = buildSessionWithTwoParticipants();
|
||||||
|
|
||||||
|
WorkflowSessionResponse response = WorkflowMapper.toResponse(session);
|
||||||
|
|
||||||
|
assertNotNull(response);
|
||||||
|
assertEquals(2, response.getParticipants().size());
|
||||||
|
assertEquals(TOKEN_A, response.getParticipants().get(0).getShareToken());
|
||||||
|
assertEquals(TOKEN_B, response.getParticipants().get(1).getShareToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toResponse_withIncludeShareTokensFalse_stripsAllPeerTokens() {
|
||||||
|
WorkflowSession session = buildSessionWithTwoParticipants();
|
||||||
|
|
||||||
|
WorkflowSessionResponse response = WorkflowMapper.toResponse(session, null, false);
|
||||||
|
|
||||||
|
assertNotNull(response);
|
||||||
|
assertEquals(2, response.getParticipants().size());
|
||||||
|
for (ParticipantResponse p : response.getParticipants()) {
|
||||||
|
assertNull(
|
||||||
|
p.getShareToken(),
|
||||||
|
"Participant share token must not be exposed in participant-facing responses");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toResponse_withIncludeShareTokensFalse_preservesOtherFields() {
|
||||||
|
WorkflowSession session = buildSessionWithTwoParticipants();
|
||||||
|
|
||||||
|
WorkflowSessionResponse response = WorkflowMapper.toResponse(session, null, false);
|
||||||
|
|
||||||
|
ParticipantResponse alice = response.getParticipants().get(0);
|
||||||
|
assertEquals(10L, alice.getId());
|
||||||
|
assertEquals("[email protected]", alice.getEmail());
|
||||||
|
assertEquals("Alice", alice.getName());
|
||||||
|
assertEquals(ParticipantStatus.PENDING, alice.getStatus());
|
||||||
|
assertEquals(ShareAccessRole.EDITOR, alice.getAccessRole());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toParticipantResponse_legacyOverload_includesShareToken() {
|
||||||
|
WorkflowParticipant p = new WorkflowParticipant();
|
||||||
|
p.setId(1L);
|
||||||
|
p.setEmail("[email protected]");
|
||||||
|
p.setStatus(ParticipantStatus.PENDING);
|
||||||
|
p.setShareToken(TOKEN_A);
|
||||||
|
|
||||||
|
ParticipantResponse response = WorkflowMapper.toParticipantResponse(p);
|
||||||
|
|
||||||
|
assertEquals(TOKEN_A, response.getShareToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toParticipantResponse_withIncludeShareTokenFalse_stripsToken() {
|
||||||
|
WorkflowParticipant p = new WorkflowParticipant();
|
||||||
|
p.setId(1L);
|
||||||
|
p.setEmail("[email protected]");
|
||||||
|
p.setStatus(ParticipantStatus.PENDING);
|
||||||
|
p.setShareToken(TOKEN_A);
|
||||||
|
|
||||||
|
ParticipantResponse response = WorkflowMapper.toParticipantResponse(p, false);
|
||||||
|
|
||||||
|
assertNull(response.getShareToken());
|
||||||
|
assertEquals(1L, response.getId());
|
||||||
|
assertEquals("[email protected]", response.getEmail());
|
||||||
|
assertEquals(ParticipantStatus.PENDING, response.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ export interface ParticipantResponse {
|
|||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: "PENDING" | "NOTIFIED" | "VIEWED" | "SIGNED" | "DECLINED";
|
status: "PENDING" | "NOTIFIED" | "VIEWED" | "SIGNED" | "DECLINED";
|
||||||
shareToken: string;
|
// Null for participant-facing endpoints (`/api/v1/workflow/participant/...`); the owner-facing
|
||||||
|
// `/api/v1/security/cert-sign/sessions/...` endpoints still populate it for share-link
|
||||||
|
// distribution. Never used to look up other participants — see GHSA-qgg6-mxw4-xg62.
|
||||||
|
shareToken: string | null;
|
||||||
accessRole: "EDITOR" | "COMMENTER" | "VIEWER";
|
accessRole: "EDITOR" | "COMMENTER" | "VIEWER";
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
lastUpdated: string;
|
lastUpdated: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user