Skip to content

fix: guest ticket JSON loops and leaks internal notes - #98

Merged
mpge merged 3 commits into
mainfrom
fix/guest-ticket-json-recursion
Oct 5, 2026
Merged

mpge merged 3 commits into
mainfrom
fix/guest-ticket-json-recursion

Conversation

@mpge

@mpge mpge commented Oct 5, 2026

Copy link
Copy Markdown
Member

Problem

Any endpoint that returned the Ticket entity serialised it through child-to-parent back-references (Reply.ticket, TicketActivity.ticket, ...). The JSON nested until Jackson gave up, so the client got about 45 KB of truncated JSON it couldn't parse, under a 200.

This also hid a security bug. The guest token endpoints returned the whole entity graph: every reply including internal notes, the activity log, the assigned agent's email, SLA data and so on. The truncation happened to cut the response off before the internal note. The guest reply lists (GET .../tickets/{token}/replies) queried all replies. I checked this directly: with only the loop fixed and the old controllers, the new tests fail because the guest response contains the internal note, and the reply list holds 2 entries instead of 1.

Fix

  1. Guest endpoints now use response DTOs. The repo already uses DTOs for detail views (TicketDetailDto), so these follow that pattern.
    • GET /escalated/api/{guest,widget}/tickets/{token} returns GuestTicketDto: id, reference, subject, description, status, priority, channel, department{name}, requester_name, created_at, updated_at, resolved_at, closed_at, attachments, replies. Replies are public only, and attachments holds only the files filed on the ticket itself.
    • GET .../tickets/{token}/replies returns List<GuestReplyDto> with public replies only, using findByTicketIdAndInternalFalseOrderByCreatedAtAsc.
    • POST .../tickets/{token}/replies returns a GuestReplyDto: id, body, author_name, author_type, is_agent, created_at, attachments.
    • New service methods findGuestView and findGuestReplies build the DTOs inside a read-only transaction.
  2. Back-references are marked @JsonIgnore. Attachment and WorkflowLog already use this pattern. The new ones are Reply.ticket, TicketActivity.ticket, CustomFieldValue.ticket, SatisfactionRating.ticket, SideConversation.ticket, SideConversationReply.sideConversation, TicketLink.sourceTicket/targetTicket, TicketSubjectLink.ticket, EscalationRule.slaPolicy, Tag.tickets, Department.tickets/agents and AgentProfile.assignedTickets.
    • This fixes the staff endpoints, which looped the same way: admin/agent ticket show and list, agent reply list, and the admin department endpoints, which return Department with tickets and agents.
    • The shared frontend reads none of these fields. It uses agents_count and tickets_count, and related tickets come from TicketDetailDto.related_tickets.

Field set compared

  • NestJS reference: the widget ticket view returns { ticket, replies }, where ticket is the entity's columns and replies comes from findByTicketId(id, false), i.e. public replies with attachments.
  • Shared frontend: Guest/Show.vue reads subject, reference, status, priority, department.name, satisfaction_rating, description, attachments, replies.
  • This DTO: it serves both of those and keeps the existing top-level-ticket response shape. Unlike NestJS it leaves out guestAccessToken, assigneeId, requesterId and SLA fields, because a guest has no use for them.
  • Not included: satisfaction_rating. This port has no single-rating field to expose.

Tests

TicketJsonShapeTest boots the host on a random port and sends real HTTP requests, with no stubs. The seeded ticket has an assigned admin, a tag, a public reply and an internal note.

  • The guest view on both prefixes is under 16 KB, parses, and has exactly the field set above. Its one reply has the expected shape.
  • No guest endpoint response contains the internal note, the agent's email, activities or guestAccessToken. JSON depth is bounded.
  • The guest reply lists hold the public reply only. A guest POST reply returns the reply DTO.
  • Staff endpoints (admin show and list, agent show and list, agent replies) return 200 with parseable, depth-bounded JSON. Staff still get both replies, including the internal note, plus the activity log.
  • Before the fix, 14 of the 16 tests failed: responses over 45 KB, unparseable JSON, and 401s from the error forward.

WidgetControllerTest.getTicketByToken_shouldReturnTicket now stubs findGuestView.

Results

  • ./gradlew test (H2): 379 tests, 0 failures, 0 errors, 9 skipped (the skips were already there)
  • ./gradlew checkstyleMain checkstyleTest: BUILD SUCCESSFUL, with no warnings in the touched files

Notes

mpge added 2 commits October 4, 2026 22:01
Reply.ticket, TicketActivity.ticket and the other child-to-parent links
serialised back into the ticket, so any endpoint returning a Ticket nested
until Jackson gave up and sent truncated JSON under a 200. Mark the
back-references @JsonIgnore, as Attachment and WorkflowLog already do. This
covers the admin and agent ticket lists and show views, agent reply lists
and the department endpoints.
The guest and widget token endpoints returned the Ticket and Reply entities.
With the serialisation loop fixed, that hands a guest every internal note,
the activity log and staff details. They now return GuestTicketDto and
GuestReplyDto with public replies only, and the reply lists query public
replies instead of all of them.
# Conflicts:
#	src/main/java/dev/escalated/controllers/widget/GuestAccessController.java
#	src/main/java/dev/escalated/controllers/widget/WidgetController.java
@mpge
mpge merged commit 2caba76 into main Oct 5, 2026
4 checks passed
@mpge mpge mentioned this pull request Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant