Repository navigation
security: Telegram menu cancel callbacks don't enforce intent ownership #152
Description
Summary
The Telegram menu callback handlers for cancelling intents and cancelling virtual cards trust the intentId payload from the callback without verifying that the intent belongs to the Telegram user who triggered it. A malicious user can intercept their own callback, swap in another user's intentId, and resend — gaining unauthorized read and mutation access.
Surfaced during review of #148 (CodeRabbit comment, PR #148 discussion). Out of scope for #90, hence this dedicated ticket.
Affected paths
In src/telegram/menuHandler.ts, inside handleMenuCallback:
menu_cancel_confirm:<intentId>→showCancelConfirm(bot, chatId, messageId, payload)menu_cancel_do:<intentId>→doCancelIntent(bot, chatId, messageId, payload)menu_card_cancel:<intentId>→doCancelCard(bot, chatId, messageId, payload)
These branches run before getUserByChatId(chatId) and never thread userId into the downstream calls. Specifically:
showCancelConfirmdoesprisma.purchaseIntent.findUnique({ where: { id: intentId } })with nouserIdfilterdoCancelIntentcallsexpireIntent(intentId)(insrc/orchestrator/intentService.ts) — no ownership checkdoCancelCardcallsgetPaymentProvider().cancelCard(intentId)— no ownership check
Threat model
The Telegram UI only renders these buttons in a user's own menu (showCancelList filters by userId). To exploit, an attacker:
- Triggers their own legitimate menu and captures the callback_query payload
- Swaps the
intentIdportion for a victim's intent ID - Resends the modified callback to the bot
Intent IDs are UUIDs (high-entropy, hard to guess), so the practical risk depends on whether IDs leak through other channels (audit exports, logs shared in support, screenshots, future referral flows, etc.). Even without ID leakage this is a defense-in-depth gap and a clear authorization invariant violation.
What an attacker gains
- Read: another user's intent details (subject, query, status, max budget) via
menu_cancel_confirm - Mutate: cancel another user's active intent via
menu_cancel_do - Mutate: cancel another user's virtual card via
menu_card_cancel
Acceptance criteria
- Resolve
getUserByChatIdbefore dispatching to any of the three branches above (move user lookup above the cancel/card-cancel branches) - Thread
userIdintoshowCancelConfirm,doCancelIntent,doCancelCard - Constrain DB lookups in
showCancelConfirmby{ id: intentId, userId }and return the same generic "Intent not found" message on a mismatch (do not leak existence) - Add a
userId(orassertOwnership(intentId, userId)) check at the service-layer entry ofexpireIntentand the payment provider'scancelCardso any future caller path also gets the check — defense in depth - Integration tests:
- cross-user
menu_cancel_confirm→ "Intent not found" - cross-user
menu_cancel_do→ no state change on victim's intent - cross-user
menu_card_cancel→ no state change on victim's card
- cross-user
- Audit-log every rejected cross-user attempt at
warnlevel with{ chatId, telegramUserId, intentId, ownerUserId }for detection
Out of scope (here)
- Rate limiting on rejected attempts (separate ticket if/when needed)
- A broader audit of every other
menu_*action — this ticket is scoped to the three cancel paths called out above. A full review of menu callback authorization should happen in a follow-up.
Notes
This bug pre-dates #148 — the dispatch structure for these three branches was unchanged in that PR. Flagged here so it doesn't get lost.
Activity
JonasBaeumer commentedon May 3, 2026
Closing as duplicate of #151 (CodeRabbit auto-filed the same finding ~3 minutes earlier — I missed it). Merged the high-value bits from this ticket (defense-in-depth at service layer, audit logging on rejected attempts, acceptance-criteria checkboxes) into #151. Also noted there that this is finding #1 of the pre-existing umbrella issue #91. Tracking work under #151 going forward.