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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user