Skip to content

Commit 4e4918b

Browse files
authored
fix(workflow): stop leaking peer share tokens from participant session API (#6241)
1 parent b966e77 commit 4e4918b

4 files changed

Lines changed: 194 additions & 11 deletions

File tree

app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ public ResponseEntity<WorkflowSessionResponse> getSessionByToken(
101101
}
102102

103103
WorkflowSession session = participant.getWorkflowSession();
104-
return ResponseEntity.ok(WorkflowMapper.toResponse(session));
104+
// Strip peer share tokens — a single participant token must not enumerate peer bearer
105+
// tokens (GHSA-qgg6-mxw4-xg62).
106+
return ResponseEntity.ok(WorkflowMapper.toResponse(session, null, false));
105107
}
106108

107109
@Operation(
@@ -122,7 +124,7 @@ public ResponseEntity<ParticipantResponse> getParticipantDetails(
122124
HttpStatus.FORBIDDEN,
123125
"Invalid or expired participant token"));
124126

125-
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
127+
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false));
126128
}
127129

128130
@Operation(
@@ -181,7 +183,7 @@ public ResponseEntity<ParticipantResponse> submitSignature(
181183
participant.getEmail(),
182184
participant.getWorkflowSession().getSessionId());
183185

184-
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
186+
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false));
185187

186188
} catch (ResponseStatusException e) {
187189
throw e;
@@ -235,7 +237,7 @@ public ResponseEntity<ParticipantResponse> declineParticipation(
235237
participant.getEmail(),
236238
participant.getWorkflowSession().getSessionId());
237239

238-
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
240+
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false));
239241
}
240242

