Repository navigation
security: Telegram menu callback handlers don't enforce intent ownership #151
Description
Note: this is a focused, expanded write-up of finding #1 in issue #91 ("security: missing authorization and input validation in Telegram menu and preferences API"). #91 remains the umbrella ticket covering three other independent findings (preferences API auth, callbackHandler non-null asserts). When picking up the work, decide whether to close this and resolve under #91 or close finding 1 in #91 in favour of this — but don't fix in two places.
Summary
Telegram menu callback handlers accept an untrusted intentId from the callback payload without first verifying that the requesting Telegram user owns the referenced intent/card. A malicious user can intercept their own callback, swap the intentId for another user's, and resend it to the bot.
Affected handlers (as of PR #148)
| Callback action | Handler | Impact |
|---|---|---|
menu_cancel_confirm:<intentId> |
showCancelConfirm |
Reads another user's intent details (subject, query, status, budget) |
menu_cancel_do:<intentId> |
doCancelIntent → expireIntent |
Cancels another user's intent |
menu_card_cancel:<intentId> |
doCancelCard → cancelCard |
Cancels another user's virtual card |
Root cause
showCancelConfirmcallsprisma.purchaseIntent.findUnique({ where: { id: intentId } })with nouserIdfilter.expireIntent(insrc/orchestrator/intentService.ts) accepts onlyintentIdand never checks ownership.getPaymentProvider().cancelCard(intentId)likewise has no ownership check.
The Telegram UI only surfaces these buttons for the user's own intents (showCancelList filters by userId), but the enforcement is client-side only.
Intent IDs are UUIDs — but not fully secret
UUIDs are hard to guess by brute force; however, they can be leaked via referral codes, audit exports, user-to-user sharing, or logs. The threat is realistic for a targeted attack.
Recommended fix
- Move the
getUserByChatIdlookup above the cancel dispatch blocks souser.idis available before any cancel action executes. - Thread
userIdintoshowCancelConfirm,doCancelIntent, anddoCancelCard. - Add
userIdto downstream DB queries/mutations:prisma.purchaseIntent.findUnique({ where: { id: intentId, userId } })expireIntent(intentId, userId)with ownership assertion inside the servicecancelCardownership check (depends on payment provider implementation)
- Defense in depth — also enforce at the service layer, not only at the Telegram handler. Add an
assertOwnership(intentId, userId)(or equivalent guard) at the entry ofexpireIntentand the payment provider'scancelCardso any future caller path (HTTP API, internal scripts, new bots) inherits the check for free. - Return a generic
⚠️ Intent not found.if the ownership check fails (avoid leaking whether the intent exists). - Log every rejected cross-user attempt at
warnwith structured fields{ chatId, telegramUserId, intentId, ownerUserId }so attempts are visible in observability and can feed alerting.
Design decision: enforcement can live at the handler layer, the service layer, or both — recommendation above is "both". Worth confirming explicitly in the fix PR.
Acceptance criteria
-
getUserByChatIdresolved before any of the three cancel branches dispatch -
userIdthreaded intoshowCancelConfirm/doCancelIntent/doCancelCard - DB lookups in
showCancelConfirmconstrained by{ id: intentId, userId } - Service-layer ownership guard added to
expireIntentandcancelCard(defense in depth) - Generic "not found" message on ownership mismatch — does not leak existence
- Rejected cross-user attempts logged at
warnwith{ chatId, telegramUserId, intentId, ownerUserId } - Integration tests for cross-user scenarios:
- cross-user
menu_cancel_confirm→ "Intent not found", no DB read leak - 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
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 above. A follow-up should review menu callback authorization holistically.
References
- Identified in PR fix(issue-90): restore observability and input validation in Telegram menu #148: fix(issue-90): restore observability and input validation in Telegram menu #148
- Original review comment: fix(issue-90): restore observability and input validation in Telegram menu #148 (comment)
- Deferred from PR fix(issue-90): restore observability and input validation in Telegram menu #148 (scope: observability/input validation, issue bug: Telegram menu errors are silently swallowed — zero observability #90) — pre-existing vulnerability
- Parent / pre-existing umbrella ticket: security: missing authorization and input validation in Telegram menu and preferences API #91 (this is finding [feature]: add monzo pod integration for setting up accounts that can receive incoming transactions #1)
/cc @JonasBaeumer
Activity