From 11852b900b5d437f1cc56337e26200a60a1873fc Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Mon, 27 Apr 2026 10:17:04 +0100 Subject: [PATCH 1/2] fix(workflow): stop leaking peer share tokens from participant session API The participant-facing endpoints under /api/v1/workflow/participant/* were serializing every peer's shareToken in the WorkflowSessionResponse. A caller holding one valid participant bearer token could read peer tokens from participants[*].shareToken and then call submit-signature or decline as any other participant in the same workflow. Add an includeShareToken(s) flag to WorkflowMapper, defaulting to true so the owner-facing SigningSessionController (which legitimately needs tokens for share-link distribution) is unchanged. Pass false from the four WorkflowParticipantController response sites, which closes the disclosure chain. Caller's own token comes from the URL, so no UX regression. Refs: GHSA-qgg6-mxw4-xg62 --- .../WorkflowParticipantController.java | 10 +- .../workflow/util/WorkflowMapper.java | 54 ++++++- .../util/WorkflowMapperShareTokenTest.java | 136 ++++++++++++++++++ 3 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/workflow/util/WorkflowMapperShareTokenTest.java diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java index f29598af06..45d3228fdb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java @@ -101,7 +101,9 @@ public ResponseEntity 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( @@ -122,7 +124,7 @@ public ResponseEntity getParticipantDetails( HttpStatus.FORBIDDEN, "Invalid or expired participant token")); - return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant)); + return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false)); } @Operation( @@ -181,7 +183,7 @@ public ResponseEntity submitSignature( participant.getEmail(), participant.getWorkflowSession().getSessionId()); - return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant)); + return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false)); } catch (ResponseStatusException e) { throw e; @@ -235,7 +237,7 @@ public ResponseEntity declineParticipation( participant.getEmail(), participant.getWorkflowSession().getSessionId()); - return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant)); + return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false)); } @Operation( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java index 82cc5bd198..6be7197d5c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java @@ -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); } /** @@ -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; } @@ -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())); } @@ -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; } @@ -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()); @@ -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)); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/util/WorkflowMapperShareTokenTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/util/WorkflowMapperShareTokenTest.java new file mode 100644 index 0000000000..2d45579b09 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/util/WorkflowMapperShareTokenTest.java @@ -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()); + } +} From f3826b4448ddc816a684cf49e6089ea3a53764af Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Mon, 27 Apr 2026 11:32:07 +0100 Subject: [PATCH 2/2] fix(workflow/types): mark ParticipantResponse.shareToken as nullable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit makes the four participant-facing endpoints emit shareToken: null in their JSON responses (peer-token disclosure fix). The TypeScript interface still declared shareToken: string, which would silently mislead any future consumer into a non-null assertion. Widen to string | null and document why. No runtime change — no consumer currently reads this field; this only makes the type honest. Refs: GHSA-qgg6-mxw4-xg62 --- frontend/src/proprietary/services/workflowService.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/proprietary/services/workflowService.ts b/frontend/src/proprietary/services/workflowService.ts index 3c8e842e25..61f50e5b01 100644 --- a/frontend/src/proprietary/services/workflowService.ts +++ b/frontend/src/proprietary/services/workflowService.ts @@ -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;