Skip to content

fix(web): close the Postmark reply holes — authorization, idempotency, parsing - #190

Merged
NathanTarbert merged 2 commits into
mainfrom
fix/postmark-reply-integrity
Aug 20, 2026
Merged

fix(web): close the Postmark reply holes — authorization, idempotency, parsing#190
NathanTarbert merged 2 commits into
mainfrom
fix/postmark-reply-integrity

Conversation

@NathanTarbert

Copy link
Copy Markdown
Collaborator

One of three PRs split out of #187. Scoped to the inbound email door so it can be reviewed and reverted on its own. Siblings and deferred work are named at the bottom — I'll fill in their numbers as they open.

Three concerns, one subject: what the Postmark webhook does with a message claiming to be a reply.

1. Authorization — closes #189, live on main today

The MailboxHash path resolved a ticket with findUnique({ where: { displayId } }):

  • no source: 'EMAIL' filter — an email could append to a Discord, Slack, Teams or GitHub ticket
  • no sender check — anyone knowing a displayId could append and reopen

The reasoning that a token we mint is trustworthy doesn't survive the fact that we minted it and then published it. generateTicketId() draws 8 characters from a 32-character alphabet with Math.random(), and the bots posted 🎫 Ticket TKT-XXXXXXXX created into public Discord and Slack threads until #172 removed that. Those threads still carry the IDs.

Resolution is now scoped to source: 'EMAIL' and gated on the same isTicketParticipant rule the header path already used — reused unchanged, so there's no second, subtly different definition of "participant."

Failing either gate means unresolved, not dropped: the mail falls through to orphan handling, so a legitimate sender writing from an unrecognised address still gets a ticket.

The hash and header paths were also mutually exclusive via if/else, so an unresolvable hash skipped header matching entirely even though a mail can legitimately carry both. Fixed.

Not currently exploitable — production and staging both hold 0 EMAIL tickets, so Postmark inbound has never created one. Safe by luck, not construction.

2. Idempotency

appendReplyToTicket did a bare message.create while the new-ticket path was guarded. Postmark retries every non-2xx and this route's own catch returns 500 — so a retried reply duplicated the customer's message and re-ran the reopen, putting a resolved ticket back in the queue twice for one email.

Dedup is a real column — Message.sourceMessageId with @@unique([ticketId, sourceMessageId]) — not a JSON path. jsonb can't carry a unique constraint, and attachments.postmarkMessageId would have added a second unindexed scan to the hot path of every reply. A pre-read short-circuits the common retry on an index hit; the constraint is the authority on a race. Same two-level pattern the new-ticket path already uses.

The reopen runs only on the branch that actually inserted, so a redelivery can't reopen a ticket a human resolved in between, and insert + reopen share one transaction. That closes the mirror gap a naive dedup opens: message commits, reopen is lost, and every later retry is a silent no-op that never reaches a human.

A duplicate answers 200 with duplicate: true — 2xx so Postmark stops, marked so retry storms stay legible.

The migration was a deploy landmine

20260811230000 ran an unguarded CREATE UNIQUE INDEX over historical data, and nothing deduped before it. A database with duplicate EMAIL sourceIds would fail to build it — and under set -e in start.sh, the worker never boots.

It now dedupes deterministically first: oldest ticket per sourceId keeps the key, later ones get sourceId = NULL with the value preserved at additionalInfo.duplicateEmailSourceId. No rows deleted, IF NOT EXISTS, fully re-runnable. A tolerant index was rejected — a constraint with holes that reads as enforced is worse than none.

Edited in place rather than renamed, which is safe: that migration is applied nowhere. Production holds 0001_init, 20260715…, 20260812220000; staging holds the first two. Only a developer who ran the pre-split branch locally needs their _prisma_migrations row refreshed.

3. Parsing

Repeated headers. getHeaderValue read only the first match, but References is legitimately split across lines by some clients — so a reply whose matching Message-ID sat in a later segment failed to thread and became a new ticket. Now reads all occurrences, and MAX_REPLY_MESSAGE_IDS moved into the innermost token loop so it bounds the total across every occurrence of both headers rather than per-header. The widened input feeds a sender-supplied IN (…), so a test feeds 200 References lines and asserts exactly 50 survive.

