Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ public ResponseEntity<WorkflowSessionResponse> getSessionByToken(
}

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(
Expand All @@ -122,7 +124,7 @@ public ResponseEntity<ParticipantResponse> getParticipantDetails(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));

return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false));
}

@Operation(
Expand Down Expand Up @@ -181,7 +183,7 @@ public ResponseEntity<ParticipantResponse> submitSignature(
participant.getEmail(),
participant.getWorkflowSession().getSessionId());

return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false));

} catch (ResponseStatusException e) {
throw e;
Expand Down Expand Up @@ -235,7 +237,7 @@ public ResponseEntity<ParticipantResponse> declineParticipation(
participant.getEmail(),
participant.getWorkflowSession().getSessionId());

return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false));
}

@Operation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public class WorkflowMapper {

/** Converts a WorkflowSession entity to a response DTO. */
public static WorkflowSessionResponse toResponse(WorkflowSession session) {
return toResponse(session, null);
return toResponse(session, null, true);
}

/**
Expand All @@ -34,6 +34,21 @@ public static WorkflowSessionResponse toResponse(WorkflowSession session) {
*/
public static WorkflowSessionResponse toResponse(
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) {
return null;
}
Expand Down Expand Up @@ -64,12 +79,12 @@ public static WorkflowSessionResponse toResponse(
if (objectMapper != null) {
response.setParticipants(
session.getParticipants().stream()
.map(p -> toParticipantResponse(p, objectMapper))
.map(p -> toParticipantResponse(p, objectMapper, includeShareTokens))
.collect(Collectors.toList()));
} else {
response.setParticipants(
session.getParticipants().stream()
.map(WorkflowMapper::toParticipantResponse)
.map(p -> toParticipantResponse(p, includeShareTokens))
.collect(Collectors.toList()));
}

Expand All @@ -88,8 +103,21 @@ public static WorkflowSessionResponse toResponse(
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) {
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) {
return null;
}
Expand All @@ -102,7 +130,9 @@ public static ParticipantResponse toParticipantResponse(WorkflowParticipant part
response.setEmail(participant.getEmail());
response.setName(participant.getName());
response.setStatus(participant.getStatus());
response.setShareToken(participant.getShareToken());
if (includeShareToken) {
response.setShareToken(participant.getShareToken());
}
response.setAccessRole(participant.getAccessRole());
response.setExpiresAt(participant.getExpiresAt());
response.setLastUpdated(participant.getLastUpdated());
Expand All @@ -122,7 +152,19 @@ public static ParticipantResponse toParticipantResponse(WorkflowParticipant part
*/
public static ParticipantResponse toParticipantResponse(
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) {
response.setWetSignatures(extractWetSignatures(participant, objectMapper));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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("owner@example.com");

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("alice@example.com");
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("bob@example.com");
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("alice@example.com", 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("a@example.com");
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("a@example.com");
p.setStatus(ParticipantStatus.PENDING);
p.setShareToken(TOKEN_A);

ParticipantResponse response = WorkflowMapper.toParticipantResponse(p, false);

assertNull(response.getShareToken());
assertEquals(1L, response.getId());
assertEquals("a@example.com", response.getEmail());
assertEquals(ParticipantStatus.PENDING, response.getStatus());
}
}
5 changes: 4 additions & 1 deletion frontend/src/proprietary/services/workflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ export interface ParticipantResponse {
email: string;
name: string;
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";
expiresAt?: string;
lastUpdated: string;
Expand Down
Loading