fix: Email-Anhang öffnet wieder zuverlässig im neuen Tab

Manche Mail-Clients setzen für PDF-Anhänge fälschlich
Content-Type: application/octet-stream (oder application/x-pdf,
"PDF Document" usw.). Der bisherige Whitelist-Check fiel dann
auf Content-Disposition: attachment zurück – der Browser hat
trotz target="_blank" am <a>-Tag KEINEN neuen Tab geöffnet,
sondern die Datei direkt im aktuellen Tab "geöffnet" (Download
oder native PDF-Anzeige), je nach Browser-Konfiguration. Effekt
für den User: Klick auf Vorschau-Icon → Vorschau ersetzt das
CRM-UI.

Fix: Magic-Byte-Detection direkt am Buffer (gleiche Logik wie
beim /api/files/download-Endpoint). PDF/PNG/JPEG/GIF/WebP werden
zuverlässig erkannt, der vom IMAP gemeldete Type wird ignoriert
(real-world unzuverlässig). Bei Match → inline mit erkanntem
Type; sonst attachment + octet-stream. text/plain bleibt durch
einen schwächeren Sniff-Check zugelassen, sofern keine HTML-
Tags am Anfang stehen.

Stored-XSS-Schutz unverändert: HTML-Anhang mit .pdf-Endung →
kein PDF-Magic → kein inline → attachment + octet-stream → kein
Browser-Rendern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 09:24:13 +02:00
parent 0bd2f9be7e
commit c93d4375ab
@@ -578,24 +578,44 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
}
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
// o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen
// zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS
// via inline-HTML-Anhang zu verhindern. Zusätzlich nosniff setzen.
const INLINE_SAFE_TYPES = new Set([
'application/pdf',
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
'image/svg+xml' /* wird unten trotzdem als download erzwungen */,
'text/plain',
]);
const rawType = (attachment.contentType || 'application/octet-stream').toLowerCase();
// SVG kann Skripte enthalten → niemals inline
const isSafeInline = INLINE_SAFE_TYPES.has(rawType) && rawType !== 'image/svg+xml';
// o.ä. sein. Für inline-Preview verlässt sich der Server nicht auf den
// gemeldeten Type, sondern prüft die Magic-Bytes des Buffer-Inhalts.
// Real-world-Problem (intern gemeldet 2026-05-30): manche Mail-Clients
// setzen für PDF-Anhänge `application/octet-stream` → unser alter
// Whitelist-Check fiel auf attachment zurück, der Browser öffnete
// trotz target="_blank" keinen neuen Tab. Mit Magic-Byte-Detection
// wird der echte Typ erkannt und inline-Preview klappt zuverlässig.
const buf: Buffer = attachment.content;
let detectedType: string | null = null;
if (buf.length >= 5 && buf.subarray(0, 5).toString('latin1') === '%PDF-') {
detectedType = 'application/pdf';
} else if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
detectedType = 'image/png';
} else if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
detectedType = 'image/jpeg';
} else if (buf.length >= 6 && (buf.subarray(0, 6).toString('latin1') === 'GIF87a' || buf.subarray(0, 6).toString('latin1') === 'GIF89a')) {
detectedType = 'image/gif';
} else if (buf.length >= 12 && buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP') {
detectedType = 'image/webp';
} else if (buf.length >= 1 && (attachment.contentType || '').toLowerCase().startsWith('text/plain')) {
// text/plain hat keine eindeutige Magic-Byte akzeptieren wenn
// der IMAP-Header das so meldet und Inhalt nur druckbare ASCII/UTF-8 ist.
// Konservative Prüfung: keine HTML-Tag-Anfänge.
const sample = buf.subarray(0, Math.min(buf.length, 256)).toString('utf8');
if (!/<[a-z!\/?]/i.test(sample)) {
detectedType = 'text/plain; charset=utf-8';
}
}
const isSafeInline = detectedType !== null;
const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment';
const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment';
// Filename: Steuerzeichen entfernen (CRLF-Injection in Header)
const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Type', isSafeInline ? rawType : 'application/octet-stream');
// Bei sicherem Inline-Typ: erkannten Type setzen (überschreibt
// eventuell falsches application/octet-stream aus IMAP). Sonst
// octet-stream erzwingen, damit der Browser nichts erraten kann.
res.setHeader('Content-Type', isSafeInline ? detectedType! : 'application/octet-stream');
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"`);
res.setHeader('Content-Length', attachment.size);
res.send(attachment.content);