You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Live on main today. Pre-existing — not introduced by any open PR.
An inbound email whose plus-address carries a ticket's displayId is appended to that ticket and reopens it, with no check that the sender has anything to do with the ticket, and no check that the ticket belongs to the email channel at all.
No source: 'EMAIL' filter. The lookup is by displayId alone, so a mail can append to a Discord, Slack, Teams or GitHub ticket.
No sender check. Anyone who knows a displayId can append, regardless of whether they are the reporter, a participant, or a stranger.
displayId is guessable and was published.generateTicketId() in packages/outpost/shared/src/utils.ts is 8 characters from a 32-character alphabet via Math.random() — not cryptographic. More importantly the bot posted these IDs into public Discord and Slack threads as 🎫 Ticket TKT-XXXXXXXX created until fix(ai): answer each ticket once, and stop showing reporters internal ticket IDs #172 removed that. Every historical thread still contains one, harvestable by anyone.
So: read a ticket ID off an old public Discord post, send an email to the inbound address with that ID as the plus-address, and your content lands on that ticket and reopens it.
Impact
Integrity and noise rather than disclosure. Nothing is echoed back to the sender, and since #172 a reply does not spend an AI response. What an attacker gets is the ability to inject arbitrary content into any ticket's history and reopen resolved tickets — visible to whoever handles it, and attributed to a From address they control.
Reopening is the part worth weighing: a resolved ticket returning to the queue is how support teams get walked into a thread they thought was closed.
Not currently exploitable in practice on either environment — production and staging both have 0 EMAIL tickets, so Postmark inbound has never created one. That is why this has not bitten anyone. It is safe by luck, not by design.
Fix
Both halves, together:
Scope the lookup: findFirst({ where: { displayId, source: 'EMAIL' } }). A mail should only ever be able to address an email ticket.
Require the sender to be a participant, the same check the header-based reply path already applies (findTicketByReplyMessageIds in the same file gates on ticket.user.email, an address parsed from an existing Message.author, or ticket.account.domain). The MailboxHash path was left ungated on the reasoning that we mint the token — but we minted it and then published it, so that reasoning does not hold.
A non-matching sender should fall through to the orphaned-reply path — file a ticket, spend no AI response — so a legitimate sender writing from a second address never loses their mail.
Related
Being fixed in the Postmark branch split out of #187. Filed separately because it is live on main and independent of that PR's subject, so it should be reviewable and revertable on its own.
Adjacent, same file, same branch: the reply-append path has no MessageID idempotency, so a Postmark redelivery duplicates the message and re-runs the reopen.
Live on
maintoday. Pre-existing — not introduced by any open PR.An inbound email whose plus-address carries a ticket's
displayIdis appended to that ticket and reopens it, with no check that the sender has anything to do with the ticket, and no check that the ticket belongs to the email channel at all.The chain
apps/web/src/app/api/webhooks/postmark/route.ts:60source: 'EMAIL'filter. The lookup is bydisplayIdalone, so a mail can append to a Discord, Slack, Teams or GitHub ticket.displayIdcan append, regardless of whether they are the reporter, a participant, or a stranger.displayIdis guessable and was published.generateTicketId()inpackages/outpost/shared/src/utils.tsis 8 characters from a 32-character alphabet viaMath.random()— not cryptographic. More importantly the bot posted these IDs into public Discord and Slack threads as🎫 Ticket TKT-XXXXXXXX createduntil fix(ai): answer each ticket once, and stop showing reporters internal ticket IDs #172 removed that. Every historical thread still contains one, harvestable by anyone.So: read a ticket ID off an old public Discord post, send an email to the inbound address with that ID as the plus-address, and your content lands on that ticket and reopens it.
Impact
Integrity and noise rather than disclosure. Nothing is echoed back to the sender, and since #172 a reply does not spend an AI response. What an attacker gets is the ability to inject arbitrary content into any ticket's history and reopen resolved tickets — visible to whoever handles it, and attributed to a
Fromaddress they control.Reopening is the part worth weighing: a resolved ticket returning to the queue is how support teams get walked into a thread they thought was closed.
Not currently exploitable in practice on either environment — production and staging both have 0 EMAIL tickets, so Postmark inbound has never created one. That is why this has not bitten anyone. It is safe by luck, not by design.
Fix
Both halves, together:
findFirst({ where: { displayId, source: 'EMAIL' } }). A mail should only ever be able to address an email ticket.findTicketByReplyMessageIdsin the same file gates onticket.user.email, an address parsed from an existingMessage.author, orticket.account.domain). TheMailboxHashpath was left ungated on the reasoning that we mint the token — but we minted it and then published it, so that reasoning does not hold.A non-matching sender should fall through to the orphaned-reply path — file a ticket, spend no AI response — so a legitimate sender writing from a second address never loses their mail.
Related
Being fixed in the Postmark branch split out of #187. Filed separately because it is live on
mainand independent of that PR's subject, so it should be reviewable and revertable on its own.Adjacent, same file, same branch: the reply-append path has no
MessageIDidempotency, so a Postmark redelivery duplicates the message and re-runs the reopen.