fix(messages): PR #769 review — escape reply sender (XSS), gate backend routes, exact customer match

- BLOCKER: stored XSS via inbound sender display name. The reply stub built raw
  HTML with the unsanitized From name and set it as innerHTML on the composer's
  contentEditable (admin origin) → onerror JS ran on Reply. Now HTML-escape
  from_address in the stub AND DOMPurify-sanitize the composer body before
  innerHTML (defense in depth).
- Gate the NEW Messages routes with requireFeatureFlag('messaging') per-route
  (queue/:id, received/:id, item/*, identities, accounts, accounts/test, send)
  — NOT the shared /email mount, so the pre-existing email-config endpoints stay
  ungated.
- DocumentActionModal auto-picks a customer only on an EXACT email match
  (customer search is prefix/fuzzy), else leaves the picker to the admin.
This commit is contained in:
Luca
2026-07-07 18:28:26 +02:00
parent 99d5996561
commit bb235e72e5
4 changed files with 33 additions and 12 deletions
@@ -48,7 +48,15 @@ export const DocumentActionModal: React.FC<{
let cancelled = false;
setResolving(true);
customerAdminService.search(senderEmail)
.then((rows) => { if (!cancelled && rows.length) pick(rows[0]); })
.then((rows) => {
if (cancelled) return;
// search matches email/name/company PREFIXES — only auto-pick on an
// EXACT email match so a spoofed/partial sender can't prefill the wrong
// customer. Otherwise leave the picker for the admin to choose.
const target = senderEmail.trim().toLowerCase();
const exact = rows.find((r) => (r.email || '').toLowerCase() === target);
if (exact) pick(exact);
})
.catch(() => {})
.finally(() => { if (!cancelled) setResolving(false); });
return () => { cancelled = true; };
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import DOMPurify from 'dompurify';
import { X, Send as SendIcon } from 'lucide-react';
import { toast } from 'react-toastify';
import { emailService } from '../../../services/email.service';
@@ -35,7 +36,9 @@ export const MessageComposer: React.FC<{
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (bodyRef.current) bodyRef.current.innerHTML = init.html || '';
// Sanitize before it hits the contentEditable innerHTML — the initial body
// can include untrusted text (e.g. an inbound sender name in a reply stub).
if (bodyRef.current) bodyRef.current.innerHTML = DOMPurify.sanitize(init.html || '');
// Load initial body exactly once; further edits are the admin's.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -55,6 +55,12 @@ const fmt = (s?: string | null) =>
// narrow sidebar); full address stays in the hover title.
const localPart = (addr?: string | null) => (addr ? `${addr.split('@')[0]}@` : '');
// Escape untrusted text before it goes into an HTML string. The inbound From
// header carries an attacker-controlled display name; the reply stub builds raw
// HTML for the (contentEditable) composer, so this MUST be escaped there.
const escapeHtml = (s: string) =>
s.replace(/[&<>"']/g, (c) => (({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' } as Record<string, string>)[c]));
const STATUS_STYLES: Record<string, string> = {
sent: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
ingested: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
@@ -527,7 +533,7 @@ const ReadingPane: React.FC<{
? () => {
const it = selection.item;
const subj = /^re:/i.test(it.subject || '') ? (it.subject || '') : `Re: ${it.subject || ''}`;
const quoted = `<p><br></p><p style="color:#888;font-size:12px">${t('messages.onWrote', 'On')} ${fmt(it.received_at)}, ${it.from_address}:</p>`;
const quoted = `<p><br></p><p style="color:#888;font-size:12px">${t('messages.onWrote', 'On')} ${fmt(it.received_at)}, ${escapeHtml(it.from_address || '')}:</p>`;
onCompose({ to: it.from_address || '', subject: subj, html: quoted, replyToReceivedId: it.id }, t('messages.reply', 'Reply'));
}
: undefined;