241243
@Operation(

app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public class WorkflowMapper {
2121

2222
/** Converts a WorkflowSession entity to a response DTO. */
2323
public static WorkflowSessionResponse toResponse(WorkflowSession session) {
24-
return toResponse(session, null);
24+
return toResponse(session, null, true);
2525
}
2626

2727
/**
@@ -34,6 +34,21 @@ public static WorkflowSessionResponse toResponse(WorkflowSession session) {
3434
*/
3535
public static WorkflowSessionResponse toResponse(
3636
WorkflowSession session, ObjectMapper objectMapper) {
37+
return toResponse(session, objectMapper, true);
38+
}
39+
40+
/**
41+
* Converts a WorkflowSession entity to a response DTO.
42+
*
43+
* @param session The workflow session entity
44+
* @param objectMapper ObjectMapper for JSON processing (null to skip wet signature extraction)
45+
* @param includeShareTokens Whether to include each participant's share token in the response.
46+
* Owner-facing endpoints set this to true so the owner can distribute share links;
47+
* participant-facing endpoints set this to false so a single participant's token cannot be
48+
* used to enumerate peer bearer tokens (GHSA-qgg6-mxw4-xg62).
49+
*/
50+
public static WorkflowSessionResponse toResponse(
51+
WorkflowSession session, ObjectMapper objectMapper, boolean includeShareTokens) {
3752
if (session == null) {
3853
return null;
3954
}
@@ -64,12 +79,12 @@ public static WorkflowSessionResponse toResponse(
6479
if (objectMapper != null) {
6580
response.setParticipants(
6681
session.getParticipants().stream()
67-
.map(p -> toParticipantResponse(p, objectMapper))
82+
.map(p -> toParticipantResponse(p, objectMapper, includeShareTokens))
6883
.collect(Collectors.toList()));
6984
} else {
7085
response.setParticipants(
7186
session.getParticipants().stream()
72-
.map(WorkflowMapper::toParticipantResponse)
87+
.map(p -> toParticipantResponse(p, includeShareTokens))
7388
.collect(Collectors.toList()));
7489
}
7590

@@ -88,8 +103,21 @@ public static WorkflowSessionResponse toResponse(
88103
return response;
89104
}
90105

91-
/** Converts a WorkflowParticipant entity to a response DTO. */
106+
/** Converts a WorkflowParticipant entity to a response DTO, including the share token. */
92107
public static ParticipantResponse toParticipantResponse(WorkflowParticipant participant) {
108+
return toParticipantResponse(participant, true);
109+
}
110+
111+
/**
112+
* Converts a WorkflowParticipant entity to a response DTO.
113+
*
114+
* @param participant The participant entity
115+
* @param includeShareToken Whether to include the participant's share token in the response.
116+
* Owner-facing endpoints set this to true; participant-facing endpoints set this to false
117+
* so the response cannot be used to enumerate peer bearer tokens (GHSA-qgg6-mxw4-xg62).
118+
*/
119+
public static ParticipantResponse toParticipantResponse(
120+
WorkflowParticipant participant, boolean includeShareToken) {
93121
if (participant == null) {
94122
return null;
95123
}
@@ -102,7 +130,9 @@ public static ParticipantResponse toParticipantResponse(WorkflowParticipant part
102130
response.setEmail(participant.getEmail());
103131
response.setName(participant.getName());
104132
response.setStatus(participant.getStatus());
105-
response.setShareToken(participant.getShareToken());
133+
if (includeShareToken) {
134+
response.setShareToken(participant.getShareToken());
135+
}
106136
response.setAccessRole(participant.getAccessRole());
107137
response.setExpiresAt(participant.getExpiresAt());
108138
response.setLastUpdated(participant.getLastUpdated());
@@ -122,7 +152,19 @@ public static ParticipantResponse toParticipantResponse(WorkflowParticipant part
122152
*/
123153
public static ParticipantResponse toParticipantResponse(
124154
WorkflowParticipant participant, ObjectMapper objectMapper) {
125-
ParticipantResponse response = toParticipantResponse(participant);
155+
return toParticipantResponse(participant, objectMapper, true);
156+
}
157+
158+
/**
159+
* Converts a WorkflowParticipant entity to a response DTO with wet signatures extracted.
160+
*
161+
* @param participant The participant entity
162+
* @param objectMapper ObjectMapper for JSON processing
163+
* @param includeShareToken Whether to include the participant's share token in the response.
164+
*/
165+
public static ParticipantResponse toParticipantResponse(
166+
WorkflowParticipant participant, ObjectMapper objectMapper, boolean includeShareToken) {
167+
ParticipantResponse response = toParticipantResponse(participant, includeShareToken);
126168
if (response != null) {
127169
response.setWetSignatures(extractWetSignatures(participant, objectMapper));
128170
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package stirling.software.proprietary.workflow.util;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNotNull;
5+
import static org.junit.jupiter.api.Assertions.assertNull;
6+
7+
import org.junit.jupiter.api.Test;
8+
9+
import stirling.software.proprietary.security.model.User;
10+
import stirling.software.proprietary.storage.model.ShareAccessRole;
11+
import stirling.software.proprietary.storage.model.StoredFile;
12+
import stirling.software.proprietary.workflow.dto.ParticipantResponse;
13+
import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse;
14+
import stirling.software.proprietary.workflow.model.ParticipantStatus;
15+
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
16+
import stirling.software.proprietary.workflow.model.WorkflowSession;
17+
import stirling.software.proprietary.workflow.model.WorkflowType;
18+
19+
/**
20+
* Regression test for GHSA-qgg6-mxw4-xg62 — verifies that the {@code includeShareToken(s)} flag
21+
* controls whether {@link WorkflowMapper} discloses participant bearer tokens in responses.
22+
* Owner-facing endpoints must still receive tokens (so they can distribute share links);
23+
* participant-facing endpoints must not, so a single participant token cannot be used to enumerate
24+
* peer bearer tokens.
25+
*/
26+
class WorkflowMapperShareTokenTest {
27+
28+
private static final String TOKEN_A = "token-aaaa-1111";
29+
private static final String TOKEN_B = "token-bbbb-2222";
30+
31+
private WorkflowSession buildSessionWithTwoParticipants() {
32+
User owner = new User();
33+
owner.setId(1L);
34+
owner.setUsername("owner@example.com");
35+
36+
StoredFile original = new StoredFile();
37+
original.setId(42L);
38+
39+
WorkflowSession session = new WorkflowSession();
40+
session.setSessionId("session-xyz");
41+
session.setOwner(owner);
42+
session.setOriginalFile(original);
43+
session.setWorkflowType(WorkflowType.SIGNING);
44+
session.setDocumentName("contract.pdf");
45+
46+
WorkflowParticipant a = new WorkflowParticipant();
47+
a.setId(10L);
48+
a.setEmail("alice@example.com");
49+
a.setName("Alice");
50+
a.setStatus(ParticipantStatus.PENDING);
51+
a.setShareToken(TOKEN_A);
52+
a.setAccessRole(ShareAccessRole.EDITOR);
53+
session.addParticipant(a);
54+
55+
WorkflowParticipant b = new WorkflowParticipant();
56+
b.setId(11L);
57+
b.setEmail("bob@example.com");
58+
b.setName("Bob");
59+
b.setStatus(ParticipantStatus.PENDING);
60+
b.setShareToken(TOKEN_B);
61+
b.setAccessRole(ShareAccessRole.EDITOR);
62+
session.addParticipant(b);
63+
64+
return session;
65+
}
66+
67+
@Test
68+
void toResponse_legacyOverload_includesShareTokensForOwnerCompatibility() {
69+
WorkflowSession session = buildSessionWithTwoParticipants();
70+
71+
WorkflowSessionResponse response = WorkflowMapper.toResponse(session);
72+
73+
assertNotNull(response);
74+
assertEquals(2, response.getParticipants().size());
75+
assertEquals(TOKEN_A, response.getParticipants().get(0).getShareToken());
76+
assertEquals(TOKEN_B, response.getParticipants().get(1).getShareToken());
77+
}
78+
79+
@Test
80+
void toResponse_withIncludeShareTokensFalse_stripsAllPeerTokens() {
81+
WorkflowSession session = buildSessionWithTwoParticipants();
82+
83+
WorkflowSessionResponse response = WorkflowMapper.toResponse(session, null, false);
84+
85+
assertNotNull(response);
86+
assertEquals(2, response.getParticipants().size());
87+
for (ParticipantResponse p : response.getParticipants()) {
88+
assertNull(
89+
p.getShareToken(),
90+
"Participant share token must not be exposed in participant-facing responses");
91+
}
92+
}
93+
94+
@Test
95+
void toResponse_withIncludeShareTokensFalse_preservesOtherFields() {
96+
WorkflowSession session = buildSessionWithTwoParticipants();
97+
98+
WorkflowSessionResponse response = WorkflowMapper.toResponse(session, null, false);
99+
100+
ParticipantResponse alice = response.getParticipants().get(0);
101+
assertEquals(10L, alice.getId());
102+
assertEquals("alice@example.com", alice.getEmail());
103+
assertEquals("Alice", alice.getName());
104+
assertEquals(ParticipantStatus.PENDING, alice.getStatus());
105+
assertEquals(ShareAccessRole.EDITOR, alice.getAccessRole());
106+
}
107+
108+
@Test
109+
void toParticipantResponse_legacyOverload_includesShareToken() {
110+
WorkflowParticipant p = new WorkflowParticipant();
111+
p.setId(1L);
112+
p.setEmail("a@example.com");
113+
p.setStatus(ParticipantStatus.PENDING);
114+
p.setShareToken(TOKEN_A);
115+
116+
ParticipantResponse response = WorkflowMapper.toParticipantResponse(p);
117+
118+
assertEquals(TOKEN_A, response.getShareToken());
119+
}
120+
121+
@Test
122+
void toParticipantResponse_withIncludeShareTokenFalse_stripsToken() {
123+
WorkflowParticipant p = new WorkflowParticipant();
124+
p.setId(1L);
125+
p.setEmail("a@example.com");
126+
p.setStatus(ParticipantStatus.PENDING);
127+
p.setShareToken(TOKEN_A);
128+
129+
ParticipantResponse response = WorkflowMapper.toParticipantResponse(p, false);
130+
131+
assertNull(response.getShareToken());
132+
assertEquals(1L, response.getId());
133+
assertEquals("a@example.com", response.getEmail());
134+
assertEquals(ParticipantStatus.PENDING, response.getStatus());
135+
}
136+
}

frontend/src/proprietary/services/workflowService.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ export interface ParticipantResponse {
66
email: string;
77
name: string;
88
status: "PENDING" | "NOTIFIED" | "VIEWED" | "SIGNED" | "DECLINED";
9-
shareToken: string;
9+
// Null for participant-facing endpoints (`/api/v1/workflow/participant/...`); the owner-facing
10+
// `/api/v1/security/cert-sign/sessions/...` endpoints still populate it for share-link
11+
// distribution. Never used to look up other participants — see GHSA-qgg6-mxw4-xg62.
12+
shareToken: string | null;
1013
accessRole: "EDITOR" | "COMMENTER" | "VIEWER";
1114
expiresAt?: string;
1215
lastUpdated: string;

0 commit comments

Comments
 (0)