Area: Messaging & chat (audit p4) · Surface: POST /messages/react -> templates/messages/conversation.php · Dimension: security · Severity: major
The DM reaction endpoint accepts and stores any string up to 16 characters with no allow-list, and the conversation template echoes it without the `$e()` escape helper. This is a stored cross-site-scripting / HTML-injection vector: a sender can inject raw HTML elements (e.g. `<img src=x>`, `<style>`, broken/partial event-handler tags) into the rendered DM thread of the other participant(s). The 16-char length cap incidentally limits the size of a single payload but is not a deliberate security control (full `<img onerror>` needs more chars, but element injection, `<style>`/`<base>` disruption, and partial-tag attacks are possible, and adjacent reactions render contiguously). OWASP A03:2021 Injection (Cross-Site Scripting). Every other user-output in this exact template uses `$e()`; this one echo is the gap.
Evidence
MessageController::react() (platform/src/Controllers/MessageController.php:1148-1182) validates the reaction only as non-empty and <=16 chars with NO character allow-list: `$emoji = trim($_POST['emoji'] ?? ''); if ($messageId <= 0 || $emoji === '' || mb_strlen($emoji) > 16) {...}` then stores it raw: `INSERT IGNORE INTO message_reactions (message_id, user_id, emoji) VALUES (:mid, :uid, :e)`. It is then rendered UNESCAPED in templates/messages/conversation.php:286: `<?= $mr['emoji'] ?>` (note: the sibling `data-emoji="<?= $e($mr['emoji']) ?>"` and `title="<?= $e($mr['users']) ?>"` on the SAME element ARE escaped, proving the text-node echo at line 286 is an oversight). Any participant in a conversation can POST an arbitrary <=16-char string as `emoji`; it persists and renders as raw HTML to every other participant on page load and after the 5s poll which does `thread.innerHTML = newThread.innerHTML` (conversation.php:715). The emoji never passes through MarkdownService::purify (that path only runs on message bodies), so HTMLPurifier provides no protection here.
Suggested fix. Escape on output: change conversation.php:286 to `<?= $e($mr['emoji']) ?>`. Additionally enforce an allow-list on input in react(): require the value to be a single Unicode emoji grapheme (or match against the known quick-emoji / custom-emoji set) before INSERT, and reject anything containing `<`, `>`, `&`, `"`. Apply the same escape to any other place reaction emoji are emitted.
Filed by the automated tenant-app audit and adversarially evidence-verified. Status: verified. Open — not yet actioned.
Patrick Bass
@mobieus