HTML-only mail. HtmlBody was never read, so an Outlook-style HTML-only email produced an empty description, an empty opening message, and an AI job over an empty question that the pipeline answered with its apology fallback. resolveMessageBody now falls back StrippedTextReply → TextBody → htmlToText(HtmlBody).

No dependency added, and this is the part I'd most like a second opinion on. Nothing suitable is in the tree, so htmlToText is a conservative tag-strip plus entity-decode with real limits: no DOM so table cells flatten to lines, newline runs collapse, an unterminated <style> leaks CSS, a > inside an attribute ends a tag early, hrefs are dropped, and the named-entity list is curated. A proper parser (html-to-text) is the better answer if this path ever carries volume — the tradeoff is a supply-chain add for a path with currently zero traffic.

No usable text after every fallback still creates a ticket — a real customer message must not vanish, and the subject plus attachments are evidence — but enqueues no AI job. An apology fallback would be delivered and would mark the ticket answered, spending its one response on nothing.

Tests

1,923 pass, typecheck 10/10. 14 mutations each observed RED and restored to GREEN. Both migrations were applied twice against a real Postgres engine with duplicates seeded, confirming they're re-runnable and leave DISCORD keys and NULL rows alone.

Two tests that encoded the vulnerable behaviour were inverted rather than deleted, and the findUnique mock now enforces where.source the way Postgres would — without that, the cross-channel test would have passed against the vulnerable code.

A note on composition, for whoever reviews the siblings

The three fixes here were developed independently, each verified green on its own, and all three patches applied with no text conflict — and the combination still failed four tests. The authorization fix correctly refused replies whose test fixtures predated it. Nothing was wrong with either change. A clean merge is not evidence of correct composition; the full suite after consolidation is.

Siblings and follow-ups

Split from #187, which stays open until all three exist and is then closed pointing at them.

…, parsing

Split out of the delivery-durability work so the email door is reviewable and
revertable on its own. Three concerns, one subject: what the inbound webhook does
with a message that claims to be a reply.

