From c93d4375ab1de6453005f096280e4275d9289a46 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 30 May 2026 09:24:13 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Email-Anhang=20=C3=B6ffnet=20wieder=20zu?= =?UTF-8?q?verl=C3=A4ssig=20im=20neuen=20Tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -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) --- .../src/controllers/cachedEmail.controller.ts | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/backend/src/controllers/cachedEmail.controller.ts b/backend/src/controllers/cachedEmail.controller.ts index 84210f23..7d7f30ed 100644 --- a/backend/src/controllers/cachedEmail.controller.ts +++ b/backend/src/controllers/cachedEmail.controller.ts @@ -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);