Area: Messaging & chat (audit p4) · Surface: POST /messages/{id}/delivered, POST /messages/typing/{conversationId} · Dimension: security · Severity: minor
A user who has soft-deleted or left a conversation (deleted_at set on their participant row) is meant to lose access to it — this is explicitly enforced in showConversation, downloadAttachment, and react. But markDelivered and typing only check that a participant row exists, not that it is active. This is a minor broken-access-control / information-disclosure gap (OWASP A01:2021): an ex-participant can keep probing the typing/presence status of the remaining party, and can mark messages delivered, on a thread they should no longer see. Low impact (no message content is returned, only a boolean activity flag), but it is inconsistent with the deliberate deleted_at gating applied to every other read/mutate path in this controller.
Evidence
MessageController::markDelivered() (platform/src/Controllers/MessageController.php:1201-1204) authorizes with `SELECT id FROM conversation_participants WHERE conversation_id = :cid AND user_id = :uid` — no `AND deleted_at IS NULL`. MessageController::typing() (lines 1226-1229) uses the identical predicate with no `deleted_at` filter. Compare showConversation() (line 283-287), downloadAttachment() (line 893-897), and react() (line 1160-1163) which all correctly add `AND deleted_at IS NULL` to enforce that ex-participants lose access. The typing endpoint returns whether the other party is currently active (lines 1235-1244), so a user who soft-deleted/left a conversation can still poll the other participant's online/typing activity.
Suggested fix. Add `AND deleted_at IS NULL` to the participant-check SELECT in both markDelivered() (line 1201-1204) and typing() (line 1226-1229), matching the predicate already used in showConversation/downloadAttachment/react.
Filed by the automated tenant-app audit and adversarially evidence-verified. Status: verified. Open — not yet actioned.
Patrick Bass
@mobieus