1. AUTHORIZATION (issue #189 — live on main, pre-existing).

The MailboxHash path resolved a ticket with findUnique({ where: { displayId } }):
no source filter, so an email could append to a Discord, Slack, Teams or GitHub
ticket; and no sender check, so anyone knowing a displayId could append and
reopen. The reasoning that a token we mint is trustworthy does not survive the
fact that we minted it and then published it — generateTicketId draws 8 chars
from a 32-char alphabet with Math.random(), and the bots posted "Ticket
TKT-XXXXXXXX created" into public Discord and Slack threads until #172 removed
that. Those threads still carry the IDs.

Resolution is now scoped to source: 'EMAIL' and gated on the same
isTicketParticipant rule the header path already used — reused unchanged, so
there is no second, subtly different definition of participant. Failing either
gate means UNRESOLVED, not dropped: the mail falls through to orphan handling,
so a legitimate sender writing from an unrecognised address still gets a ticket.

The hash and header paths were also mutually exclusive via if/else, so an
unresolvable hash skipped header matching entirely. A mail can legitimately carry
both; an unresolved hash now falls through to headers before orphan handling.

Not currently exploitable: production and staging both hold 0 EMAIL tickets, so
Postmark inbound has never created one. Safe by luck, not by construction.

2. IDEMPOTENCY.

appendReplyToTicket did a bare message.create while the new-ticket path was
guarded. Postmark retries every non-2xx and this route's own catch returns 500,
so a retried reply duplicated the customer's message AND re-ran the reopen —
putting a resolved ticket back in the queue twice for one email.

Dedup is a real column, Message.sourceMessageId with @@unique([ticketId,
sourceMessageId]), not a JSON path: jsonb cannot carry a unique constraint, and
attachments.postmarkMessageId would have added a second unindexed scan to the hot
path of every reply. A pre-read short-circuits the common retry on an index hit
and the constraint is the authority on a race — the same two-level pattern the
new-ticket path already uses.

The reopen runs only on the branch that actually inserted, so a redelivery cannot
reopen a ticket a human resolved in between, and insert plus reopen share one
transaction. That closes the mirror gap a naive dedup opens, where the message
commits, the reopen is lost, and every subsequent retry is a silent no-op that
never reaches a human. A duplicate answers 200 with duplicate: true — 2xx so
Postmark stops, marked so retry storms stay legible.

Migration 20260811230000 ran an unguarded CREATE UNIQUE INDEX over historical
data, and nothing deduped before it, so a database with duplicate EMAIL sourceIds
would fail to build it — which under set -e in start.sh means the worker never
boots. It now dedupes deterministically first: oldest ticket per sourceId keeps
the key, later ones get sourceId = NULL with the value preserved at
additionalInfo.duplicateEmailSourceId. No rows deleted, IF NOT EXISTS, fully
re-runnable. A tolerant index was rejected as a constraint with holes that reads
as enforced.

Edited in place rather than renamed, which is safe here: that migration is
applied nowhere. Production holds 0001_init, 20260715 and 20260812220000;
staging holds the first two. Only a developer who ran the pre-split branch
locally needs their _prisma_migrations row refreshed.

3. PARSING.

getHeaderValue read only the first matching header. References is legitimately
split across multiple lines by some clients, so a reply whose matching
Message-ID sat in a later segment failed to thread and became a new ticket.
getHeaderValues now reads all occurrences, and MAX_REPLY_MESSAGE_IDS is checked
in the innermost token loop so it bounds the total across every occurrence of
both headers rather than per-header — the widened input feeds a sender-supplied
IN (...), so the cap has to hold after the change, and a test feeds 200
References lines to prove it.

HtmlBody was never read, so an HTML-only email — Outlook, routinely — produced an
empty description, an empty opening message, and an AI job over an empty
question that the pipeline answered with its apology fallback. resolveMessageBody
now falls back StrippedTextReply -> TextBody -> htmlToText(HtmlBody).

No dependency added: nothing suitable is in the tree (jsdom is dev-only,
entities is transitive under react-markdown), so htmlToText is a conservative
tag-strip plus entity-decode. Its documented limits are real and worth reading
before trusting it on rich mail — no DOM so table cells flatten to lines,
newline runs collapse, an unterminated <style> leaks CSS, a > inside an
attribute ends a tag early, hrefs are dropped, and the named-entity list is
curated. A proper parser is the better answer if this path ever carries volume.

An email with no usable text after every fallback still creates a ticket — a
real customer message must not vanish, and the subject plus attachments are
evidence — but enqueues NO AI job. An apology fallback would be delivered and
would mark the ticket answered, spending its one response on nothing.

Verification: turbo typecheck 10/10, turbo test 19/19 (1923 tests). Every fix
carries red-green verification; 14 mutations were each observed RED and restored.
The migrations were applied twice against a real Postgres engine with duplicates
seeded, confirming they are re-runnable and leave DISCORD keys and NULL rows
alone.

Note on composition: the three fixes were developed independently, each verified
green on its own, and all three patches applied with no text conflict — and the
combination still failed four tests. C1's authorization correctly refused replies
whose fixtures predated it. Nothing was wrong with either change; a clean merge
is not evidence of correct composition. The fixtures now carry participant data.

Follow-up work split out of this same review, tracked separately: worker
correctness (the poll loop that never binds per-type concurrency limits), the AI
response state machine (recovery paths that drop an owed escalation, and no
sweeper for stranded PENDING responses), and the /health rework, which cannot
land until the worker poll loop is fixed because the poll loop is what makes it
report 503 on a healthy worker.

@jerelvelarde jerelvelarde left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the three concerns independently against the diff, not the description.

Authorization (the actual #189 fix) — correct. Both reply paths now converge on the same two gates: source: 'EMAIL' scoping and isTicketParticipant. Two details I specifically checked and liked:

  • isTicketParticipant refuses to infer a domain from user.email or a message author, and only trusts the human-set Account.domain. That is the difference between a fix and a re-opened hole — deriving the domain would have made every gmail.com sender a participant on most consumer tickets.
  • normalizeParticipantEmail gating on a real address regex means the non-email authors already in Message.author (Outpost AI, System, slack:U123, octocat (583231)) can't be impersonated into participation.

Failing a gate falling through to header matching and then to orphan handling — rather than dropping — is the right call, and the hash/header paths no longer being mutually exclusive is a genuine bug fix on its own.

findUnique({ where: { displayId, source: 'EMAIL' } }) is valid here — extendedWhereUnique has been GA since Prisma 5 and this repo is on 6.2.

Idempotency — correct, and the reopen placement is the load-bearing part. Running the reopen only on the branch that actually inserted, inside the same transaction as the insert, closes both directions: no reopen of a ticket a human resolved in between, and no committed-message-without-reopen that every later retry silently no-ops. Answering 200 with duplicate: true is right — a non-2xx here would keep Postmark retrying a delivery we have fully processed.

Both migrations dedupe before constraining, and neither deletes a row. Deterministic winner (ORDER BY "createdAt", id), value preserved at additionalInfo.duplicateEmailSourceId, IF NOT EXISTS, re-runnable. Given prisma migrate deploy runs under set -e in the worker start script, an unguarded CREATE UNIQUE INDEX really would have been a boot-blocking landmine, so this matters more than it looks.

Parsing. Moving the MAX_REPLY_MESSAGE_IDS check into the innermost token loop so it bounds the total rather than per-header is the right fix, and the 200-References-lines test asserting exactly 50 survive pins it.

On htmlToText — you flagged this as the part you most wanted a second opinion on. I'd ship it. The limits are documented precisely rather than optimistically, decoding after stripping is the correct order (an escaped &lt;p&gt; never gets re-read as a tag), and comments are removed first so Outlook conditional-comment layouts don't survive as text. For a path with zero current traffic, a curated tag-strip behind a swappable signature beats a supply-chain add. Revisit if it ever carries volume.

Two non-blocking follow-ups, neither worth holding this:

  1. findTicketByReplyMessageIds still resolves appended messages through attachments.postmarkMessageId — up to 50 OR'd unindexed jsonb path scans — even though this PR adds sourceMessageId as an indexed column and backfills it. sourceMessageId: { in: messageIds } would be one indexed lookup. The jsonb read is only still needed for the historical rn > 1 rows the backfill deliberately left NULL, so a union of the two (or just accepting that gap) would drop the hot-path cost.
  2. getHeaderValue is now referenced only from the test file — the production code all goes through getHeaderValues. Worth deleting so nobody reaches for the first-match-only version later, which is exactly the bug this PR fixed.

Approving. This closes the only open security issue on the launch path, and outpost#215 (Postmark DNS for support.copilotkit.ai) is now unblocked to follow.

@linear-code

linear-code Bot commented Aug 19, 2026

Copy link
Copy Markdown
CPK-7923 1. Merge PR #190 — Postmark reply integrity (closes the security hole)

PR #190fix(web): close the Postmark reply holes — authorization, idempotency, parsing. +2559/-158 across 6 files. CI green, MERGEABLE / BLOCKED (needs review approval).

Merge this one first. Two reasons: it closes outpost#189, where an inbound email carrying any ticket's display ID can append to and reopen that ticket on any channel — the only open security issue on the launch path. And it is the smallest of the three splits, so it costs the other two the least rebase.

Its only overlap with #191 is schema.prisma. Everything else is apps/web/src/app/api/webhooks/postmark/ plus its own unique-messageId migration.

Ordering constraint elsewhere

outpost#215 — configuring Postmark DNS for support.copilotkit.ai — must not happen before this merges. Turning on live inbound mail while the authorization hole is open makes it publicly reachable.

Resolves one conflict in packages/outpost/db/prisma/schema.prisma, in
`model Message`. Both sides added distinct fields in the same region and
neither touched the other's, so the resolution is a union:

  - this branch: `sourceMessageId` + @@unique([ticketId, sourceMessageId]),
    the indexed dedup slot for inbound provider message IDs (Postmark today).
  - main (#191): `responseKey`, `responseState`, `responseJobId`,
    `responseError`, `deliveryConfirmed`, `escalationRequiredReason`,
    @@unique([ticketId, responseKey]), and @@index([responseState, createdAt])
    for PENDING_RESPONSE_SWEEP.

The shared tail (`attachments`, `createdAt`) is kept once. The two unique
constraints cover different columns and do not interact.

Verified on the merged result, not assumed:
  - prisma validate (workspace-pinned binary): schema is valid
  - all five migrations present from both sides; none dropped in the merge
  - typecheck 10/10
  - full suite green: 1075 shared, 679 web, every package passing
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.

security: an email carrying any ticket's displayId can append to and reopen that ticket, on any channel

2 participants