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);